From 716b756c74284b4c6afdf12baac804ff5de731fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:23:35 +0000 Subject: [PATCH 1/6] Start fix for #1561 Assisted-by: Claude Code:claude-opus-5-5 From 87d9321f372091f10fdc4882e4ca7b0013c1984f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:33:58 +0000 Subject: [PATCH 2/6] Test yarn berry missing portal:/link: copies A yarn berry portal: or link: dependency under another name whose relative target directory is missing (for example a git submodule CI didn't check out) is silently dropped today: hosted and vendored scans report success and vex attests not_affected, while the install that has the directory links an unpatched copy. Add regression tests for #1561 on the vex discovery, vendored berry and hosted engine paths. They fail on main. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/hosted_memory_engine.rs | 63 +++++++++++++++++++ .../src/vendor/yarn_berry_lock.rs | 48 ++++++++++++++ .../src/vex/discover/yarn.rs | 45 +++++++++++++ 3 files changed, 156 insertions(+) diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index d44408988..f11de821f 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -1941,3 +1941,66 @@ async fn issue_1540_berry_absolute_portal_and_link_copies_are_named() { ); } } + +/// #1561: a berry `portal:` / `link:` copy with a RELATIVE path whose +/// target is not in the snapshot (a git submodule CI never checked out) +/// can't be read, so like an absolute one (#1540) it may be any version of +/// any package. The hosted scan still pins the registry entry, and names +/// that copy as possibly unpatched (`redirect_copy_unpatched`); it used to +/// report a clean success while the install with the submodule linked +/// unpatched bytes as `node_modules/lp2`. +#[tokio::test] +async fn issue_1561_berry_missing_relative_portal_and_link_copies_are_named() { + const UUID: &str = "15611561-1561-4561-8561-156115611561"; + let ws = "app%40workspace%3A."; + for protocol in ["portal", "link"] { + let spec = format!("{protocol}:./vend/lp"); + let key = format!("lp2@{spec}::locator={ws}"); + let server = MockServer::start().await; + mount_left_pad_grant(&server, UUID).await; + let mut files = BTreeMap::new(); + files.insert( + "package.json".to_string(), + format!( + r#"{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"^1.3.0","lp2":"{spec}"}}}}"# + ) + .into_bytes(), + ); + files.insert( + "yarn.lock".to_string(), + format!( + "__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"app@workspace:.\":\n version: 0.0.0-use.local\n \ + resolution: \"app@workspace:.\"\n dependencies:\n \ + left-pad: \"npm:^1.3.0\"\n lp2: \"{spec}\"\n \ + languageName: unknown\n linkType: soft\n\n\ + \"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ + resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n \ + languageName: node\n linkType: hard\n\n\ + \"{key}\":\n version: 0.0.0-use.local\n resolution: \"{key}\"\n \ + languageName: node\n linkType: soft\n", + "1".repeat(128) + ) + .into_bytes(), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{protocol}: {:?}", project.error); + assert_eq!( + project.redirected.len(), + 1, + "{protocol}: {:?} {:#}", + project.skipped, + project.redirect + ); + let details = copy_unpatched_details(project); + assert_eq!(details.len(), 1, "{protocol}: {:#}", project.redirect); + assert!( + details[0].contains(key.as_str()) + && details[0].contains("can't be read") + && details[0].contains("UNPATCHED"), + "{protocol}: {}", + details[0] + ); + } +} diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 7b2ff0a74..f7c44bcee 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -2561,6 +2561,54 @@ __metadata: } } + /// #1561: a RELATIVE `portal:` / `link:` copy whose target directory is + /// missing (a git submodule CI never checked out) is as unknown as an + /// absolute one (#1540): named as a possible unpatched copy, while the + /// registry entry still vendors. It was dropped silently. Control: a + /// `link:` to an existing plain directory with no `package.json` is no + /// package copy and stays unnamed. + #[tokio::test] + async fn issue_1561_missing_relative_portal_and_link_targets_are_named() { + for protocol in ["portal", "link"] { + for plain_dir in [false, true] { + let key = format!("lp2@{protocol}:./vend/lp::locator=vendor-spike%40workspace%3A."); + let block = format!( + "\n\"{key}\":\n version: 0.0.0-use.local\n resolution: \"{key}\"\n \ + languageName: node\n linkType: soft\n" + ); + let lock = format!("{B3_BEFORE_LOCK}{block}"); + let fx = fixture_with(B3_BEFORE_PKG, &lock).await; + if plain_dir { + let dir = fx.tmp.path().join("vend/lp"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("logo.svg"), "") + .await + .unwrap(); + } + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{protocol}/{plain_dir}: {:?}", result.error); + assert!(entry.is_some(), "{protocol}/{plain_dir}: registry vendors"); + let copies: Vec<&VendorWarning> = warnings + .iter() + .filter(|w| w.code == "vendor_copy_unpatched") + .collect(); + if plain_dir { + assert!(copies.is_empty(), "{protocol}: {warnings:?}"); + continue; + } + assert_eq!(copies.len(), 1, "{protocol}: {warnings:?}"); + assert!( + copies[0].detail.contains(&format!("`\"{key}\"`")) + && copies[0].detail.contains("installs a package under") + && copies[0].detail.contains("can't be read"), + "{protocol}: {}", + copies[0].detail + ); + } + } + } + /// #939 (review): when left-pad@1.3.0 is locked ONLY under another /// dependency name, vendoring refuses `vendor_lock_entry_not_rewritable` /// naming that copy, in the loop and in the download plan alike, not diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 6122bf1e6..bb6dcd91d 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -1952,6 +1952,51 @@ mod tests { } } + /// #1561: a RELATIVE `portal:` / `link:` copy whose target can't be read + /// (a missing directory, e.g. a git submodule CI never checked out, or a + /// `package.json` that is no regular file) is as unknown as an absolute + /// one (#1540): it may be any version of any package, so it contests + /// every ref its lock wires. It was dropped like a `link:` to a plain + /// directory, so `vex` attested `not_affected` while the install that + /// had the target linked unpatched left-pad@1.3.0 as `node_modules/lp2`. + #[tokio::test] + async fn issue_1561_unreadable_relative_portal_and_link_targets_contest_every_ref() { + let wired = berry_vendored_block("left-pad", "1.3.0", UUID_A); + for protocol in ["portal", "link"] { + for target in ["missing", "manifest-is-a-directory"] { + let p = Project::new(); + if target == "manifest-is-a-directory" { + p.write("vend/lp/package.json/index.js", ""); + } + 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", + package_json_with_resolutions(serde_json::json!({ + "left-pad": format!("file:./.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"), + })), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{protocol} {target}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains(&format!("lp2@{protocol}:")) + && d.detail.contains("can't be read")), + "{protocol} {target}: {:#?}", + 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. From e89b9db7468c74e723f01f885112120a0ac25c9f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:42:39 +0000 Subject: [PATCH 3/6] Flag yarn berry portal:/link: copies with no target A yarn berry portal: or link: dependency under another name gets its package and version only from its package.json. When a relative target was missing (an uninitialized git submodule, a generated or gitignored directory) or its package.json couldn't be read, the copy was treated like a link: to a plain asset directory and dropped. Scans reported success and vex attested not_affected, while the install that had the directory linked an unpatched copy. Such a copy is now unknown, like an absolute one: hosted warns redirect_copy_unpatched, vendored warns vendor_copy_unpatched, and vex withholds every package the lock wires. A link: to a directory that exists with no package.json is still ignored. Fixes #1561 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/vendor/yarn_berry_lock.rs | 15 +++--- .../src/vendor/yarn_copies.rs | 46 +++++++++++++++++-- .../src/vex/discover/yarn.rs | 22 +++++---- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index f7c44bcee..59823f86e 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -276,13 +276,14 @@ impl NpmLockBackend for YarnBerryBackend { vendoring writes never reaches it, so that copy stays UNPATCHED" ) } else { - // An unread `portal:` / `link:` copy has no version - // to name (#1540). - let what = if copy.source.is_unread_linked() { - "a package".to_string() - } else { - format!("version {version}") - }; + // An unidentified `portal:` / `link:` copy has no + // version to name (#1540, #1561). + let what = + if matches!(copy.source, crate::formats::yarn::copy::CopyRef::Linked(_)) { + "a package".to_string() + } else { + format!("version {version}") + }; format!( "{YARN_LOCK} entry `{key}` installs {what} under another \ dependency name, from {origin}, whose package can't be read, so it \ diff --git a/crates/socket-patch-core/src/vendor/yarn_copies.rs b/crates/socket-patch-core/src/vendor/yarn_copies.rs index 847d78f0c..28e4878ec 100644 --- a/crates/socket-patch-core/src/vendor/yarn_copies.rs +++ b/crates/socket-patch-core/src/vendor/yarn_copies.rs @@ -20,6 +20,7 @@ use crate::formats::yarn::source::{classic_copy_source, CopySource}; use crate::utils::fs::read_regular_to_bytes; use super::lock_inventory::yarn::{berry_entries, classic_entries}; +use super::lock_inventory::ProjectView; /// A lock entry under another dependency name that installs, or may /// install, `name@version` ([`other_name_copies`]). @@ -42,9 +43,10 @@ pub(crate) struct OtherNameCopy { /// that version whose package cannot be read is kept, unidentified: it /// may hold `name`, and leaving it out would let the scan and `vex` vouch /// for a lock that installs it unpatched (#1516). So is a `portal:` / -/// `link:` copy that is never read (an absolute path): its version is its -/// manifest's, so it may be `version` too (#1540). One read and found to -/// hold no versioned package (a `link:` to a plain directory) is left out. +/// `link:` copy whose package is unknown ([`linked_copy_unknown`]): its +/// version is its manifest's, so it may be `version` too (#1540, #1561). +/// One that holds no versioned package (a `link:` to a plain directory) is +/// left out. pub(crate) async fn other_name_copies( project_root: &Path, lock_text: &str, @@ -107,10 +109,15 @@ pub(crate) async fn other_name_copies( Some(rel) => read_regular_to_bytes(&project_root.join(rel)).await.ok(), None => None, }; + let read = bytes.is_some(); let identified = match copy_identity(&source, &lock_version, bytes).await { Some((n, v)) if n == name && v == version => true, Some(_) => continue, - None if matches!(source, CopyRef::Linked(_)) && !source.is_unread_linked() => continue, + None if matches!(source, CopyRef::Linked(_)) + && !linked_copy_unknown(ProjectView::Disk(project_root), &source, read).await => + { + continue + } None => false, }; out.push(OtherNameCopy { @@ -122,6 +129,37 @@ pub(crate) async fn other_name_copies( out } +/// Whether a `portal:` / `link:` copy whose manifest named no versioned +/// package may be any version of any package, given whether its +/// `package.json` was `read`. Its lock version is the placeholder +/// `0.0.0-use.local`, so only that manifest says what it is. One never +/// read (an absolute path, #1540) is unknown, and so is one whose target +/// can't be read: a missing directory (a git submodule CI never checked +/// out, a generated or gitignored directory) or a `package.json` that is +/// no readable regular file (#1561). Only a directory that exists with no +/// `package.json` at all (a `link:` to plain assets), or a manifest read +/// and found to name no versioned package, is no package copy. +pub(crate) async fn linked_copy_unknown( + view: ProjectView<'_>, + source: &CopyRef, + read: bool, +) -> bool { + let CopyRef::Linked(dir) = source else { + return false; + }; + if source.is_unread_linked() { + return true; + } + if read { + return false; + } + let Some(manifest) = source.read_path() else { + return true; + }; + let dir = if dir.is_empty() { "." } else { dir.as_str() }; + !view.is_dir(dir) || view.exists_no_follow(&manifest).await +} + /// [`CopyRef::identity`] off the async runtime when it unpacks a tarball (a /// user's `file:` tarball can be large); lockfile discovery reads copies /// through it too. diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index bb6dcd91d..4972e3bfe 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -878,32 +878,38 @@ struct LockCopy { /// non-registry url) may be any package at its lock version, so it is /// recorded as a copy of every package this lock wires at that version: /// vouching for those would rest on a copy nobody read (#1516). A -/// `portal:` / `link:` copy has no lock version: one never read (an -/// absolute path) may be any version of any package, and contests every -/// package this lock wires (#1540); one read and found to hold no -/// versioned package (a `link:` to a plain directory) is left alone. +/// `portal:` / `link:` copy has no lock version: one whose package is +/// unknown (never read, an absolute path, or a target that can't be read, +/// [`crate::vendor::yarn_copies::linked_copy_unknown`]) may be any version +/// of any package, and contests every package this lock wires (#1540, +/// #1561); one that holds no versioned package (a `link:` to a plain +/// directory) is left alone. async fn record_copies(ctx: &DiscoverCtx<'_>, copies: Vec, out: &mut Discovery) { for copy in copies { let bytes = match copy.source.read_path() { Some(rel) => ctx.read_advisory_bytes(&rel).await, None => None, }; + let read = bytes.is_some(); let Some((name, version)) = crate::vendor::yarn_copies::copy_identity(©.source, ©.version, bytes).await else { - let unread_linked = copy.source.is_unread_linked(); - if matches!(copy.source, CopyRef::Linked(_)) && !unread_linked { + let linked = matches!(copy.source, CopyRef::Linked(_)); + if linked + && !crate::vendor::yarn_copies::linked_copy_unknown(ctx.view, ©.source, read) + .await + { continue; } out.unidentified_copy( YARN_LOCK, - (!unread_linked).then_some(copy.version.as_str()), + (!linked).then_some(copy.version.as_str()), ©.key, &format!( "{}, whose package can't be read, so it may be {} of any package \ (yarn keys it by the dependency name)", copy.source.how(), - if unread_linked { + if linked { "any version" } else { "this version" From 39b542b2e260261f977f0377027011ef587a8372 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:43:11 +0000 Subject: [PATCH 4/6] Docs: missing yarn portal:/link: targets Say that a relative portal: or link: copy whose target is missing is treated like an absolute one, and that a link: to a directory with no package.json is not a package copy. Refs #1561 Assisted-by: Claude Code:claude-opus-5-5 --- docs/ecosystems.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 9d6762c2a..1645a12ca 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -296,8 +296,10 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: is named as a copy it may be, and `vex` attests nothing that lock wires at that version. An absolute path (`portal:/home/me/lp`, `file:/abs/lp.tgz`) is never read, as the lock is untrusted input; a `portal:` / `link:` copy whose `package.json` isn't - read has no version either, so it may be any version of any package and `vex` attests - nothing that lock wires. Use a relative path to let the copy be read. Hosted mode still pins the + read (an absolute path, or a relative target that is missing, such as a git submodule + CI didn't check out) has no version either, so it may be any version of any package + and `vex` attests nothing that lock wires. A `link:` to a directory that exists with no + `package.json` is not a package copy. Use a relative path to let the copy be read. Hosted mode still pins the registry entry and names the copy (`redirect_copy_unpatched`, unless a yarn classic skip warning already names it); vendored mode still wires the registry entry and names it (`vendor_copy_unpatched` for berry; the yarn classic codes below for classic); and From 6a3fc28f8488c8b687cdcff394b54c44830184ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:33:05 +0000 Subject: [PATCH 5/6] Flag empty and unparseable portal:/link: targets A git submodule CI never checked out leaves an empty directory, not a missing one, and a package.json that is a Git LFS pointer or encrypted blob reads fine but is no JSON. Both were still dropped like a link: to plain assets, so scans stayed clean and vex attested not_affected. An empty target directory and a package.json that doesn't parse now make the copy unknown too. Only a non-empty directory with no package.json, or a parsed manifest with no versioned package, is still ignored. Also pass the root as "" to the project view so hosted and disk agree on portal:. targets, and share the hosted test fixture. Refs #1561 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/hosted_memory_engine.rs | 152 ++++++++---------- .../src/formats/yarn/copy.rs | 24 +-- .../src/vendor/yarn_berry_lock.rs | 38 +++-- .../src/vendor/yarn_copies.rs | 38 ++--- .../src/vex/discover/yarn.rs | 27 +++- 5 files changed, 145 insertions(+), 134 deletions(-) diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index f11de821f..6af8fa388 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -1880,6 +1880,56 @@ async fn issue_939_berry_renamed_copy_is_named() { } } +/// Run the hosted engine over a berry project that depends on left-pad +/// from the registry and on `lp2` through the `portal:` / `link:` `spec`, +/// 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 = BTreeMap::new(); + files.insert( + "package.json".to_string(), + format!( + r#"{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"^1.3.0","lp2":"{spec}"}}}}"# + ) + .into_bytes(), + ); + files.insert( + "yarn.lock".to_string(), + format!( + "__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"app@workspace:.\":\n version: 0.0.0-use.local\n \ + resolution: \"app@workspace:.\"\n dependencies:\n \ + left-pad: \"npm:^1.3.0\"\n lp2: \"{spec}\"\n \ + languageName: unknown\n linkType: soft\n\n\ + \"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ + resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n \ + languageName: node\n linkType: hard\n\n\ + \"{key}\":\n version: 0.0.0-use.local\n resolution: \"{key}\"\n \ + languageName: node\n linkType: soft\n", + "1".repeat(128) + ) + .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) +} + /// #1540: a berry `portal:` / `link:` copy with an ABSOLUTE path is never /// read (#1516), and its lock version is the placeholder /// `0.0.0-use.local`, so it may be any version of any package. The hosted @@ -1888,52 +1938,13 @@ async fn issue_939_berry_renamed_copy_is_named() { /// report a clean success while `node_modules/lp2` held unpatched bytes. #[tokio::test] async fn issue_1540_berry_absolute_portal_and_link_copies_are_named() { - const UUID: &str = "15401540-1540-4540-8540-154015401540"; - let ws = "app%40workspace%3A."; for protocol in ["portal", "link"] { let spec = format!("{protocol}:/home/me/shared/left-pad"); - let key = format!("lp2@{spec}::locator={ws}"); - let server = MockServer::start().await; - mount_left_pad_grant(&server, UUID).await; - let mut files = BTreeMap::new(); - files.insert( - "package.json".to_string(), - format!( - r#"{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"^1.3.0","lp2":"{spec}"}}}}"# - ) - .into_bytes(), - ); - files.insert( - "yarn.lock".to_string(), - format!( - "__metadata:\n version: 8\n cacheKey: 10c0\n\n\ - \"app@workspace:.\":\n version: 0.0.0-use.local\n \ - resolution: \"app@workspace:.\"\n dependencies:\n \ - left-pad: \"npm:^1.3.0\"\n lp2: \"{spec}\"\n \ - languageName: unknown\n linkType: soft\n\n\ - \"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ - resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n \ - languageName: node\n linkType: hard\n\n\ - \"{key}\":\n version: 0.0.0-use.local\n resolution: \"{key}\"\n \ - languageName: node\n linkType: soft\n", - "1".repeat(128) - ) - .into_bytes(), - ); - let output = run_engine(&server, build_input(&files, &[], options(false))).await; - let project = &output.projects[0]; - assert!(project.error.is_none(), "{protocol}: {:?}", project.error); - assert_eq!( - project.redirected.len(), - 1, - "{protocol}: {:?} {:#}", - project.skipped, - project.redirect - ); - let details = copy_unpatched_details(project); - assert_eq!(details.len(), 1, "{protocol}: {:#}", project.redirect); + let details = + berry_linked_copy_details("15401540-1540-4540-8540-154015401540", &spec, &[]).await; + assert_eq!(details.len(), 1, "{protocol}: {details:?}"); assert!( - details[0].contains(key.as_str()) + details[0].contains(&format!("lp2@{spec}::")) && details[0].contains("can't be read") && details[0].contains("UNPATCHED"), "{protocol}: {}", @@ -1948,59 +1959,28 @@ async fn issue_1540_berry_absolute_portal_and_link_copies_are_named() { /// any package. The hosted scan still pins the registry entry, and names /// that copy as possibly unpatched (`redirect_copy_unpatched`); it used to /// report a clean success while the install with the submodule linked -/// unpatched bytes as `node_modules/lp2`. +/// unpatched bytes as `node_modules/lp2`. Control: a target whose +/// `package.json` the snapshot holds is read, so one naming another +/// package is no copy of left-pad. #[tokio::test] async fn issue_1561_berry_missing_relative_portal_and_link_copies_are_named() { const UUID: &str = "15611561-1561-4561-8561-156115611561"; - let ws = "app%40workspace%3A."; for protocol in ["portal", "link"] { let spec = format!("{protocol}:./vend/lp"); - let key = format!("lp2@{spec}::locator={ws}"); - let server = MockServer::start().await; - mount_left_pad_grant(&server, UUID).await; - let mut files = BTreeMap::new(); - files.insert( - "package.json".to_string(), - format!( - r#"{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"^1.3.0","lp2":"{spec}"}}}}"# - ) - .into_bytes(), - ); - files.insert( - "yarn.lock".to_string(), - format!( - "__metadata:\n version: 8\n cacheKey: 10c0\n\n\ - \"app@workspace:.\":\n version: 0.0.0-use.local\n \ - resolution: \"app@workspace:.\"\n dependencies:\n \ - left-pad: \"npm:^1.3.0\"\n lp2: \"{spec}\"\n \ - languageName: unknown\n linkType: soft\n\n\ - \"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ - resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n \ - languageName: node\n linkType: hard\n\n\ - \"{key}\":\n version: 0.0.0-use.local\n resolution: \"{key}\"\n \ - languageName: node\n linkType: soft\n", - "1".repeat(128) - ) - .into_bytes(), - ); - let output = run_engine(&server, build_input(&files, &[], options(false))).await; - let project = &output.projects[0]; - assert!(project.error.is_none(), "{protocol}: {:?}", project.error); - assert_eq!( - project.redirected.len(), - 1, - "{protocol}: {:?} {:#}", - project.skipped, - project.redirect - ); - let details = copy_unpatched_details(project); - assert_eq!(details.len(), 1, "{protocol}: {:#}", project.redirect); + let details = berry_linked_copy_details(UUID, &spec, &[]).await; + assert_eq!(details.len(), 1, "{protocol}: {details:?}"); assert!( - details[0].contains(key.as_str()) + details[0].contains(&format!("lp2@{spec}::")) && details[0].contains("can't be read") && details[0].contains("UNPATCHED"), "{protocol}: {}", details[0] ); + let other = [( + "vend/lp/package.json", + r#"{"name":"other-pkg","version":"1.3.0"}"#, + )]; + let details = berry_linked_copy_details(UUID, &spec, &other).await; + assert!(details.is_empty(), "{protocol}: {details:?}"); } } diff --git a/crates/socket-patch-core/src/formats/yarn/copy.rs b/crates/socket-patch-core/src/formats/yarn/copy.rs index a262ed783..3c4d5df3b 100644 --- a/crates/socket-patch-core/src/formats/yarn/copy.rs +++ b/crates/socket-patch-core/src/formats/yarn/copy.rs @@ -58,13 +58,6 @@ impl CopyRef { } } - /// A `portal:` / `link:` copy that is never read ([`CopyRef::read_path`] - /// is `None`: an absolute path). Its lock version is the placeholder - /// `0.0.0-use.local`, so it may be any version of any package (#1540). - pub(crate) fn is_unread_linked(&self) -> bool { - matches!(self, CopyRef::Linked(_)) && self.read_path().is_none() - } - /// The `(name, version)` this copy installs, given the lock entry's /// `version` and the bytes read at [`CopyRef::read_path`]. `None` when /// the copy does not say (an unreadable or nameless manifest, a @@ -219,6 +212,13 @@ fn copy_path(base: &str, path: &str) -> Option { )) } +/// Whether `bytes` parse as a JSON `package.json` (any content): a copy +/// whose manifest doesn't (a Git LFS pointer, an encrypted blob) can't be +/// read for its package (#1561). +pub(crate) fn is_json_manifest(bytes: &[u8]) -> bool { + parse_manifest(bytes).is_some() +} + fn parse_manifest(bytes: &[u8]) -> Option { let bytes = crate::formats::text::strip_bom_bytes(bytes); serde_json::from_slice::(bytes).ok() @@ -346,11 +346,11 @@ mod tests { "{reference}" ); } - let unix = CopyRef::Linked("/home/me/lp".into()); - assert!(unix.read_path().is_none() && unix.is_unread_linked()); - let relative = CopyRef::Linked("vend/lp".into()); - assert!(!relative.is_unread_linked()); - assert!(!CopyRef::Directory("/home/me/lp".into()).is_unread_linked()); + assert!(CopyRef::Linked("/home/me/lp".into()).read_path().is_none()); + assert_eq!( + CopyRef::Linked("vend/lp".into()).read_path().as_deref(), + Some("vend/lp/package.json") + ); } #[test] diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 59823f86e..ceb6ebb66 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -2562,8 +2562,10 @@ __metadata: } } - /// #1561: a RELATIVE `portal:` / `link:` copy whose target directory is - /// missing (a git submodule CI never checked out) is as unknown as an + /// #1561: a RELATIVE `portal:` / `link:` copy whose target is missing, + /// empty (the placeholder git leaves for a submodule CI never checked + /// out) or holds a `package.json` that is no JSON (a Git LFS pointer) is + /// as unknown as an /// absolute one (#1540): named as a possible unpatched copy, while the /// registry entry still vendors. It was dropped silently. Control: a /// `link:` to an existing plain directory with no `package.json` is no @@ -2571,7 +2573,8 @@ __metadata: #[tokio::test] async fn issue_1561_missing_relative_portal_and_link_targets_are_named() { for protocol in ["portal", "link"] { - for plain_dir in [false, true] { + for target in ["missing", "empty", "lfs-pointer", "plain-dir"] { + let plain_dir = target == "plain-dir"; let key = format!("lp2@{protocol}:./vend/lp::locator=vendor-spike%40workspace%3A."); let block = format!( "\n\"{key}\":\n version: 0.0.0-use.local\n resolution: \"{key}\"\n \ @@ -2579,17 +2582,30 @@ __metadata: ); let lock = format!("{B3_BEFORE_LOCK}{block}"); let fx = fixture_with(B3_BEFORE_PKG, &lock).await; - if plain_dir { - let dir = fx.tmp.path().join("vend/lp"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("logo.svg"), "") + let dir = fx.tmp.path().join("vend/lp"); + match target { + "empty" => tokio::fs::create_dir_all(&dir).await.unwrap(), + "lfs-pointer" => { + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("package.json"), + "version https://git-lfs.github.com/spec/v1\noid sha256:00\nsize 1\n", + ) .await .unwrap(); + } + "plain-dir" => { + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("logo.svg"), "") + .await + .unwrap(); + } + _ => {} } let (result, entry, warnings) = expect_done(fx.vendor(false).await); - assert!(result.success, "{protocol}/{plain_dir}: {:?}", result.error); - assert!(entry.is_some(), "{protocol}/{plain_dir}: registry vendors"); + assert!(result.success, "{protocol}/{target}: {:?}", result.error); + assert!(entry.is_some(), "{protocol}/{target}: registry vendors"); let copies: Vec<&VendorWarning> = warnings .iter() .filter(|w| w.code == "vendor_copy_unpatched") @@ -2598,12 +2614,12 @@ __metadata: assert!(copies.is_empty(), "{protocol}: {warnings:?}"); continue; } - assert_eq!(copies.len(), 1, "{protocol}: {warnings:?}"); + assert_eq!(copies.len(), 1, "{protocol}/{target}: {warnings:?}"); assert!( copies[0].detail.contains(&format!("`\"{key}\"`")) && copies[0].detail.contains("installs a package under") && copies[0].detail.contains("can't be read"), - "{protocol}: {}", + "{protocol}/{target}: {}", copies[0].detail ); } diff --git a/crates/socket-patch-core/src/vendor/yarn_copies.rs b/crates/socket-patch-core/src/vendor/yarn_copies.rs index 28e4878ec..405757273 100644 --- a/crates/socket-patch-core/src/vendor/yarn_copies.rs +++ b/crates/socket-patch-core/src/vendor/yarn_copies.rs @@ -13,7 +13,7 @@ use std::path::Path; use crate::formats::yarn::blocks::{berry_field, classic_field}; -use crate::formats::yarn::copy::{berry_copy_ref, classic_copy_ref, CopyRef}; +use crate::formats::yarn::copy::{berry_copy_ref, classic_copy_ref, is_json_manifest, CopyRef}; use crate::formats::yarn::is_berry_lock; use crate::formats::yarn::patterns::classic_key_real_name; use crate::formats::yarn::source::{classic_copy_source, CopySource}; @@ -109,12 +109,12 @@ pub(crate) async fn other_name_copies( Some(rel) => read_regular_to_bytes(&project_root.join(rel)).await.ok(), None => None, }; - let read = bytes.is_some(); + let parsed = bytes.as_deref().is_some_and(is_json_manifest); let identified = match copy_identity(&source, &lock_version, bytes).await { Some((n, v)) if n == name && v == version => true, Some(_) => continue, None if matches!(source, CopyRef::Linked(_)) - && !linked_copy_unknown(ProjectView::Disk(project_root), &source, read).await => + && !linked_copy_unknown(ProjectView::Disk(project_root), &source, parsed).await => { continue } @@ -130,34 +130,36 @@ pub(crate) async fn other_name_copies( } /// Whether a `portal:` / `link:` copy whose manifest named no versioned -/// package may be any version of any package, given whether its -/// `package.json` was `read`. Its lock version is the placeholder -/// `0.0.0-use.local`, so only that manifest says what it is. One never -/// read (an absolute path, #1540) is unknown, and so is one whose target -/// can't be read: a missing directory (a git submodule CI never checked -/// out, a generated or gitignored directory) or a `package.json` that is -/// no readable regular file (#1561). Only a directory that exists with no -/// `package.json` at all (a `link:` to plain assets), or a manifest read +/// package may be any version of any package. Its lock version is the +/// placeholder `0.0.0-use.local`, so only its `package.json` says what it +/// is; `manifest_parsed` says that file was read and parsed as JSON. A copy +/// is unknown when it is never read (an absolute path, #1540) or its +/// target can't be read (#1561): a missing directory, an EMPTY one (the +/// placeholder git leaves for a submodule CI never checked out), or a +/// `package.json` that is no readable regular file or no JSON (a Git LFS +/// pointer, an encrypted blob). Only a non-empty directory with no +/// `package.json` at all (a `link:` to plain assets), or a manifest parsed /// and found to name no versioned package, is no package copy. pub(crate) async fn linked_copy_unknown( view: ProjectView<'_>, source: &CopyRef, - read: bool, + manifest_parsed: bool, ) -> bool { let CopyRef::Linked(dir) = source else { return false; }; - if source.is_unread_linked() { - return true; - } - if read { + if manifest_parsed { return false; } let Some(manifest) = source.read_path() else { return true; }; - let dir = if dir.is_empty() { "." } else { dir.as_str() }; - !view.is_dir(dir) || view.exists_no_follow(&manifest).await + if !view.is_dir(dir) || view.exists_no_follow(&manifest).await { + return true; + } + view.list_dir(dir) + .await + .map_or(true, |entries| entries.is_empty()) } /// [`CopyRef::identity`] off the async runtime when it unpacks a tarball (a diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 4972e3bfe..0bcbd5584 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -890,13 +890,15 @@ async fn record_copies(ctx: &DiscoverCtx<'_>, copies: Vec, out: &mut D Some(rel) => ctx.read_advisory_bytes(&rel).await, None => None, }; - let read = bytes.is_some(); + let parsed = bytes + .as_deref() + .is_some_and(crate::formats::yarn::copy::is_json_manifest); let Some((name, version)) = crate::vendor::yarn_copies::copy_identity(©.source, ©.version, bytes).await else { let linked = matches!(copy.source, CopyRef::Linked(_)); if linked - && !crate::vendor::yarn_copies::linked_copy_unknown(ctx.view, ©.source, read) + && !crate::vendor::yarn_copies::linked_copy_unknown(ctx.view, ©.source, parsed) .await { continue; @@ -1959,8 +1961,9 @@ mod tests { } /// #1561: a RELATIVE `portal:` / `link:` copy whose target can't be read - /// (a missing directory, e.g. a git submodule CI never checked out, or a - /// `package.json` that is no regular file) is as unknown as an absolute + /// (a missing directory, the empty one git leaves for a submodule CI + /// never checked out, a `package.json` that is no regular file or no + /// JSON, such as a Git LFS pointer) is as unknown as an absolute /// one (#1540): it may be any version of any package, so it contests /// every ref its lock wires. It was dropped like a `link:` to a plain /// directory, so `vex` attested `not_affected` while the install that @@ -1969,10 +1972,20 @@ mod tests { async fn issue_1561_unreadable_relative_portal_and_link_targets_contest_every_ref() { let wired = berry_vendored_block("left-pad", "1.3.0", UUID_A); for protocol in ["portal", "link"] { - for target in ["missing", "manifest-is-a-directory"] { + for target in ["missing", "empty", "manifest-is-a-directory", "lfs-pointer"] { let p = Project::new(); - if target == "manifest-is-a-directory" { - p.write("vend/lp/package.json/index.js", ""); + match target { + "empty" => std::fs::create_dir_all(p.root().join("vend/lp")).unwrap(), + "manifest-is-a-directory" => { + p.write("vend/lp/package.json/index.js", ""); + } + "lfs-pointer" => { + p.write( + "vend/lp/package.json", + "version https://git-lfs.github.com/spec/v1\noid sha256:00\nsize 1\n", + ); + } + _ => {} } let descriptor = format!("lp2@{protocol}:./vend/lp::locator=app%40workspace%3A."); p.write( From 1018077f9c4281c7cf3971d7de1ed18ff0bf577d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:33:05 +0000 Subject: [PATCH 6/6] Docs: empty and LFS portal:/link: targets Refs #1561 Assisted-by: Claude Code:claude-opus-5-5 --- docs/ecosystems.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1645a12ca..b95edaef9 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -296,9 +296,10 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: is named as a copy it may be, and `vex` attests nothing that lock wires at that version. An absolute path (`portal:/home/me/lp`, `file:/abs/lp.tgz`) is never read, as the lock is untrusted input; a `portal:` / `link:` copy whose `package.json` isn't - read (an absolute path, or a relative target that is missing, such as a git submodule - CI didn't check out) has no version either, so it may be any version of any package - and `vex` attests nothing that lock wires. A `link:` to a directory that exists with no + read (an absolute path, or a relative target that is missing or empty, such as a git + submodule CI didn't check out, or whose `package.json` is no JSON, such as a Git LFS + pointer) has no version either, so it may be any version of any package and `vex` + attests nothing that lock wires. A `link:` to a non-empty directory with no `package.json` is not a package copy. Use a relative path to let the copy be read. Hosted mode still pins the registry entry and names the copy (`redirect_copy_unpatched`, unless a yarn classic skip warning already names it); vendored mode still wires the registry entry and names