From 2fd7fa6445532f7e717b218f2221a4cf9210a1a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:23:17 +0000 Subject: [PATCH 1/4] Start fix for #1598 Assisted-by: Claude Code:claude-opus-5-5 From cd59fac05e083dd349a6f70ff22bdb5795e8edc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:36:05 +0000 Subject: [PATCH 2/4] Fix yarn copies with invalid manifest names A yarn portal:/link:/file: copy whose package.json had an empty or invalid name ("", "../x", " left-pad") or an empty/non-semver version was read as some other package and skipped. Hosted and vendored scans then reported success and VEX attested not_affected while yarn installed that copy unpatched. Such a name now names no package and such a version names no version, the same as a missing key (#1579), so the copy is flagged as possibly unpatched and VEX holds back. Yarn 1 file: copies share the reader and get the same treatment. Fixes #1598 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/hosted_memory_engine.rs | 30 ++++ .../src/formats/yarn/copy.rs | 84 ++++++++++- .../src/formats/yarn/source.rs | 10 +- .../src/vex/discover/yarn.rs | 138 ++++++++++++++++++ docs/ecosystems.md | 5 +- 5 files changed, 253 insertions(+), 14 deletions(-) diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index e42ff3958..88843322d 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -2017,3 +2017,33 @@ async fn issue_1579_berry_portal_placeholder_and_versionless_copies_are_named() } } } + +/// #1598: a linked manifest whose `name` is empty or no valid npm name +/// names no package, and one whose `version` is empty or no semver names +/// no version, exactly like a missing key (#1579). The hosted scan read +/// them literally ("a package called `""`", "another version"), reported a +/// clean success, and its in-run VEX attested `not_affected` while yarn +/// linked unpatched left-pad as `node_modules/lp2`. Now each is named. +#[tokio::test] +async fn issue_1598_berry_invalid_manifest_name_or_version_is_named() { + const UUID: &str = "15981598-1598-4598-8598-159815981598"; + for protocol in ["portal", "link"] { + let spec = format!("{protocol}:./vend/lp"); + for text in [ + r#"{"name":"","version":"1.3.0"}"#, + r#"{"name":"../x","version":"1.3.0"}"#, + r#"{"name":" left-pad","version":"1.3.0"}"#, + r#"{"name":"left-pad","version":""}"#, + r#"{"name":"left-pad","version":"v1.3.0"}"#, + ] { + let details = + berry_linked_copy_details(UUID, &spec, &[("vend/lp/package.json", text)]).await; + assert_eq!(details.len(), 1, "{protocol} {text}: {details:?}"); + assert!( + details[0].contains(&format!("lp2@{spec}::")) && details[0].contains("UNPATCHED"), + "{protocol} {text}: {}", + details[0] + ); + } + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/copy.rs b/crates/socket-patch-core/src/formats/yarn/copy.rs index 12daca80e..09cab0c88 100644 --- a/crates/socket-patch-core/src/formats/yarn/copy.rs +++ b/crates/socket-patch-core/src/formats/yarn/copy.rs @@ -81,8 +81,14 @@ impl CopyRef { CopyRef::Directory(_) => manifest_name(bytes?)?, CopyRef::Linked(..) => { let manifest = parse_manifest(bytes?)?; - let name = manifest.get("name")?.as_str()?.to_string(); - let version = manifest.get("version")?.as_str()?.to_string(); + let name = package_name(&manifest)?; + // An empty or non-semver version names no version (#1598), + // as a missing one does (#1579). + let version = manifest + .get("version")? + .as_str() + .filter(|v| semver::Version::parse(v).is_ok())? + .to_string(); return Some((name, version)); } CopyRef::Url(url) => registry_tarball_name(url, lock_version)?, @@ -97,9 +103,7 @@ impl CopyRef { /// invalid npm name names none, so the copy may be any package). pub(crate) fn linked_name(&self, bytes: Option<&[u8]>) -> Option { match self { - CopyRef::Linked(..) => { - manifest_name(bytes?).filter(|n| crate::vendor::is_safe_npm_name(n)) - } + CopyRef::Linked(..) => manifest_name(bytes?), _ => None, } } @@ -234,11 +238,23 @@ fn copy_path(base: &str, path: &str) -> Option { )) } -fn parse_manifest(bytes: &[u8]) -> Option { +pub(super) fn parse_manifest(bytes: &[u8]) -> Option { let bytes = crate::formats::text::strip_bom_bytes(bytes); serde_json::from_slice::(bytes).ok() } +/// A parsed manifest's `name`, when it names a package at all. An empty +/// name, or one no package can carry (a path such as `../x`, whitespace), +/// names none, so a copy holding it may be any package (#1579, #1598): +/// reading it literally would call the copy "some other package" and let +/// it escape every contest. +pub(super) fn package_name(manifest: &serde_json::Value) -> Option { + let name = manifest.get("name")?.as_str()?; + (crate::vendor::is_safe_npm_name(name) + && !name.chars().any(|c| c.is_whitespace() || c.is_control())) + .then(|| name.to_string()) +} + /// `name` of the `package.json` inside an npm tarball. fn tarball_manifest_name(bytes: &[u8]) -> Option { let map = crate::patch::package::read_archive_bytes_to_map_any_root(bytes).ok()?; @@ -405,6 +421,62 @@ mod tests { assert_eq!(dir.identity("1.3.0", None), None); } + /// #1598: an empty or invalid npm name names no package, and an empty + /// or non-semver version names no version, as a missing key does + /// (#1579), for every copy whose manifest is read. + #[test] + fn invalid_manifest_name_or_version_identifies_nothing() { + let linked = CopyRef::Linked("vend/lp".into(), LinkKind::Portal); + let dir = CopyRef::Directory("lpdir".into()); + let tarball = CopyRef::Tarball("lp.tgz".into()); + for name in ["", "../x", " left-pad", "left-pad ", "left pad", "a/b/c"] { + let manifest = serde_json::json!({ "name": name, "version": "1.3.0" }).to_string(); + assert_eq!( + linked.identity("0.0.0-use.local", Some(manifest.as_bytes())), + None, + "linked {name:?}" + ); + assert_eq!(linked.linked_name(Some(manifest.as_bytes())), None); + assert_eq!( + dir.identity("1.3.0", Some(manifest.as_bytes())), + None, + "directory {name:?}" + ); + let bytes = crate::vex::discover::testing::npm_tgz_rooted("package", name, "1.3.0"); + assert_eq!( + tarball.identity("1.3.0", Some(&bytes)), + None, + "tarball {name:?}" + ); + } + for version in ["", " 1.3.0", "v1.3.0", "latest", "1.3"] { + let manifest = + serde_json::json!({ "name": "left-pad", "version": version }).to_string(); + assert_eq!( + linked.identity("0.0.0-use.local", Some(manifest.as_bytes())), + None, + "linked version {version:?}" + ); + assert_eq!( + linked.linked_name(Some(manifest.as_bytes())).as_deref(), + Some("left-pad"), + "a usable name still says which package (#1579)" + ); + } + // Valid spellings keep their identity. + for (name, version) in [ + ("left-pad", "1.3.0"), + ("@scope/left-pad", "1.3.0-beta.1"), + ("Left_Pad.js", "1.3.0+build.5"), + ] { + let manifest = serde_json::json!({ "name": name, "version": version }).to_string(); + assert_eq!( + linked.identity("0.0.0-use.local", Some(manifest.as_bytes())), + Some((name.into(), version.into())) + ); + } + } + /// #1579: a linked manifest naming a package but no version names that /// package; an empty or invalid name names none. #[test] diff --git a/crates/socket-patch-core/src/formats/yarn/source.rs b/crates/socket-patch-core/src/formats/yarn/source.rs index c08f7ec6c..0c8bfc516 100644 --- a/crates/socket-patch-core/src/formats/yarn/source.rs +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -127,14 +127,10 @@ pub(crate) fn classic_copy_real_name( } } -/// `name` of a `package.json`. +/// `name` of a `package.json`, when it names a package +/// ([`super::copy::package_name`]). pub(crate) fn manifest_name(bytes: &[u8]) -> Option { - let bytes = crate::formats::text::strip_bom_bytes(bytes); - serde_json::from_slice::(bytes) - .ok()? - .get("name")? - .as_str() - .map(str::to_string) + super::copy::package_name(&super::copy::parse_manifest(bytes)?) } /// The package an npm registry tarball url serves, from its diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index f6401483c..95d24de68 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -2119,6 +2119,144 @@ mod tests { ); } + /// #1598: a copy's manifest whose `name` is empty or no valid npm name + /// (`""`, `"../x"`, `" left-pad"`) names no package, and one whose + /// `version` is empty or no valid semver names no version, exactly like + /// a manifest that lacks the key (#1579). Each was read as a copy of a + /// package literally called `""` (or of "another version"), so it was + /// dropped as unrelated and `vex` attested `not_affected` while yarn + /// installed unpatched left-pad@1.3.0 as `node_modules/lp2`. Berry + /// `portal:` / `link:` / `file:` directories and yarn 1 `file:` + /// directories and tarballs alike now contest the wiring. + #[tokio::test] + async fn issue_1598_invalid_manifest_name_or_version_contests() { + let wired = berry_vendored_block("left-pad", "1.3.0", UUID_A); + let resolutions = package_json_with_resolutions(serde_json::json!({ + "left-pad": format!("file:./.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"), + })); + let invalid_names = ["", "../x", " left-pad", "left pad"]; + let mut cases: Vec<(String, String, String)> = Vec::new(); + for protocol in ["portal", "link"] { + for name in invalid_names { + cases.push(( + protocol.into(), + serde_json::json!({ "name": name, "version": "1.3.0" }).to_string(), + "can't be read".into(), + )); + } + for version in ["", "v1.3.0", " 1.3.0", "latest"] { + cases.push(( + protocol.into(), + serde_json::json!({ "name": "left-pad", "version": version }).to_string(), + "names left-pad but no version".into(), + )); + } + } + for (protocol, manifest, why) in cases { + let p = Project::new(); + p.write("vend/lp/package.json", &manifest); + let descriptor = format!("lp2@{protocol}:./vend/lp::locator=app%40workspace%3A."); + p.write( + "yarn.lock", + berry(&[ + wired.clone(), + berry_block(&descriptor, "0.0.0-use.local", &descriptor, None), + ]), + ); + p.write("package.json", resolutions.clone()); + let out = run(&p).await; + assert!( + out.refs.is_empty(), + "{protocol} {manifest}: {:#?}", + out.refs + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains(&format!("lp2@{protocol}:")) + && d.detail.contains(&why)), + "{protocol} {manifest}: {:#?}", + out.diagnostics + ); + } + + // A berry `file:` directory holds its lock version; only its name + // is read. + for name in invalid_names { + let p = Project::new(); + p.write( + "vend/lp/package.json", + serde_json::json!({ "name": name, "version": "1.3.0" }).to_string(), + ); + let descriptor = "lp2@file:./vend/lp::locator=app%40workspace%3A."; + p.write( + "yarn.lock", + berry(&[ + wired.clone(), + berry_block(descriptor, "1.3.0", descriptor, None), + ]), + ); + p.write("package.json", resolutions.clone()); + let out = run(&p).await; + assert!(out.refs.is_empty(), "berry file: {name:?}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE && d.detail.contains("lp2@file:")), + "berry file: {name:?}: {:#?}", + out.diagnostics + ); + } + + // yarn 1 `file:` directories and tarballs share the name reader. + let classic_wired = classic_block( + "left-pad@1.3.0", + "1.3.0", + &format!("file:./.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz#{SHA1}"), + Some(SRI), + ); + let copies = [ + ( + "file: directory", + "\"lp2@file:./lpdir\":\n version \"1.3.0\"\n\n".to_string(), + ), + ( + "file: tarball", + classic_block( + "\"lp2@file:./forks/left-pad-1.3.0.tgz\"", + "1.3.0", + &format!("file:./forks/left-pad-1.3.0.tgz#{SHA1}"), + None, + ), + ), + ]; + for name in invalid_names { + for (case, copy) in &copies { + let p = Project::new(); + p.write("yarn.lock", classic(&[classic_wired.clone(), copy.clone()])); + p.write( + "lpdir/package.json", + serde_json::json!({ "name": name, "version": "1.3.0" }).to_string(), + ); + p.write("forks/left-pad-1.3.0.tgz", npm_tgz(name, "1.3.0")); + let out = run(&p).await; + assert!( + out.refs.is_empty(), + "classic {case} {name:?}: {:#?}", + out.refs + ); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE && d.detail.contains("lp2@")), + "classic {case} {name:?}: {:#?}", + out.diagnostics + ); + } + } + } + /// #1540 control: a RELATIVE `link:` to a plain directory (no /// `package.json`, which `link:` allows) is read and holds no package, /// so it contests nothing. diff --git a/docs/ecosystems.md b/docs/ecosystems.md index c35f6d056..a4c88b011 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -304,7 +304,10 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: submodule CI didn't check out, or whose `package.json` is no JSON, such as a Git LFS pointer, or whose `package.json` names no package) has no version either, so it may be any version of any package and `vex` attests nothing that lock wires. One whose - `package.json` names a package but no version may be any version of that package. A + `package.json` names a package but no version may be any version of that package. An + empty `name`, or one no npm package can carry (`"../x"`, `" left-pad"`), names no + package, and an empty or non-semver `version` (`""`, `"v1.3.0"`) names no version, for + every copy whose `package.json` is read (#1598). A `portal:` to a directory with no `package.json` (a `.gitkeep` placeholder a fetch step fills) is unknown too, since yarn refuses to install a portal without one; only a `link:` to a non-empty directory with no `package.json` is not a package copy. Use a From a3a6d63b245db2a2a4a59ac63fedbc26079c5f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:52:44 +0000 Subject: [PATCH 3/4] Use npm's own name and version rules for copies A copy whose package.json name has characters npm never allows ("left-pad?", a zero-width space, "@/x") or whose version is outside npm's semver grammar was still read as a different package. Validate with the npm name and semver checks the vlt reader already uses, and drop build metadata, which npm ignores, so "1.3.0+build" counts as 1.3.0. Refs #1598 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/yarn/copy.rs | 76 +++++++++++++------ .../src/formats/yarn/source.rs | 7 +- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-core/src/formats/yarn/copy.rs b/crates/socket-patch-core/src/formats/yarn/copy.rs index 09cab0c88..33e5229fa 100644 --- a/crates/socket-patch-core/src/formats/yarn/copy.rs +++ b/crates/socket-patch-core/src/formats/yarn/copy.rs @@ -11,7 +11,7 @@ //! [`CopyRef`], and so cannot disagree on which package it is. use super::patterns::split_classic_pattern; -use super::source::{is_tarball_path, manifest_name, registry_tarball_name}; +use super::source::{is_tarball_path, registry_tarball_name}; /// Where a yarn lock entry's copy comes from, read for the package it holds. #[derive(Debug, Clone, PartialEq, Eq)] @@ -81,15 +81,7 @@ impl CopyRef { CopyRef::Directory(_) => manifest_name(bytes?)?, CopyRef::Linked(..) => { let manifest = parse_manifest(bytes?)?; - let name = package_name(&manifest)?; - // An empty or non-semver version names no version (#1598), - // as a missing one does (#1579). - let version = manifest - .get("version")? - .as_str() - .filter(|v| semver::Version::parse(v).is_ok())? - .to_string(); - return Some((name, version)); + return Some((package_name(&manifest)?, package_version(&manifest)?)); } CopyRef::Url(url) => registry_tarball_name(url, lock_version)?, }; @@ -238,21 +230,35 @@ fn copy_path(base: &str, path: &str) -> Option { )) } -pub(super) fn parse_manifest(bytes: &[u8]) -> Option { +fn parse_manifest(bytes: &[u8]) -> Option { let bytes = crate::formats::text::strip_bom_bytes(bytes); serde_json::from_slice::(bytes).ok() } +/// `name` of a `package.json`, when it names a package ([`package_name`]). +pub(crate) fn manifest_name(bytes: &[u8]) -> Option { + package_name(&parse_manifest(bytes)?) +} + /// A parsed manifest's `name`, when it names a package at all. An empty -/// name, or one no package can carry (a path such as `../x`, whitespace), -/// names none, so a copy holding it may be any package (#1579, #1598): -/// reading it literally would call the copy "some other package" and let -/// it escape every contest. -pub(super) fn package_name(manifest: &serde_json::Value) -> Option { +/// name, or one npm could never have published (a path such as `../x`, +/// whitespace, any character outside npm's url-safe set), names none, so a +/// copy holding it may be any package (#1579, #1598): reading it literally +/// would call the copy "some other package" and let it escape every +/// contest. +fn package_name(manifest: &serde_json::Value) -> Option { let name = manifest.get("name")?.as_str()?; - (crate::vendor::is_safe_npm_name(name) - && !name.chars().any(|c| c.is_whitespace() || c.is_control())) - .then(|| name.to_string()) + crate::vendor::vlt_lock_text::is_registry_package_name(name).then(|| name.to_string()) +} + +/// A parsed manifest's `version`, when it names one: npm's semver grammar, +/// with build metadata dropped as npm drops it (`1.3.0+build.5` IS 1.3.0). +/// An empty or non-semver version (`""`, `v1.3.0`, `latest`) names none, +/// as a missing one does (#1579, #1598). +fn package_version(manifest: &serde_json::Value) -> Option { + let version = manifest.get("version")?.as_str()?; + crate::vendor::vlt_lock_text::is_npm_semver(version) + .then(|| version.split('+').next().unwrap_or(version).to_string()) } /// `name` of the `package.json` inside an npm tarball. @@ -429,7 +435,18 @@ mod tests { let linked = CopyRef::Linked("vend/lp".into(), LinkKind::Portal); let dir = CopyRef::Directory("lpdir".into()); let tarball = CopyRef::Tarball("lp.tgz".into()); - for name in ["", "../x", " left-pad", "left-pad ", "left pad", "a/b/c"] { + for name in [ + "", + "../x", + " left-pad", + "left-pad ", + "left pad", + "a/b/c", + "left-pad\u{200b}", + "left-pad?", + "@/left-pad", + ".left-pad", + ] { let manifest = serde_json::json!({ "name": name, "version": "1.3.0" }).to_string(); assert_eq!( linked.identity("0.0.0-use.local", Some(manifest.as_bytes())), @@ -449,7 +466,14 @@ mod tests { "tarball {name:?}" ); } - for version in ["", " 1.3.0", "v1.3.0", "latest", "1.3"] { + for version in [ + "", + " 1.3.0", + "v1.3.0", + "latest", + "1.3", + "9007199254740993.0.0", + ] { let manifest = serde_json::json!({ "name": "left-pad", "version": version }).to_string(); assert_eq!( @@ -467,7 +491,7 @@ mod tests { for (name, version) in [ ("left-pad", "1.3.0"), ("@scope/left-pad", "1.3.0-beta.1"), - ("Left_Pad.js", "1.3.0+build.5"), + ("Left_Pad.js", "1.3.0"), ] { let manifest = serde_json::json!({ "name": name, "version": version }).to_string(); assert_eq!( @@ -475,6 +499,14 @@ mod tests { Some((name.into(), version.into())) ); } + // Build metadata is no part of npm's version: the copy IS 1.3.0. + assert_eq!( + linked.identity( + "0.0.0-use.local", + Some(br#"{"name":"left-pad","version":"1.3.0+build.5"}"#) + ), + Some(("left-pad".into(), "1.3.0".into())) + ); } /// #1579: a linked manifest naming a package but no version names that diff --git a/crates/socket-patch-core/src/formats/yarn/source.rs b/crates/socket-patch-core/src/formats/yarn/source.rs index 0c8bfc516..126adef03 100644 --- a/crates/socket-patch-core/src/formats/yarn/source.rs +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -1,6 +1,7 @@ //! Where yarn 1 installs a classic lock block's copy from, decided once for //! every mode that reads or rewrites the block. +use super::copy::manifest_name; use super::patterns::split_classic_pattern; use crate::vendor::npm_origin::npm_spec_is_registry; @@ -127,12 +128,6 @@ pub(crate) fn classic_copy_real_name( } } -/// `name` of a `package.json`, when it names a package -/// ([`super::copy::package_name`]). -pub(crate) fn manifest_name(bytes: &[u8]) -> Option { - super::copy::package_name(&super::copy::parse_manifest(bytes)?) -} - /// The package an npm registry tarball url serves, from its /// `//-/-.tgz` path (`` may be `@scope/leaf`, /// its `@` / `/` possibly percent-encoded); `None` for any other shape. From 022189d19d730c933097345064f5caeab626bd47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:54:32 +0000 Subject: [PATCH 4/4] Test #1598 copy naming through the real binary The #1598 test only ran the in-memory engine. Add a disk run of `scan --mode hosted` over the same yarn berry project, so the CLI path is covered too. It fails without the fix and passes with it. Refs #1598 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AtKg1zkBqwqeqibLMwej8X --- .../tests/hosted_memory_engine.rs | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index 88843322d..ab354a335 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -1885,10 +1885,29 @@ async fn issue_939_berry_renamed_copy_is_named() { /// plus `extra` files, and return its `redirect_copy_unpatched` details /// (after checking the registry entry is still pinned). async fn berry_linked_copy_details(uuid: &str, spec: &str, extra: &[(&str, &str)]) -> Vec { - let ws = "app%40workspace%3A."; - let key = format!("lp2@{spec}::locator={ws}"); let server = MockServer::start().await; mount_left_pad_grant(&server, uuid).await; + let mut files = berry_linked_copy_files(spec); + for (rel, text) in extra { + files.insert(rel.to_string(), text.as_bytes().to_vec()); + } + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{spec}: {:?}", project.error); + assert_eq!( + project.redirected.len(), + 1, + "{spec}: {:?} {:#}", + project.skipped, + project.redirect + ); + copy_unpatched_details(project) +} + +/// A berry project depending on registry left-pad and on `lp2` at `spec`. +fn berry_linked_copy_files(spec: &str) -> BTreeMap> { + let ws = "app%40workspace%3A."; + let key = format!("lp2@{spec}::locator={ws}"); let mut files = BTreeMap::new(); files.insert( "package.json".to_string(), @@ -1914,20 +1933,7 @@ async fn berry_linked_copy_details(uuid: &str, spec: &str, extra: &[(&str, &str) ) .into_bytes(), ); - for (rel, text) in extra { - files.insert(rel.to_string(), text.as_bytes().to_vec()); - } - let output = run_engine(&server, build_input(&files, &[], options(false))).await; - let project = &output.projects[0]; - assert!(project.error.is_none(), "{spec}: {:?}", project.error); - assert_eq!( - project.redirected.len(), - 1, - "{spec}: {:?} {:#}", - project.skipped, - project.redirect - ); - copy_unpatched_details(project) + files } /// #1540: a berry `portal:` / `link:` copy with an ABSOLUTE path is never @@ -2047,3 +2053,35 @@ async fn issue_1598_berry_invalid_manifest_name_or_version_is_named() { } } } + +/// #1598 on disk: the real binary's `scan --mode hosted` over the same +/// project names the copy too, not only the in-memory engine. +#[tokio::test] +async fn issue_1598_berry_invalid_manifest_name_or_version_is_named_on_disk() { + const UUID: &str = "15981598-1598-4598-8598-159815981598"; + for text in [ + r#"{"name":"","version":"1.3.0"}"#, + r#"{"name":"../x","version":"1.3.0"}"#, + r#"{"name":"left-pad","version":""}"#, + ] { + let server = MockServer::start().await; + mount_left_pad_grant(&server, UUID).await; + let mut files = berry_linked_copy_files("portal:./vend/lp"); + files.insert("vend/lp/package.json".to_string(), text.as_bytes().to_vec()); + let disk = run_disk(&server, &files, false); + let warnings = disk.redirect["warnings"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + warnings + .iter() + .any(|w| w["code"] == "redirect_copy_unpatched" + && w["detail"] + .as_str() + .is_some_and(|d| d.contains("UNPATCHED"))), + "{text}: {:#}", + disk.envelope + ); + } +}