Repository: vercel/next.js
Pull request: Turbopack: fix invalid NFT entry with file behind symlink

The `.nft.json` file could contain

```
        "../../../../../../node_modules/.pnpm/node_modules/@libsql/client",
        "../../../../../../node_modules/.pnpm/node_modules/@libsql/client/package.json",
```

which was added by the `resolve` call of `module '@libsql' with subpath '/' <dynamic>` from here: https://github.com/tursodatabase/libsql-js/blob/8cc8069fa4f886c2c0bf335ddf5c11e3d4a46efa/index.js#L26

The first line is a symlink to a directory, but the second line is a file "inside" of the symlink. This would create invalid zip files and could cause deployment failures.

Also adds a debug assertion for this:

```
FATAL: An unexpected Turbopack error occurred:
Encountered file inside of symlink in NFT list: [project]/node_modules/.pnpm/libsql@0.5.17/node_modules/@libsql/darwin-arm64 is a symlink, but [project]/node_modules/.pnpm/libsql@0.5.17/node_modules/@libsql/darwin-arm64/package.json was created inside of it

Debug info:
- Execution of TaskId { id: 2147483650 } transient failed
- Execution of get_all_written_entrypoints_with_issues_operation failed
- Execution of EntrypointsOperation::new failed
- Execution of all_entrypoints_write_to_disk_operation failed
- Execution of Project::emit_all_output_assets failed
- Execution of emit_assets failed
- Execution of emit failed
- Execution of <NftJsonAsset as Asset>::content failed
- Encountered file inside of symlink in NFT list: [project]/node_modules/.pnpm/libsql@0.5.17/node_modules/@libsql/darwin-arm64 is a symlink, but [project]/node_modules/.pnpm/libsql@0.5.17/node_modules/@libsql/darwin-arm64/package.json was created inside of it
```

Part of CIBA-3032

Closes PACK-5302

Changed files:
filename: crates/next-api/src/nft_json.rs
patch: @@ -231,6 +231,38 @@ impl Asset for NftJsonAsset {
                 continue;
             }
 
+            #[cfg(debug_assertions)]
+            {
+                // Verify that we there are no entries where a file is created inside of a symlink,
+                // as this can result in invalid ZIP files and deployment failures.
+                // For example
+                // node_modules/.pnpm/node_modules/@libsql/client/package.json
+                // where
+                // node_modules/.pnpm/node_modules/@libsql/client is a symlink
+                let mut current_path = referenced_chunk_path.parent();
+                loop {
+                    use turbo_tasks_fs::FileSystemEntryType;
+
+                    if current_path.is_root() {
+                        break;
+                    }
+
+                    if matches!(
+                        &*current_path.get_type().await?,
+                        FileSystemEntryType::Symlink
+                    ) {
+                        bail!(
+                            "Encountered file inside of symlink in NFT list: {} is a symlink, but \
+                             {} was created inside of it",
+                            current_path.value_to_string().await?,
+                            referenced_chunk_path.value_to_string().await?
+                        );
+                    }
+
+                    current_path = current_path.parent();
+                }
+            }
+
             let Some(specifier) = get_output_specifier(
                 &referenced_chunk_path,
                 &ident_folder,
@@ -241,6 +273,7 @@ impl Asset for NftJsonAsset {
             else {
                 continue;
             };
+
             result.insert(specifier);
         }
 

filename: turbopack/crates/turbopack-core/src/resolve/mod.rs
patch: @@ -2133,58 +2133,70 @@ async fn resolve_into_folder(
     package_path: FileSystemPath,
     options: Vc<ResolveOptions>,
 ) -> Result<Vc<ResolveResult>> {
-    let package_json_path = package_path.join("package.json")?;
     let options_value = options.await?;
 
-    for resolve_into_package in options_value.into_package.iter() {
-        match resolve_into_package {
-            ResolveIntoPackage::MainField { field: name } => {
-                if let Some(package_json) =
-                    &*read_package_json(Vc::upcast(FileSource::new(package_json_path.clone())))
-                        .await?
-                    && let Some(field_value) = package_json[name.as_str()].as_str()
-                {
-                    let normalized_request = RcStr::from(normalize_request(field_value));
-                    if normalized_request.is_empty()
-                        || &*normalized_request == "."
-                        || &*normalized_request == "./"
+    let mut affecting_sources = vec![];
+    if let Some(package_json_path) =
+        exists(&package_path.join("package.json")?, &mut affecting_sources).await?
+    {
+        for resolve_into_package in options_value.into_package.iter() {
+            match resolve_into_package {
+                ResolveIntoPackage::MainField { field: name } => {
+                    if let Some(package_json) =
+                        &*read_package_json(Vc::upcast(FileSource::new(package_json_path.clone())))
+                            .await?
+                        && let Some(field_value) = package_json[name.as_str()].as_str()
                     {
-                        continue;
-                    }
-                    let request = Request::parse_string(normalized_request);
-
-                    // main field will always resolve not fully specified
-                    let options = if options_value.fully_specified {
-                        options.with_fully_specified(false).resolve().await?
-                    } else {
-                        options
+                        let normalized_request = RcStr::from(normalize_request(field_value));
+                        if normalized_request.is_empty()
+                            || &*normalized_request == "."
+                            || &*normalized_request == "./"
+                        {
+                            continue;
+                        }
+                        let request = Request::parse_string(normalized_request);
+
+                        // main field will always resolve not fully specified
+                        let options = if options_value.fully_specified {
+                            options.with_fully_specified(false).resolve().await?
+                        } else {
+                            options
+                        };
+                        let result =
+                            &*resolve_internal_inline(package_path.clone(), request, options)
+                                .await?
+                                .await?;
+                        // we are not that strict when a main field fails to resolve
+                        // we continue to try other alternatives
+                        if !result.is_unresolvable_ref() {
+                            let mut result: ResolveResultBuilder =
+                                result.with_request_ref(rcstr!(".")).into();
+                            result.affecting_sources.push(ResolvedVc::upcast(
+                                FileSource::new(package_json_path).to_resolved().await?,
+                            ));
+                            result.affecting_sources.extend(affecting_sources);
+                            return Ok(ResolveResult::from(result).cell());
+                        }
                     };
-                    let result = &*resolve_internal_inline(package_path.clone(), request, options)
-                        .await?
-                        .await?;
-                    // we are not that strict when a main field fails to resolve
-                    // we continue to try other alternatives
-                    if !result.is_unresolvable_ref() {
-                        let mut result: ResolveResultBuilder =
-                            result.with_request_ref(rcstr!(".")).into();
-                        result.affecting_sources.push(ResolvedVc::upcast(
-                            FileSource::new(package_json_path).to_resolved().await?,
-                        ));
-                        return Ok(ResolveResult::from(result).cell());
-                    }
-                };
+                }
+                ResolveIntoPackage::ExportsField { .. } => {}
             }
-            ResolveIntoPackage::ExportsField { .. } => {}
         }
     }
 
     if options_value.fully_specified {
-        return Ok(*ResolveResult::unresolvable());
+        return Ok(*ResolveResult::unresolvable_with_affecting_sources(
+            affecting_sources,
+        ));
     }
 
     // fall back to dir/index.[js,ts,...]
     let pattern = match &options_value.default_files[..] {
-        [] => return Ok(*ResolveResult::unresolvable()),
+        [] => {
+            return Ok(*ResolveResult::unresolvable_with_affecting_sources(
+                affecting_sources,
+            ));
+        }
         [file] => Pattern::Constant(format!("./{file}").into()),
         files => Pattern::Alternatives(
             files
@@ -2199,7 +2211,8 @@ async fn resolve_into_folder(
     Ok(
         resolve_internal_inline(package_path.clone(), request, options)
             .await?
-            .with_request(rcstr!(".")),
+            .with_request(rcstr!("."))
+            .with_affecting_sources(ResolvedVc::deref_vec(affecting_sources)),
     )
 }