From 92529feb86b3fcd4cc463776091e2f3842d6b429 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:24:25 +0000 Subject: [PATCH 1/7] Start fix for #939, #1236 Assisted-by: Claude Code:claude-opus-5-5 From d59c04653883e1e36e40bd6fcf5fd83f1bde2934 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:47:44 +0000 Subject: [PATCH 2/7] Read renamed yarn copies through one helper Yarn keys a file: tarball or directory, a url tarball and a berry portal:/link: dependency by the name the depender gave it, so "lp2": "file:./left-pad-1.3.0.tgz" locks as lp2@file:... while node_modules/lp2 is left-pad@1.3.0. Only VEX discovery could tell which package such a copy really holds, and it skipped berry portal:/link: copies entirely, so a lock-only vex still attested not_affected while that copy installed unpatched (#939). Move the copy-identity reading into formats::yarn::copy (CopyRef), so every reader takes the package from the copy the same way: the tarball's or directory's package.json, a registry url's path, or, for portal:/link:, the target's package.json name and version (the lock only records 0.0.0-use.local). VEX now records portal:/link: copies too. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/yarn/copy.rs | 291 ++++++++++++++++++ .../socket-patch-core/src/formats/yarn/mod.rs | 4 +- .../src/formats/yarn/source.rs | 2 +- .../src/vex/discover/yarn.rs | 219 ++++++------- 4 files changed, 393 insertions(+), 123 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/yarn/copy.rs diff --git a/crates/socket-patch-core/src/formats/yarn/copy.rs b/crates/socket-patch-core/src/formats/yarn/copy.rs new file mode 100644 index 000000000..0a835998d --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/copy.rs @@ -0,0 +1,291 @@ +//! Which package a yarn lock entry's own copy really installs, whatever +//! dependency name the entry is keyed by (#939, #1236). +//! +//! Yarn (classic and berry alike) keys a `file:` tarball or directory, a +//! url tarball and a berry `portal:` / `link:` dependency by the name the +//! depender gave it: `"lp2": "file:./left-pad-1.3.0.tgz"` locks as +//! `lp2@file:…`, while `node_modules/lp2` IS left-pad@1.3.0. No pin of +//! `left-pad` reaches that copy, so every reader that asks "does this lock +//! also install an unpatched copy of the patched `name@version`?" (lockfile +//! discovery, the vendored backends) reads the copy itself, through +//! [`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}; + +/// Where a yarn lock entry's copy comes from, read for the package it holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CopyRef { + /// A root-relative `file:` tarball: the package is the `package.json` + /// inside it. + Tarball(String), + /// A root-relative `file:` directory yarn copies into `node_modules`: + /// the package is its `package.json` `name`, at the lock's version. + Directory(String), + /// A root-relative berry `portal:` / `link:` directory yarn links into + /// `node_modules`: the lock records the placeholder version + /// `0.0.0-use.local`, so the package's name AND version are its + /// `package.json`'s. + Linked(String), + /// A url tarball (`#` fragment dropped): the package is the registry + /// package its `//-/-.tgz` path names. + Url(String), +} + +impl CopyRef { + /// The root-relative file to read for [`CopyRef::identity`]: the tarball + /// itself, or the directory's `package.json`. `None` for a url, which + /// names its package in its path. + pub(crate) fn read_path(&self) -> Option { + match self { + CopyRef::Tarball(rel) => Some(rel.clone()), + CopyRef::Directory(dir) | CopyRef::Linked(dir) => Some(if dir.is_empty() { + "package.json".to_string() + } else { + format!("{dir}/package.json") + }), + CopyRef::Url(_) => 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 + /// non-registry url). + pub(crate) fn identity( + &self, + lock_version: &str, + bytes: Option<&[u8]>, + ) -> Option<(String, String)> { + let name = match self { + CopyRef::Tarball(_) => tarball_manifest_name(bytes?)?, + 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(); + return Some((name, version)); + } + CopyRef::Url(url) => registry_tarball_name(url, lock_version)?, + }; + Some((name, lock_version.to_string())) + } + + /// [`CopyRef::identity`] off the async runtime when it unpacks a + /// tarball (a user's `file:` tarball can be large). + pub(crate) async fn identity_of( + &self, + lock_version: &str, + bytes: Option>, + ) -> Option<(String, String)> { + if !matches!(self, CopyRef::Tarball(_)) { + return self.identity(lock_version, bytes.as_deref()); + } + let copy = self.clone(); + let lock_version = lock_version.to_string(); + tokio::task::spawn_blocking(move || copy.identity(&lock_version, bytes.as_deref())) + .await + .ok() + .flatten() + } + + /// Where the copy installs from, for a warning or diagnostic: "installs + /// it from …" / "links it to …". A url is redacted, since the phrase + /// may be published (a VEX document, a JSON envelope). + pub(crate) fn how(&self) -> String { + match self { + CopyRef::Tarball(rel) | CopyRef::Directory(rel) => { + format!("installs it from the user's file:{rel}") + } + CopyRef::Linked(rel) => { + let rel = if rel.is_empty() { "." } else { rel }; + format!("links it to the user's directory {rel} (portal:/link:)") + } + CopyRef::Url(url) => format!( + "installs it from {:?}", + crate::utils::redact::redact_url(url) + ), + } + } +} + +/// The copy a classic block installs from its key patterns and `resolved`: +/// a `file:` path in a key pattern (relative to the root, where yarn 1 +/// writes it), else an http(s) `resolved`. `None` for anything else, or a +/// `file:` path that leaves the root. The caller decides which blocks are +/// copies at all ([`super::source::classic_copy_source`]). +pub(crate) fn classic_copy_ref(patterns: &[String], resolved: Option<&str>) -> Option { + let file = patterns.iter().find_map(|p| { + let (_, range) = split_classic_pattern(p)?; + range.strip_prefix("file:") + }); + match file { + Some(path) => { + let path = path.split('#').next().unwrap_or_default(); + let rel = crate::utils::cargo_workspace::normalize_rel("", path)?; + Some(if is_tarball_path(path) { + CopyRef::Tarball(rel) + } else { + CopyRef::Directory(rel) + }) + } + None => { + let url = resolved?.split('#').next().unwrap_or_default(); + url.starts_with("http") + .then(|| CopyRef::Url(url.to_string())) + } + } +} + +/// The copy a berry locator `reference` installs from: a `file:` tarball or +/// directory, a `portal:` / `link:` directory (each relative to the +/// workspace its `locator=` binding names), or an http(s) url. `None` for +/// any other protocol (`npm:`, `workspace:`, `patch:`, `exec:`, git), or a +/// path that leaves the root. +pub(crate) fn berry_copy_ref(reference: &str) -> Option { + if reference.starts_with("http") { + return Some(CopyRef::Url( + reference.split('#').next().unwrap_or_default().to_string(), + )); + } + let (path, linked) = if let Some(path) = reference.strip_prefix("file:") { + (path, false) + } else if let Some(path) = reference + .strip_prefix("portal:") + .or_else(|| reference.strip_prefix("link:")) + { + (path, true) + } else { + return None; + }; + let path = path.split(['#', ':']).next().unwrap_or_default(); + let rel = berry_copy_path(reference, path)?; + Some(if linked { + CopyRef::Linked(rel) + } else if is_tarball_path(path) { + CopyRef::Tarball(rel) + } else { + CopyRef::Directory(rel) + }) +} + +/// The root-relative path a berry `file:` / `portal:` / `link:` reference +/// names: relative to the workspace in its `locator=` binding +/// (`b@workspace:packages/b`), or to the root when it has none. `None` when +/// it leaves the root. +fn berry_copy_path(reference: &str, path: &str) -> Option { + let workspace = reference + .split_once("::") + .and_then(|(_, bindings)| bindings.split('&').find_map(|b| b.strip_prefix("locator="))) + .map(crate::utils::purl::percent_decode_purl_component) + .and_then(|locator| { + locator + .split_once("@workspace:") + .map(|(_, ws)| ws.to_string()) + }) + .unwrap_or_default(); + let workspace = if workspace == "." { + String::new() + } else { + workspace + }; + crate::utils::cargo_workspace::normalize_rel(&workspace, path) +} + +fn parse_manifest(bytes: &[u8]) -> Option { + let bytes = crate::formats::text::strip_bom_bytes(bytes); + serde_json::from_slice::(bytes).ok() +} + +/// `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(bytes).ok()?; + manifest_name(map.get("package.json")?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pats(p: &[&str]) -> Vec { + p.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn classic_refs_split_tarballs_directories_and_urls() { + assert_eq!( + classic_copy_ref( + &pats(&["lp2@file:./forks/lp.tgz"]), + Some("file:./forks/lp.tgz") + ), + Some(CopyRef::Tarball("forks/lp.tgz".into())) + ); + assert_eq!( + classic_copy_ref(&pats(&["lp2@file:./lpdir"]), None), + Some(CopyRef::Directory("lpdir".into())) + ); + assert_eq!( + classic_copy_ref( + &pats(&["lp2@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"]), + Some("https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#abc"), + ), + Some(CopyRef::Url( + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz".into() + )) + ); + assert_eq!(classic_copy_ref(&pats(&["lp2@file:../out"]), None), None); + } + + #[test] + fn berry_refs_resolve_against_the_locator_workspace() { + let ws = "::locator=b%40workspace%3Apackages%2Fb"; + assert_eq!( + berry_copy_ref(&format!("file:../../forks/lp.tgz#../../forks/lp.tgz{ws}")), + Some(CopyRef::Tarball("forks/lp.tgz".into())) + ); + assert_eq!( + berry_copy_ref(&format!("file:../../../lp.tgz#../../../lp.tgz{ws}")), + None, + "three levels up from packages/b leaves the root" + ); + assert_eq!( + berry_copy_ref(&format!("file:../forks/lp.tgz#../forks/lp.tgz{ws}")), + Some(CopyRef::Tarball("packages/forks/lp.tgz".into())) + ); + assert_eq!( + berry_copy_ref(&format!("file:../forks/lp#../forks/lp{ws}")), + Some(CopyRef::Directory("packages/forks/lp".into())) + ); + assert_eq!( + berry_copy_ref(&format!("portal:../lp{ws}")), + Some(CopyRef::Linked("packages/lp".into())) + ); + assert_eq!( + berry_copy_ref("link:./vend/lp::locator=app%40workspace%3A."), + Some(CopyRef::Linked("vend/lp".into())) + ); + assert_eq!(berry_copy_ref("npm:1.3.0"), None); + assert_eq!(berry_copy_ref("workspace:packages/b"), None); + } + + #[test] + fn linked_identity_reads_name_and_version_from_the_manifest() { + let linked = CopyRef::Linked("vend/lp".into()); + assert_eq!(linked.read_path().as_deref(), Some("vend/lp/package.json")); + let manifest = br#"{"name":"left-pad","version":"1.3.0"}"#; + assert_eq!( + linked.identity("0.0.0-use.local", Some(manifest)), + Some(("left-pad".into(), "1.3.0".into())) + ); + assert_eq!( + linked.identity("0.0.0-use.local", Some(br#"{"name":"x"}"#)), + None + ); + let dir = CopyRef::Directory("lpdir".into()); + assert_eq!( + dir.identity("1.3.0", Some(br#"{"name":"left-pad","version":"9.9.9"}"#)), + Some(("left-pad".into(), "1.3.0".into())) + ); + assert_eq!(dir.identity("1.3.0", None), None); + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index 5f53523f1..280befcbe 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -6,7 +6,8 @@ //! * a classic block's dependency sub-maps against a patched manifest //! ([`classic_deps`]); //! * key, descriptor and locator patterns ([`patterns`]); -//! * where yarn 1 installs a block's copy from ([`source`]); +//! * where yarn 1 installs a block's copy from ([`source`]), and which +//! package a copy keyed by another dependency name holds ([`copy`]); //! * the stanza view the hosted berry writers re-key and re-order //! entries in ([`stanzas`]); //! * a `.yarnrc.yml` setting as yarn resolves it ([`rc`]); @@ -23,6 +24,7 @@ pub mod berry_gates; pub(crate) mod berry_prune; pub(crate) mod blocks; pub(crate) mod classic_deps; +pub(crate) mod copy; pub(crate) mod patterns; pub mod rc; pub(crate) mod source; diff --git a/crates/socket-patch-core/src/formats/yarn/source.rs b/crates/socket-patch-core/src/formats/yarn/source.rs index 224d5dbca..c08f7ec6c 100644 --- a/crates/socket-patch-core/src/formats/yarn/source.rs +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -237,7 +237,7 @@ pub(crate) fn yarn_classic_range_is_git(range: &str) -> bool { } /// `file:` path → tarball or directory? Directories cannot be rewired. -fn is_tarball_path(path: &str) -> bool { +pub(crate) fn is_tarball_path(path: &str) -> bool { let path = path.split('#').next().unwrap_or(path).trim_end_matches('/'); path.ends_with(".tgz") || path.ends_with(".tar.gz") } diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index e1cd166cc..0e8aef30c 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -90,14 +90,13 @@ use super::{ DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; 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::is_berry_lock; use crate::formats::yarn::patterns::{ classic_key_real_name, classic_pattern_real_name, resolution_selector_target, - split_classic_pattern, split_resolved_sha1, BerryLocator, -}; -use crate::formats::yarn::source::{ - classic_copy_source, manifest_name, registry_tarball_name, CopySource, + split_resolved_sha1, BerryLocator, }; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::yarn::{ berry_checksum_pin, berry_entries, classic_entries, BerryLock, YarnEntry, @@ -157,28 +156,10 @@ async fn extract_classic(ctx: &DiscoverCtx<'_>, entries: Vec, out: &m /// the key pattern's, relative to the root (a path yarn resolved against a /// workspace member reads nothing there, and is left alone). fn classic_copy(entry: &YarnEntry, resolved: Option<&str>) -> Option { - let version = classic_field(&entry.block.lines, "version")?; - let file = entry.patterns.iter().find_map(|p| { - let (_, range) = split_classic_pattern(p)?; - range.strip_prefix("file:") - }); - let source = match file { - Some(path) => { - let path = path.split('#').next().unwrap_or_default(); - CopyRef::File(crate::utils::cargo_workspace::normalize_rel("", path)?) - } - None => { - let url = resolved?.split('#').next().unwrap_or_default(); - if !url.starts_with("http") { - return None; - } - CopyRef::Url(url.to_string()) - } - }; Some(LockCopy { key: entry.block.key.clone(), - source, - version: version.to_string(), + source: classic_copy_ref(&entry.patterns, resolved)?, + version: classic_field(&entry.block.lines, "version")?.to_string(), }) } @@ -539,6 +520,21 @@ fn berry_block( } else if reference.starts_with("file:") || reference.starts_with("http") { (reference, None) } else { + // A `portal:` / `link:` directory under another dependency name + // links `node_modules/` to the package its `package.json` + // names, which no `resolutions` pin of that package reaches (#939). + if reference.starts_with("portal:") || reference.starts_with("link:") { + if let (Some(source), Some(version)) = ( + berry_copy_ref(reference), + berry_field(&block.lines, "version"), + ) { + copies.push(LockCopy { + key: block.key.clone(), + source, + version: version.to_string(), + }); + } + } return; // workspace: / patch: / portal: / link: / exec: / git }; let Some(wiring) = classify(ctx, spec, YARN_LOCK, &block.key, out) else { @@ -555,16 +551,10 @@ fn berry_block( // A user's `file:` / url copy: yarn keys it by the DEPENDENCY // name (`lp2@file:…`), so which package it installs is read // from the copy itself ([`record_copies`], #939). - let source = match reference.strip_prefix("file:") { - Some(path) => { - let path = path.split(['#', ':']).next().unwrap_or_default(); - berry_file_copy_path(reference, path).map(CopyRef::File) - } - None => Some(CopyRef::Url( - reference.split('#').next().unwrap_or_default().to_string(), - )), - }; - if let (Some(version), Some(source)) = (berry_field(&block.lines, "version"), source) { + if let (Some(version), Some(source)) = ( + berry_field(&block.lines, "version"), + berry_copy_ref(reference), + ) { copies.push(LockCopy { key: block.key.clone(), source, @@ -854,114 +844,43 @@ fn emit( } /// A yarn lock entry (berry or classic) that installs a user's `file:` -/// tarball / directory or url tarball (not a Socket wiring), awaiting -/// [`record_copies`]. +/// tarball / directory, url tarball or berry `portal:` / `link:` directory +/// (not a Socket wiring), awaiting [`record_copies`]. struct LockCopy { key: String, source: CopyRef, version: String, } -/// Where a [`LockCopy`]'s bytes come from. -enum CopyRef { - /// A root-relative `file:` tarball or directory. - File(String), - /// A url tarball, `#` fragment dropped. - Url(String), -} - /// Record each [`LockCopy`] as an unpatched copy of the package it really -/// installs (#939 berry, #1236 classic). yarn keys a `file:` / url -/// dependency by the name the depender gave it, so -/// `"lp2": "file:left-pad-1.3.0.tgz"` locks as `lp2@…` while -/// `node_modules/lp2` IS left-pad@1.3.0, and no pin of `left-pad` reaches -/// it. The real name comes from the copy itself: the registry tarball path -/// of a url (`…//-/-.tgz`), the `package.json` inside -/// a `file:` tarball, or a `file:` directory's `package.json`. A copy whose -/// name cannot be read is left alone. +/// installs (#939 berry, #1236 classic). yarn keys such a dependency by +/// the name the depender gave it, so `"lp2": "file:left-pad-1.3.0.tgz"` +/// locks as `lp2@…` while `node_modules/lp2` IS left-pad@1.3.0, and no pin +/// of `left-pad` reaches it. The real package comes from the copy itself +/// ([`CopyRef::identity`]); a copy whose package cannot be read is left +/// alone. async fn record_copies(ctx: &DiscoverCtx<'_>, copies: Vec, out: &mut Discovery) { for copy in copies { - let (name, how) = match ©.source { - CopyRef::File(rel) => { - let name = if is_tarball_leaf(rel) { - match ctx.read_advisory_bytes(rel).await { - Some(bytes) => { - tokio::task::spawn_blocking(move || tarball_package_name(&bytes)) - .await - .ok() - .flatten() - } - None => None, - } - } else { - let manifest = if rel.is_empty() { - PACKAGE_JSON.to_string() - } else { - format!("{rel}/{PACKAGE_JSON}") - }; - match ctx.read_advisory_text(&manifest).await { - Some(text) => manifest_name(text.as_bytes()), - None => None, - } - }; - (name, format!("installs it from the user's file:{rel}")) - } - CopyRef::Url(url) => ( - registry_tarball_name(url, ©.version), - // Published in the VEX document: never a credential. - format!( - "installs it from {:?}", - crate::utils::redact::redact_url(url) - ), - ), + let bytes = match copy.source.read_path() { + Some(rel) => ctx.read_advisory_bytes(&rel).await, + None => None, }; - let Some(name) = name else { + let Some((name, version)) = copy.source.identity_of(©.version, bytes).await else { continue; }; out.unpatched_copy( YARN_LOCK, - npm_purl(&name, ©.version), + npm_purl(&name, &version), ©.key, &format!( - "{how}, which no Socket wiring of {name} reaches (yarn keys it by the \ - dependency name)" + "{}, which no Socket wiring of {name} reaches (yarn keys it by the \ + dependency name)", + copy.source.how() ), ); } } -/// The root-relative path a berry `file:` reference names: relative to the -/// workspace in its `locator=` binding (`b@workspace:packages/b`), or to -/// the root when it has none. `None` when it leaves the root. -fn berry_file_copy_path(reference: &str, path: &str) -> Option { - let workspace = reference - .split_once("::") - .and_then(|(_, bindings)| bindings.split('&').find_map(|b| b.strip_prefix("locator="))) - .map(crate::utils::purl::percent_decode_purl_component) - .and_then(|locator| { - locator - .split_once("@workspace:") - .map(|(_, ws)| ws.to_string()) - }) - .unwrap_or_default(); - let workspace = if workspace == "." { - String::new() - } else { - workspace - }; - crate::utils::cargo_workspace::normalize_rel(&workspace, path) -} - -fn is_tarball_leaf(path: &str) -> bool { - path.ends_with(".tgz") || path.ends_with(".tar.gz") -} - -/// `name` of the `package.json` inside an npm tarball. -fn tarball_package_name(bytes: &[u8]) -> Option { - let map = crate::patch::package::read_archive_bytes_to_map(bytes).ok()?; - manifest_name(map.get(PACKAGE_JSON)?) -} - #[cfg(test)] mod tests { use super::super::testing::*; @@ -1702,6 +1621,64 @@ mod tests { } } + /// #939 (bug-hunt run 25): a berry `portal:` / `link:` dependency under + /// another name links `node_modules/lp2` to a directory holding the + /// wired left-pad@1.3.0, which no `resolutions` pin reaches. The lock + /// records the placeholder version `0.0.0-use.local`, so the package's + /// name and version come from the target's `package.json`; it contests + /// the wiring like a `file:` copy does. Controls: a target holding + /// another package or another version is no copy of left-pad@1.3.0. + #[tokio::test] + async fn issue_939_berry_portal_and_link_copies_contest_the_ref() { + let ws = "a%40workspace%3Apackages%2Fa"; + let wired = berry_vendored_block("left-pad", "1.3.0", UUID_A); + let project = |protocol: &str, manifest: &str| { + let descriptor = format!("lp2@{protocol}:../../vend/lp::locator={ws}"); + let p = Project::new(); + 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"), + })), + ); + p.write("vend/lp/package.json", manifest); + p + }; + for protocol in ["portal", "link"] { + let out = run(&project( + protocol, + r#"{"name":"left-pad","version":"1.3.0"}"#, + )) + .await; + assert!(out.refs.is_empty(), "{protocol}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains(&format!("lp2@{protocol}:")) + && d.detail.contains("UNPATCHED")), + "{protocol}: {:#?}", + out.diagnostics + ); + for other in [ + r#"{"name":"other-pkg","version":"1.3.0"}"#, + r#"{"name":"left-pad","version":"1.2.0"}"#, + ] { + assert_refs( + &run(&project(protocol, other)).await, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Vendored)], + ); + } + } + } + /// A berry registry locator beside a hosted one of the same /// `name@version` (scoped `resolutions`, or a workspace member added /// after the rewire, then `yarn install`) installs the registry bytes From 376b47579e99213db44fc23f547e5af888c33dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:47:44 +0000 Subject: [PATCH 3/7] Warn about renamed yarn copies in hosted scans A hosted scan pinned the registry entry and reported a clean success while the same yarn.lock also installed the patched name@version under another dependency name (a renamed file: tarball in yarn classic, any renamed file:/url/portal:/link: copy in yarn berry). That copy stays unpatched, and nothing in the scan output said so (#939, #1236). The run already checks its own result with lockfile discovery and names each pnpm copy the pin cannot reach (redirect_copy_unpatched). Do the same for yarn.lock, skipping a copy a yarn classic skip warning already names, so every renamed copy discovery sees is reported and the in-run VEX never assumes the patch applied. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/hosted_memory_common/mod.rs | 6 +- .../tests/hosted_memory_engine.rs | 230 ++++++++++++++++++ crates/socket-patch-core/src/hosted/engine.rs | 44 ++-- 3 files changed, 261 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index 068823228..cf820b7ec 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -253,8 +253,8 @@ pub fn read_tree(dir: &Path) -> BTreeMap> { out } -/// `files` added to a session (`bun.lockb` as bytes, the rest as text) -/// plus presence-only markers. +/// `files` added to a session (`bun.lockb` and `.tgz` tarballs as bytes, +/// the rest as text) plus presence-only markers. pub fn build_input( files: &BTreeMap>, present: &[&str], @@ -262,7 +262,7 @@ pub fn build_input( ) -> socket_patch_cli::hosted_memory::HostedScanInput { let mut builder = SessionBuilder::new(options).expect("valid options"); for (path, bytes) in files { - if path.ends_with("bun.lockb") { + if path.ends_with("bun.lockb") || path.ends_with(".tgz") { builder.add_binary(path, bytes).unwrap(); } else { builder.push_chunk(path, bytes).unwrap(); diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index c18a987a9..c4196724a 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -1649,3 +1649,233 @@ async fn a_leftover_root_only_scaffold_on_pnpm_9_is_flagged() { ); } } + +/// An npm tarball holding only `package/package.json` for `name@version`. +fn npm_manifest_tgz(name: &str, version: &str) -> Vec { + let manifest = format!(r#"{{"name":"{name}","version":"{version}"}}"#); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + let mut header = tar::Header::new_gnu(); + header.set_size(manifest.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, "package/package.json", manifest.as_bytes()) + .unwrap(); + builder.into_inner().unwrap().finish().unwrap() +} + +/// A granted hosted left-pad@1.3.0 patch on `server` (tarball and yarn +/// berry zip artifacts), its served tarball mounted. +async fn mount_left_pad_grant(server: &MockServer, uuid: &str) { + use base64::Engine as _; + use sha2::Digest as _; + let tarball = npm_manifest_tgz("left-pad", "1.3.0"); + let sha512 = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(&tarball)) + ); + let artifact = format!("/patch/npm/left-pad/1.3.0/tok/{uuid}/left-pad-1.3.0.tgz"); + let url = format!("{}{artifact}", server.uri()); + let patch = common::Patch { + purl: "pkg:npm/left-pad@1.3.0".into(), + uuid: uuid.into(), + reference: serde_json::json!({ + "status": "granted", + "url": url, + "purl": null, + "artifacts": [ + { "kind": "tarball", "url": url, "integrity": { "sha512": sha512 } }, + { "kind": "yarn-berry-zip", "url": url, + "integrity": { "yarnBerry10c0": format!("10c0/{}", "7".repeat(128)) } } + ], + "registryOverride": null, + }), + }; + mount_api(server, &[patch]).await; + Mock::given(method("GET")) + .and(path(artifact)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(tarball)) + .mount(server) + .await; +} + +/// The `redirect_copy_unpatched` warnings a project's redirect reports. +fn copy_unpatched_details(project: &socket_patch_cli::hosted_memory::ProjectResult) -> Vec { + project.redirect["warnings"] + .as_array() + .unwrap() + .iter() + .filter(|w| w["code"] == "redirect_copy_unpatched") + .map(|w| w["detail"].as_str().unwrap().to_string()) + .collect() +} + +/// #1236 follow-up: yarn 1 locks a `file:` TARBALL declared under another +/// dependency name (`"lp2": "file:./forks/left-pad-1.3.0.tgz"`) as +/// `lp2@file:…` and installs it as `node_modules/lp2`, which is +/// left-pad@1.3.0 and stays unpatched whatever the pin does. The hosted +/// scan still pins the registry block, and now names that copy (the +/// tarball's own `package.json` says which package it is). Control: a +/// tarball holding another package is not named. +#[tokio::test] +async fn issue_1236_classic_renamed_file_tarball_copy_is_named() { + const UUID: &str = "12361236-1236-4236-8236-123612361236"; + let key = "\"lp2@file:./forks/left-pad-1.3.0.tgz\""; + for (fork, named) in [("left-pad", true), ("other-pkg", false)] { + let server = MockServer::start().await; + mount_left_pad_grant(&server, UUID).await; + let mut files = BTreeMap::new(); + files.insert( + "package.json".to_string(), + br#"{"name":"app","version":"1.0.0","packageManager":"yarn@1.22.22","dependencies":{"left-pad":"1.3.0","lp2":"file:./forks/left-pad-1.3.0.tgz"}}"#.to_vec(), + ); + files.insert( + "yarn.lock".to_string(), + format!( + "# yarn lockfile v1\n\n\nleft-pad@1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#{}\"\n \ + integrity sha512-UP==\n\n{key}:\n version \"1.3.0\"\n \ + resolved \"file:./forks/left-pad-1.3.0.tgz#{}\"\n", + "a".repeat(40), + "b".repeat(40), + ) + .into_bytes(), + ); + files.insert( + "forks/left-pad-1.3.0.tgz".to_string(), + npm_manifest_tgz(fork, "1.3.0"), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{:?}", project.error); + assert_eq!(project.redirected.len(), 1, "{fork}: {:?}", project.skipped); + let details = copy_unpatched_details(project); + if named { + assert_eq!(details.len(), 1, "{:#}", project.redirect); + assert!( + details[0].contains(&format!("`{key}`")) && details[0].contains("UNPATCHED"), + "{}", + details[0] + ); + } else { + assert!(details.is_empty(), "{details:?}"); + } + } +} + +/// #939: yarn berry keys a `file:` tarball / directory, a url tarball and +/// a `portal:` / `link:` dependency by the DEPENDENCY name (`lp2@…`), so a +/// copy of left-pad@1.3.0 under another name escapes the `resolutions` pin +/// and installs unpatched. The hosted scan still pins the registry entry, +/// and now names that copy instead of reporting a clean success. Control: +/// a `file:` / `portal:` copy holding another package is not named. +#[tokio::test] +async fn issue_939_berry_renamed_copy_is_named() { + const UUID: &str = "93939393-9393-4393-8393-939393939393"; + let ws = "app%40workspace%3A."; + let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + // (case, manifest spec, lock key, lock version, resolution) + let cases = [ + ( + "file: tarball", + "file:./forks/left-pad-1.3.0.tgz".to_string(), + format!("lp2@file:./forks/left-pad-1.3.0.tgz::locator={ws}"), + "1.3.0", + format!( + "lp2@file:./forks/left-pad-1.3.0.tgz#./forks/left-pad-1.3.0.tgz::hash=5c8e4c&locator={ws}" + ), + ), + ( + "file: directory", + "file:./forks/left-pad".to_string(), + format!("lp2@file:./forks/left-pad::locator={ws}"), + "1.3.0", + format!("lp2@file:./forks/left-pad#./forks/left-pad::hash=1a2b3c&locator={ws}"), + ), + ( + "registry tarball url", + url.to_string(), + format!("lp2@{url}"), + "1.3.0", + format!("lp2@{url}"), + ), + ( + "portal:", + "portal:./forks/left-pad".to_string(), + format!("lp2@portal:./forks/left-pad::locator={ws}"), + "0.0.0-use.local", + format!("lp2@portal:./forks/left-pad::locator={ws}"), + ), + ( + "link:", + "link:./forks/left-pad".to_string(), + format!("lp2@link:./forks/left-pad::locator={ws}"), + "0.0.0-use.local", + format!("lp2@link:./forks/left-pad::locator={ws}"), + ), + ]; + for (case, spec, key, version, resolution) in &cases { + for fork in ["left-pad", "other-pkg"] { + let named = fork == "left-pad" || case.contains("url"); + 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: {version}\n resolution: \"{resolution}\"\n \ + languageName: node\n linkType: hard\n", + "1".repeat(128) + ) + .into_bytes(), + ); + files.insert( + "forks/left-pad-1.3.0.tgz".to_string(), + npm_manifest_tgz(fork, "1.3.0"), + ); + files.insert( + "forks/left-pad/package.json".to_string(), + format!(r#"{{"name":"{fork}","version":"1.3.0"}}"#).into_bytes(), + ); + let output = run_engine(&server, build_input(&files, &[], options(false))).await; + let project = &output.projects[0]; + assert!(project.error.is_none(), "{case}: {:?}", project.error); + assert_eq!( + project.redirected.len(), + 1, + "{case}/{fork}: {:?} {:#}", + project.skipped, + project.redirect + ); + let details = copy_unpatched_details(project); + if named { + assert_eq!(details.len(), 1, "{case}/{fork}: {:#}", project.redirect); + assert!( + details[0].contains(key.as_str()) && details[0].contains("UNPATCHED"), + "{case}: {}", + details[0] + ); + } else { + assert!(details.is_empty(), "{case}/{fork}: {details:?}"); + } + } + } +} diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 590088890..538251b09 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1745,7 +1745,7 @@ pub async fn rewrite( /// the management commands read it ([`HostedInventory`]): the skips for /// the confirmed candidates whose pin would be contested wiring, a /// [`REDIRECT_PIN_LOCKLESS`] warning per lockless pin, and a -/// [`REDIRECT_COPY_UNPATCHED`] warning per pnpm `file:` copy a pin's own +/// [`REDIRECT_COPY_UNPATCHED`] warning per pnpm or yarn copy a pin's own /// lock installs beside it. A lockless NuGet / /// Cargo pin ([`UnlockedPin`]) is written as before — whether such a pin /// may be written at all is the open hosted-rollback decision (E45) — but @@ -1879,14 +1879,17 @@ async fn unattributed_pins( ) }) .collect(); - // A pin whose own pnpm lock also installs an unpatched copy of the same - // `name@version` from a `file:` directory or tarball dependency (#935) - // is written: the registry copy is patched, and discovery withholds the - // pin from attestation but still manages it ([`Discovery::shadowed`], - // #828). The pnpm rewriter cannot see such a copy (a directory's - // version lives in its own package.json), so the run names it here, and - // keeps the in-run VEX from assuming the uuid applied, as the yarn - // classic rewriter does for the `file:` copies it reports itself. + // A pin whose own pnpm or yarn lock also installs an unpatched copy of + // the same `name@version` is written: the registry copy is patched, and + // discovery withholds the pin from attestation but still manages it + // ([`Discovery::shadowed`], #828). The rewriters cannot see every such + // copy: a pnpm `file:` directory's version lives in its own package.json + // (#935), and yarn keys a `file:` / url / `portal:` copy by the + // DEPENDENCY name (`lp2@file:…`), so which package it holds is read from + // the copy itself (#939, #1236). The run names each one here, and keeps + // the in-run VEX from assuming the uuid applied. A copy a rewriter + // already named (the yarn classic `file:` directory skip) is not named + // twice. // // [`Discovery::shadowed`]: crate::vex::discover::Discovery::shadowed let mut copy_left: Vec = Vec::new(); @@ -1903,11 +1906,18 @@ async fn unattributed_pins( && c.file == r.source_file && matches!( c.file.file_name().and_then(|n| n.to_str()), - Some("pnpm-lock.yaml" | "shrinkwrap.yaml") + Some("pnpm-lock.yaml" | "shrinkwrap.yaml" | "yarn.lock") ) }) { copy_shadowed.insert(r.uuid.clone()); - if !named.insert((c.file.as_path(), c.key.as_str())) { + let entry = format!("`{}`", c.key); + if done + .rewrite + .warnings + .iter() + .any(|w| w.detail.contains(&entry)) + || !named.insert((c.file.as_path(), c.key.as_str())) + { continue; } copy_left.push(warning( @@ -1915,7 +1925,7 @@ async fn unattributed_pins( format!( "{}: {} is pinned to patch {}, but lock entry `{}` {}; that copy stays \ UNPATCHED, so `vex` attests nothing for {}. Depend on the registry \ - release instead of that file: copy to patch it", + release instead of that copy to patch it", c.file.display(), r.purl, r.uuid, @@ -2007,7 +2017,7 @@ struct Gated { vetoed: Vec, /// A [`REDIRECT_PIN_LOCKLESS`] warning per lockless pin. lockless: Vec, - /// A [`REDIRECT_COPY_UNPATCHED`] warning per pnpm `file:` copy a pin's + /// A [`REDIRECT_COPY_UNPATCHED`] warning per pnpm or yarn copy a pin's /// own lock installs beside it. copy_left: Vec, /// The uuids of those pins, which the in-run VEX must not assume @@ -2037,9 +2047,11 @@ fn reusable_prior( /// NuGet / Cargo redirect), which no later command can attribute. pub const REDIRECT_PIN_LOCKLESS: &str = "redirect_pin_lockless"; -/// Warning: a confirmed pin's own pnpm lock also installs an unpatched copy -/// of the same `name@version` from a `file:` directory or tarball -/// dependency (#935), which no rewrite reaches. +/// Warning: a confirmed pin's own pnpm or yarn lock also installs an +/// unpatched copy of the same `name@version` from a `file:` directory or +/// tarball dependency (#935), or, in a yarn lock, from a `file:` / url / +/// berry `portal:` / `link:` dependency declared under another name (#939, +/// #1236), which no rewrite reaches. pub const REDIRECT_COPY_UNPATCHED: &str = "redirect_copy_unpatched"; /// `skipped[].reason` of a candidate whose pin lockfile discovery would not From 83dabb894ed8d91746836fb8185ec3753bc8237e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:47:44 +0000 Subject: [PATCH 4/7] Warn about renamed yarn copies when vendoring Vendored scans keyed every check on the lock entry's own name, so a yarn berry copy of the patched package under another dependency name got no warning at all, and a yarn classic renamed file: tarball was missed too (#939, #1236). The resolutions entry or re-lock vendoring writes never reaches such a copy, so it installs unpatched. Add vendor::yarn_copies, which lists those copies for either lock grammar through the shared copy reader. The classic backend now names renamed tarballs as well, and the berry backend names every renamed copy with vendor_copy_unpatched while still wiring the registry entry. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/mod.rs | 1 + .../src/vendor/yarn_berry_lock.rs | 115 ++++++++++++++++ .../src/vendor/yarn_classic_lock.rs | 128 ++++++++++-------- .../src/vendor/yarn_copies.rs | 95 +++++++++++++ 4 files changed, 281 insertions(+), 58 deletions(-) create mode 100644 crates/socket-patch-core/src/vendor/yarn_copies.rs diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 6e5042c27..ba29bb238 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -111,6 +111,7 @@ pub mod vlt_lock; pub(crate) mod vlt_lock_text; pub(crate) mod yarn_berry_lock; pub(crate) mod yarn_classic_lock; +pub(crate) mod yarn_copies; #[cfg(test)] mod yarn_layering_tests; 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 7a1a43021..f5700bd51 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -236,6 +236,23 @@ impl NpmLockBackend for YarnBerryBackend { ), )); } + // A `file:` / url / `portal:` / `link:` copy of the package under + // ANOTHER dependency name (`lp2@file:…`, #939): the name-keyed + // resolutions entry never moves it, so it keeps installing the + // unpatched bytes. Named, like the alias entries above. + for (key, source) in + super::yarn_copies::other_name_copies(project_root, &lock_text, name, version).await + { + warnings.push(VendorWarning::new( + "vendor_copy_unpatched", + format!( + "{YARN_LOCK} entry `{key}` installs {name}@{version} under another \ + dependency name, from {}; the name-keyed resolutions entry vendoring \ + writes never reaches it, so that copy stays UNPATCHED", + super::yarn_copies::copy_origin(&source) + ), + )); + } let (target_idx, target_is_ours) = target_gate(&scan, name, version)?; Ok(YarnBerryPlan { lock_text, @@ -2400,6 +2417,104 @@ __metadata: ); } + /// #939: yarn berry keys a `file:` tarball / directory, a url tarball + /// and a `portal:` / `link:` dependency by the DEPENDENCY name + /// (`lp2@…`), so a copy of left-pad@1.3.0 under another name escapes the + /// name-keyed resolutions entry and installs unpatched. Vendoring still + /// wires the registry entry, names that copy (`vendor_copy_unpatched`), + /// and leaves it byte-untouched. Control: a `file:` / `portal:` copy + /// holding another package is not named. + #[tokio::test] + async fn issue_939_other_name_copies_are_named() { + let ws = "vendor-spike%40workspace%3A."; + let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + let cases = [ + ( + "file: tarball", + format!("lp2@file:./forks/left-pad-1.3.0.tgz::locator={ws}"), + "1.3.0", + format!( + "lp2@file:./forks/left-pad-1.3.0.tgz#./forks/left-pad-1.3.0.tgz::hash=5c8e4c&locator={ws}" + ), + ), + ( + "file: directory", + format!("lp2@file:./forks/left-pad::locator={ws}"), + "1.3.0", + format!("lp2@file:./forks/left-pad#./forks/left-pad::hash=1a2b3c&locator={ws}"), + ), + ( + "registry tarball url", + format!("lp2@{url}"), + "1.3.0", + format!("lp2@{url}"), + ), + ( + "portal:", + format!("lp2@portal:./forks/left-pad::locator={ws}"), + "0.0.0-use.local", + format!("lp2@portal:./forks/left-pad::locator={ws}"), + ), + ( + "link:", + format!("lp2@link:./forks/left-pad::locator={ws}"), + "0.0.0-use.local", + format!("lp2@link:./forks/left-pad::locator={ws}"), + ), + ]; + for (case, key, version, resolution) in &cases { + for fork in ["left-pad", "other-pkg"] { + let named = fork == "left-pad" || case.contains("url"); + let block = format!( + "\n\"{key}\":\n version: {version}\n resolution: \"{resolution}\"\n \ + languageName: node\n linkType: hard\n" + ); + let lock = format!("{B3_BEFORE_LOCK}{block}"); + let fx = fixture_with(B3_BEFORE_PKG, &lock).await; + let forks = fx.tmp.path().join("forks"); + tokio::fs::create_dir_all(forks.join("left-pad")) + .await + .unwrap(); + tokio::fs::write( + forks.join("left-pad-1.3.0.tgz"), + crate::vex::discover::testing::npm_tgz(fork, "1.3.0"), + ) + .await + .unwrap(); + tokio::fs::write( + forks.join("left-pad/package.json"), + format!(r#"{{"name":"{fork}","version":"1.3.0"}}"#), + ) + .await + .unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{case}: {:?}", result.error); + assert!(entry.is_some(), "{case}: the registry entry still vendors"); + let copies: Vec<&VendorWarning> = warnings + .iter() + .filter(|w| w.code == "vendor_copy_unpatched") + .collect(); + assert_eq!( + copies.len(), + usize::from(named), + "{case}/{fork}: {warnings:?}" + ); + if named { + assert!( + copies[0].detail.contains(&format!("`\"{key}\"`")) + && copies[0].detail.contains("UNPATCHED"), + "{case}: {}", + copies[0].detail + ); + } + let text = tokio::fs::read_to_string(fx.lock_path()).await.unwrap(); + assert!(text.contains("left-pad@file:./"), "{case}: rewired: {text}"); + assert!(text.contains(&block), "{case}: copy byte-untouched: {text}"); + } + } + } + /// When the ONLY entry for the patched version is an alias descriptor, /// the refusal must say so — the generic "make sure the package is /// installed" detail would send the user to a `yarn install` that diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 8c44bd924..523e68978 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -121,7 +121,7 @@ impl NpmLockBackend for YarnClassicBackend { // ── 3. Find the rewritable blocks (pre-flight, BEFORE staging) ──── let blocks = scan_blocks_shared(&text); let other_name = - other_name_copy_warnings(project_root, &blocks, &coords.name, &coords.version).await; + other_name_copy_warnings(project_root, &text, &coords.name, &coords.version).await; let (candidate_keys, skipped) = rewritable_candidates(&blocks, &coords.name, &coords.version).map_err(|o| { only_other_name_copies(o, &blocks, &coords.name, &coords.version, &other_name) @@ -379,65 +379,36 @@ fn rewritable_candidates( } /// A named warning for each block that installs `name@version` under -/// ANOTHER dependency name (#1236): yarn 1 keys a `file:` directory or url -/// copy by the name the depender gave it (`"lp2@file:./lpdir"`), so which -/// package it is comes from the copy itself (the directory's -/// `package.json`, the registry url's path). No re-lock of `name` reaches -/// that copy, so it stays unpatched; the registry blocks are still wired. +/// ANOTHER dependency name (#1236): yarn 1 keys a `file:` tarball or +/// directory or a url copy by the name the depender gave it +/// (`"lp2@file:./lpdir"`), so which package it is comes from the copy +/// itself ([`super::yarn_copies::other_name_copies`]). No re-lock of `name` +/// reaches that copy, so it stays unpatched; the registry blocks are still +/// wired. async fn other_name_copy_warnings( project_root: &Path, - blocks: &[LockBlock], + lock_text: &str, name: &str, version: &str, ) -> Vec { - let mut out = Vec::new(); - for block in blocks { - let patterns = split_key_patterns(&block.key); - if classic_key_real_name(&patterns) == Some(name) - || classic_field(&block.lines, "version") != Some(version) - { - continue; - } - let manifest = crate::formats::yarn::source::classic_file_directory(&patterns).map(|dir| { - if dir.is_empty() { - "package.json".to_string() - } else { - format!("{dir}/package.json") - } - }); - let text = match manifest { - Some(rel) => read_regular_to_string(&project_root.join(&rel)).await.ok(), - None => None, - }; - let copy = crate::formats::yarn::source::classic_copy_real_name( - &patterns, - classic_field(&block.lines, "resolved"), - version, - |_| text.clone(), - ); - let Some((_, source)) = copy.filter(|(n, _)| n == name) else { - continue; - }; - let (code, from) = match source { - CopySource::Directory => ( - "vendor_link_entry_skipped", - "a file: directory, which yarn copies into node_modules", - ), - _ => ( - "vendor_yarn_classic_non_registry_entry_skipped", - "a URL tarball", - ), - }; - out.push(VendorWarning::new( - code, - format!( - "lock entry `{}` installs {name}@{version} under another dependency name, \ - from {from}; vendoring can't rewire it, so this copy stays UNPATCHED", - block.key - ), - )); - } - out + super::yarn_copies::other_name_copies(project_root, lock_text, name, version) + .await + .into_iter() + .map(|(key, source)| { + let code = match source { + crate::formats::yarn::copy::CopyRef::Directory(_) => "vendor_link_entry_skipped", + _ => "vendor_yarn_classic_non_registry_entry_skipped", + }; + VendorWarning::new( + code, + format!( + "lock entry `{key}` installs {name}@{version} under another dependency \ + name, from {}; vendoring can't rewire it, so this copy stays UNPATCHED", + super::yarn_copies::copy_origin(&source) + ), + ) + }) + .collect() } /// A `vendor_lock_entry_not_found` refusal of a package whose only copies @@ -486,6 +457,7 @@ fn has_own_name_block(blocks: &[LockBlock], name: &str, version: &str) -> bool { /// per package. pub(super) struct ClassicProject { blocks: Arc>, + text: Arc, } /// Read the lock as [`vendor_yarn_classic`]'s step 2 does — read, @@ -498,6 +470,7 @@ pub(super) async fn read_project(project_root: &Path) -> Result Vec> { let project = read_project(project_root).await; - let blocks = project.as_ref().ok().map(|p| Arc::clone(&p.blocks)); + let lock = project + .as_ref() + .ok() + .map(|p| (Arc::clone(&p.blocks), Arc::clone(&p.text))); let mut gated = super::npm_common::gate_packages(project, packages, |project, coords| { let (name, version) = (coords.name.as_str(), coords.version.as_str()); rewritable_candidates(&project.blocks, name, version) @@ -520,7 +496,7 @@ pub(crate) async fn preflight_packages( }); // The loop's step 3 turns "not found" into "not rewritable" when the // package's only copies are locked under another name; so does the plan. - if let Some(blocks) = blocks { + if let Some((blocks, text)) = lock { for ((purl, record), gate) in packages.iter().zip(gated.iter_mut()) { if *gate != Err("vendor_lock_entry_not_found") { continue; @@ -529,7 +505,7 @@ pub(crate) async fn preflight_packages( continue; }; if !has_own_name_block(&blocks, &coords.name, &coords.version) - && !other_name_copy_warnings(project_root, &blocks, &coords.name, &coords.version) + && !other_name_copy_warnings(project_root, &text, &coords.name, &coords.version) .await .is_empty() { @@ -1934,6 +1910,42 @@ wow@^1.0.0: } } + /// #1236 follow-up: a `file:` TARBALL declared under another dependency + /// name (`"lp2": "file:./forks/left-pad-1.3.0.tgz"`) is read from the + /// tarball's own `package.json` and named too; the registry block is + /// still wired. Control: a tarball holding another package is not named. + #[tokio::test] + async fn issue_1236_other_name_file_tarball_copy_is_named() { + let extra = "\n\"lp2@file:./forks/left-pad-1.3.0.tgz\":\n version \"1.3.0\"\n \ + resolved \"file:./forks/left-pad-1.3.0.tgz#cccc\"\n"; + let lock = format!("{Y2_BEFORE}{extra}"); + for (fork, named) in [("left-pad", true), ("other-pkg", false)] { + let fx = fixture_with_lock(&lock).await; + tokio::fs::create_dir_all(fx.root().join("forks")) + .await + .unwrap(); + tokio::fs::write( + fx.root().join("forks/left-pad-1.3.0.tgz"), + crate::vex::discover::testing::npm_tgz(fork, "1.3.0"), + ) + .await + .unwrap(); + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!(entry.unwrap().wiring.len(), 1, "the registry block"); + let tarball: Vec<&VendorWarning> = warnings + .iter() + .filter(|w| { + w.code == "vendor_yarn_classic_non_registry_entry_skipped" + && w.detail.contains("lp2@") + && w.detail.contains("file: tarball") + }) + .collect(); + assert_eq!(tarball.len(), usize::from(named), "{fork}: {warnings:?}"); + assert!(fx.lock_text().await.contains(extra.trim_start())); + } + } + /// #1236 (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/vendor/yarn_copies.rs b/crates/socket-patch-core/src/vendor/yarn_copies.rs new file mode 100644 index 000000000..049c9290b --- /dev/null +++ b/crates/socket-patch-core/src/vendor/yarn_copies.rs @@ -0,0 +1,95 @@ +//! The yarn lock entries a vendored wiring of `name@version` cannot reach +//! because they install a copy of it under ANOTHER dependency name (#939, +//! #1236), shared by the classic and berry backends. +//! +//! Yarn keys a `file:` tarball or directory, a url tarball and a berry +//! `portal:` / `link:` dependency by the name the depender gave it +//! (`lp2@file:./left-pad-1.3.0.tgz`), so a re-lock or `resolutions` entry +//! for `left-pad` never moves it, and `node_modules/lp2` keeps the +//! unpatched left-pad bytes. Which package such a copy is comes from the +//! copy itself ([`CopyRef::identity`]), read exactly as lockfile discovery +//! reads it, so the scan names every copy `vex` refuses to attest beside. + +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::is_berry_lock; +use crate::formats::yarn::patterns::classic_key_real_name; +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}; + +/// `(lock key, copy)` of every live entry of `lock_text` (either grammar) +/// that installs `name@version` under another dependency name, in lock +/// order. A copy whose package cannot be read (a missing directory, an +/// unreadable tarball, a non-registry url) is left out. +pub(crate) async fn other_name_copies( + project_root: &Path, + lock_text: &str, + name: &str, + version: &str, +) -> Vec<(String, CopyRef)> { + let candidates: Vec<(String, CopyRef, String)> = if is_berry_lock(lock_text) { + berry_entries(lock_text) + .entries + .into_iter() + .filter(|e| e.live) + .filter_map(|e| { + let locator = e.locator()?; + if locator.name == name { + return None; + } + let source = berry_copy_ref(locator.reference)?; + let lock_version = berry_field(&e.block.lines, "version")?.to_string(); + Some((e.block.key.clone(), source, lock_version)) + }) + .collect() + } else { + classic_entries(lock_text) + .into_iter() + .filter(|e| e.live && classic_key_real_name(&e.patterns) != Some(name)) + .filter_map(|e| { + let resolved = classic_field(&e.block.lines, "resolved"); + // A registry block under another name is an `npm:` alias + // (named by the alias warnings); a git block names no + // package in the lock. + if !matches!( + classic_copy_source(&e.patterns, resolved), + CopySource::Directory | CopySource::RemoteTarball + ) { + return None; + } + let source = classic_copy_ref(&e.patterns, resolved)?; + let lock_version = classic_field(&e.block.lines, "version")?.to_string(); + Some((e.block.key.clone(), source, lock_version)) + }) + .collect() + }; + let mut out = Vec::new(); + for (key, source, lock_version) in candidates { + let bytes = match source.read_path() { + Some(rel) => read_regular_to_bytes(&project_root.join(rel)).await.ok(), + None => None, + }; + if source + .identity_of(&lock_version, bytes) + .await + .is_some_and(|(n, v)| n == name && v == version) + { + out.push((key, source)); + } + } + out +} + +/// Where a copy installs from, for a vendored warning. +pub(crate) fn copy_origin(source: &CopyRef) -> &'static str { + match source { + CopyRef::Tarball(_) => "a file: tarball", + CopyRef::Directory(_) => "a file: directory, which yarn copies into node_modules", + CopyRef::Linked(_) => "a portal:/link: directory, which yarn links into node_modules", + CopyRef::Url(_) => "a URL tarball", + } +} From e6a3197751a21a9919083658dee171fc78de706b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:47:44 +0000 Subject: [PATCH 5/7] Document warnings for renamed yarn copies Describe how hosted and vendored scans and vex treat a yarn copy of the patched package declared under another dependency name, and list the widened redirect_copy_unpatched and new vendor_copy_unpatched codes. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 +++-- docs/ecosystems.md | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 60a5f835b..c55f25dc2 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -218,7 +218,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is a `skipped` event whose `errorCode` is the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `verified` events and warnings are the wet run's `applied` events and warnings. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_cargo_directory_source` (#455: a cargo config replaces crates.io with an on-disk source through its `[source.crates-io] replace-with` chain — a directory source, as a `cargo vendor` project has, or a `cargo local-registry` — so the project builds every crates.io crate offline from it and a Socket registry pin would break `cargo build --frozen --offline`. The configs read are the ones cargo reads for a build in the scanned directory: the project's own `.cargo/config*`, every parent directory's (a monorepo root) and `$CARGO_HOME`'s. Every cargo dependency is skipped with nothing written, one warning each naming the crate and the config that set the replacement, and the detail points at agent mode, which patches the copy cargo builds in place. A replacement with a registry mirror is not an offline source and is still redirected). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. -**Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm lock also installs an unpatched copy of the same `name@version` from a `file:` directory or tarball dependency is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. +**Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped when a variant other than `sources` / `javadoc` names the patched release), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`; the dep is skipped when a profile holds its only declaration), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). @@ -1456,6 +1456,7 @@ command's integration tests check its output. | `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. Likewise, a `pylock.toml` / `pylock..toml` that `pipenv lock` writes beside `Pipfile.lock` under `[pipenv] use_pylock = true` is wired with it (#1368), with the same ledger, revert, supersede and re-run behavior (`pypi_pipenv_pylock_wired`); a pylock the wiring cannot rewrite in place (a symlink, a non-registry source) stays a loser. | | `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. | | `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. | +| `vendor_copy_unpatched` | `skipped` (warning) | vendor / scan / get `--mode vendored` (yarn berry, #939): the registry entry was wired, but `yarn.lock` also installs the same `name@version` from a `file:` tarball / directory, URL tarball or `portal:` / `link:` dependency declared under another name (`lp2@file:…`), which the name-keyed `resolutions` entry never reaches; the detail names that entry, and that copy stays unpatched (`vex` does not attest the package beside it). | | `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. | | `vendor_yarn_berry_unsupported` | `failed` | vendor (npm): yarn-berry Plug'n'Play layout; use its native `yarn patch` workflow. | | `vendor_bun_lockb_invalid` | `failed` | vendor / scan / get `--mode vendored`: the binary lock is malformed, unreadable, unsupported or cannot be rewritten safely. The detail names the parser, hash or filesystem error. Refused before patch downloads and before hosted takeover; the package's `failed` event carries `errorCode` and `error`, while `get ` also carries top-level `error.code`. Dry-run predicts the same refusal. | @@ -1516,7 +1517,7 @@ command's integration tests check its output. | `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. This includes a file the patch adds (empty beforeHash) that already exists with other content. `--strict` turns this case into a `failed` event instead. Agent-mode `get` / `scan --json` carry it as a `(content_mismatch_overwritten) …` `warnings[]` entry (#1004). | | `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). | | `redirect_unattributable` | `skipped` (hosted event) | scan/get `--mode hosted`: the rewriters would pin the patch, but lockfile discovery over the result reads that pin as contested (another lock or requirements file resolves the same version elsewhere, or the pin is not one the package manager consumes), so `vex`, `rollback`, `remove` and `vendor` would refuse it. The candidate is left out of the rewrite, so nothing is written for it; the detail carries discovery's findings. Exit code unchanged. | -| `redirect_copy_unpatched` | `warnings[]` (warning) | scan/get `--mode hosted` (pnpm): the pin was written, but its own lock also installs an unpatched copy of the same `name@version` from a `file:` directory or tarball dependency (#935), which no rewrite reaches. The detail names the lock entry. Neither the run's own `--vex` nor a later `vex` attests the package (`vex` warns `patched_ref_unattributable`), but discovery still manages the pin, so `rollback` / `remove` undo it. | +| `redirect_copy_unpatched` | `warnings[]` (warning) | scan/get `--mode hosted` (pnpm, yarn): the pin was written, but its own lock also installs an unpatched copy of the same `name@version`, which no rewrite reaches: a pnpm `file:` directory or tarball dependency (#935), or a yarn `file:` tarball / directory, URL tarball or berry `portal:` / `link:` dependency declared under another name (`lp2@file:…`, #939, #1236; not repeated for a copy a yarn classic skip warning already names). The detail names the lock entry. Neither the run's own `--vex` nor a later `vex` attests the package (`vex` warns `patched_ref_unattributable`), but discovery still manages the pin, so `rollback` / `remove` undo it. | | `redirect_pin_lockless` | `warnings[]` (warning) | scan/get `--mode hosted` (nuget, cargo): the pin was written without a lockfile that records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it as unattributable. The detail names the lockfile to create (`dotnet restore --use-lock-file`, `cargo generate-lockfile`) before re-running the hosted scan. | | `redirect_pypi_stale_install` | `warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. | | `redirect_nuget_stale_global_package` / `vendor_nuget_stale_global_package` | `warnings[]` (warning, hosted) / `skipped` (warning, vendor advisory event) | scan `--mode hosted` / `vendor` / `scan --mode vendored` (nuget, v5.0 #352): NuGet's global packages folder (`NUGET_PACKAGES`, else `~/.nuget/packages`) already holds the patched package extracted from other bytes (its `.nupkg.metadata` `contentHash` is not the patched one). A patch keeps the upstream id and version and NuGet restores a package already in that folder without asking any source, so `dotnet restore` would keep the upstream bytes (silently without a lock, NU1403 against the re-pinned lock with one). The detail names the directory and the remedy: delete it, then `dotnet restore` (`dotnet nuget locals global-packages --clear` also works but empties the whole machine-wide folder, and the detail says so); CI caches of that folder must drop it too. Read-only like the gem guard (the folder is shared machine-wide), re-fired on every re-run until the copy is gone, skipped on `--dry-run`; a hosted purl it flags is excluded from the same run's `--vex` `assume_applied` and reported stale. A dir without `.nupkg.metadata` (a legacy `packages/` folder) is never judged. | diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 7d23a3b40..f43fa0199 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -278,6 +278,18 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: unpatched artifact. The reverse shape — an alias of the patched NAME pointing at a different package (`"left-pad@npm:some-fork@^1.3.0"`, the fork-substitution idiom) — is never rewritten: it resolves a different package. +- **yarn copies under another dependency name (classic & berry)** — yarn keys a `file:` + tarball or directory, a URL tarball and a berry `portal:` / `link:` dependency by the + name the depender gave it: `"lp2": "file:./left-pad-1.3.0.tgz"` locks as `lp2@file:…`, + while `node_modules/lp2` is left-pad@1.3.0. No pin or vendored wiring of `left-pad` + reaches that copy, so it stays unpatched. Which package such a copy is comes from the + copy itself (the tarball's or directory's `package.json`, or the registry package a + URL's path names; a berry `portal:` / `link:` target's `package.json` also gives the + version, since the lock records `0.0.0-use.local`). 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 + `vex` never attests the package from that lock while the copy is there. - **yarn classic and yarn 2+** — installing with yarn 2+ (berry) migrates a classic (v1) `yarn.lock` and re-resolves every entry from the registry, dropping hosted pins and vendored wiring alike, so the packages install unpatched. A run that leaves such a pin @@ -325,9 +337,10 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: that copy stays unpatched; `vex` never attests the package from that lock while the copy is there. This holds whatever dependency name the project gives the copy: yarn 1 locks `"lp2": "file:./lpdir"` as `lp2@file:./lpdir`, so which package it is comes from - the directory's `package.json` (and, for `vex`, a `file:` tarball's manifest or a - registry tarball URL's path; hosted and vendored scans name a URL copy with - `redirect_yarn_classic_non_registry_entry_skipped` / + the directory's `package.json` (or a `file:` tarball's manifest, or a registry tarball + URL's path; hosted scans name a URL copy with + `redirect_yarn_classic_non_registry_entry_skipped` and a `file:` tarball copy with + `redirect_copy_unpatched`, and vendored scans name either with `vendor_yarn_classic_non_registry_entry_skipped`). A git copy under another name records no package name in the lock and is not detected. When every entry of the package is such a copy (git, `file:` directory, non-registry tarball or `link:`), vendoring refuses with `vendor_lock_entry_not_rewritable` naming them, From 382bc1a3c63000a03033e9e90f886512bf6fe96a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:06:36 +0000 Subject: [PATCH 6/7] Tighten renamed-copy checks after review - hosted: skip a copy only when a yarn rewriter warning names the same root yarn.lock entry, so a pnpm or unrelated warning never hides one - vendored berry: a package installed only under other dependency names is refused as vendor_lock_entry_not_rewritable naming those copies (in the loop and the download plan), not "not found, run yarn install", which can't help - vendored: never read a copy of another version (no needless tarball unpacking) or socket-patch's own .socket/ wiring as a user's copy - keep the formats layer free of async code: the tarball read off the runtime moves to vendor::yarn_copies::copy_identity Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/yarn/copy.rs | 18 --- crates/socket-patch-core/src/hosted/engine.rs | 21 ++- .../src/vendor/yarn_berry_lock.rs | 138 +++++++++++++++--- .../src/vendor/yarn_copies.rs | 36 ++++- .../src/vex/discover/yarn.rs | 4 +- 5 files changed, 168 insertions(+), 49 deletions(-) diff --git a/crates/socket-patch-core/src/formats/yarn/copy.rs b/crates/socket-patch-core/src/formats/yarn/copy.rs index 0a835998d..ec1ea9cd1 100644 --- a/crates/socket-patch-core/src/formats/yarn/copy.rs +++ b/crates/socket-patch-core/src/formats/yarn/copy.rs @@ -71,24 +71,6 @@ impl CopyRef { Some((name, lock_version.to_string())) } - /// [`CopyRef::identity`] off the async runtime when it unpacks a - /// tarball (a user's `file:` tarball can be large). - pub(crate) async fn identity_of( - &self, - lock_version: &str, - bytes: Option>, - ) -> Option<(String, String)> { - if !matches!(self, CopyRef::Tarball(_)) { - return self.identity(lock_version, bytes.as_deref()); - } - let copy = self.clone(); - let lock_version = lock_version.to_string(); - tokio::task::spawn_blocking(move || copy.identity(&lock_version, bytes.as_deref())) - .await - .ok() - .flatten() - } - /// Where the copy installs from, for a warning or diagnostic: "installs /// it from …" / "links it to …". A url is redacted, since the phrase /// may be published (a VEX document, a JSON envelope). diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 538251b09..37fcb0388 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1910,12 +1910,7 @@ async fn unattributed_pins( ) }) { copy_shadowed.insert(r.uuid.clone()); - let entry = format!("`{}`", c.key); - if done - .rewrite - .warnings - .iter() - .any(|w| w.detail.contains(&entry)) + if yarn_rewriter_named(&done.rewrite.warnings, &c.file, &c.key) || !named.insert((c.file.as_path(), c.key.as_str())) { continue; @@ -2010,6 +2005,20 @@ async fn unattributed_pins( } } +/// Whether a yarn rewriter's own warning already names lock entry `key` of +/// the root `yarn.lock` (the only yarn lock the rewriters read): the yarn +/// classic `file:` directory / url / alias skips name the entry by its raw +/// key, exactly as discovery records it. +fn yarn_rewriter_named(warnings: &[RewriteWarning], file: &std::path::Path, key: &str) -> bool { + if file != std::path::Path::new("yarn.lock") { + return false; + } + let entry = format!("`{key}`"); + warnings + .iter() + .any(|w| w.code.starts_with("redirect_yarn_") && w.detail.contains(&entry)) +} + /// What [`unattributed_pins`] decided for one pass. #[derive(Default)] struct Gated { 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 f5700bd51..167698af4 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -240,9 +240,24 @@ impl NpmLockBackend for YarnBerryBackend { // ANOTHER dependency name (`lp2@file:…`, #939): the name-keyed // resolutions entry never moves it, so it keeps installing the // unpatched bytes. Named, like the alias entries above. - for (key, source) in - super::yarn_copies::other_name_copies(project_root, &lock_text, name, version).await - { + let other_name = + super::yarn_copies::other_name_copies(project_root, &lock_text, name, version).await; + // The package IS installed, only under other names, and `yarn + // install` can't re-lock those copies: not "not found" (#939). + if scan.target.is_none() && scan.alias_keys.is_empty() && !other_name.is_empty() { + let keys: Vec<&str> = other_name.iter().map(|(key, _)| key.as_str()).collect(); + return Err(Box::new(refused( + "vendor_lock_entry_not_rewritable", + format!( + "{YARN_LOCK} installs {name}@{version} only under other dependency names \ + ({}), which the name-keyed resolutions entry vendoring writes can never \ + reach (`yarn install` will not help); depend on `{name}` from the \ + registry to vendor it", + keys.join(", ") + ), + ))); + } + for (key, source) in other_name { warnings.push(VendorWarning::new( "vendor_copy_unpatched", format!( @@ -631,6 +646,7 @@ fn target_gate( pub(super) struct BerryProject { blocks: std::sync::Arc>, pkg: Value, + lock_text: std::sync::Arc, } /// Read the project as [`vendor_yarn_berry`]'s steps 2–5 do — the lock @@ -666,7 +682,11 @@ pub(super) async fn read_project(project_root: &Path) -> Result Vec> { - super::npm_common::gate_packages( - read_project(project_root).await, - packages, - |project, coords| { - let (name, version) = (coords.name.as_str(), coords.version.as_str()); - let pkg_obj = project - .pkg - .as_object() - .ok_or("vendor_yarn_berry_manifest_unreadable")?; - resolutions_gate(pkg_obj, name, version) - .map_err(|o| super::npm_common::refusal_code(&o))?; - let scan = - scan_berry_target(&project.blocks, name, version).map_err(|(code, _)| code)?; - target_gate(&scan, name, version) - .map(drop) - .map_err(|o| super::npm_common::refusal_code(&o)) - }, - ) + let project = read_project(project_root).await; + let lock_text = project + .as_ref() + .ok() + .map(|p| std::sync::Arc::clone(&p.lock_text)); + let mut gated = super::npm_common::gate_packages(project, packages, |project, coords| { + let (name, version) = (coords.name.as_str(), coords.version.as_str()); + let pkg_obj = project + .pkg + .as_object() + .ok_or("vendor_yarn_berry_manifest_unreadable")?; + resolutions_gate(pkg_obj, name, version) + .map_err(|o| super::npm_common::refusal_code(&o))?; + let scan = scan_berry_target(&project.blocks, name, version).map_err(|(code, _)| code)?; + target_gate(&scan, name, version) + .map(drop) + .map_err(|o| super::npm_common::refusal_code(&o)) + }); + // The loop's step 6 turns "not found" into "not rewritable" when the + // package's only copies are locked under other names; so does the plan. + // ("Not found" with alias entries is the alias refusal, left as is.) + if let Some(lock_text) = lock_text { + for ((purl, record), gate) in packages.iter().zip(gated.iter_mut()) { + if *gate != Err("vendor_lock_entry_not_found") { + continue; + } + let Ok(coords) = super::npm_common::guard_coordinates(purl, record) else { + continue; + }; + let blocks = scan_blocks_shared(&lock_text); + let aliased = scan_berry_target(&blocks, &coords.name, &coords.version) + .is_ok_and(|scan| !scan.alias_keys.is_empty()); + if !aliased + && !super::yarn_copies::other_name_copies( + project_root, + &lock_text, + &coords.name, + &coords.version, + ) + .await + .is_empty() + { + *gate = Err("vendor_lock_entry_not_rewritable"); + } + } + } + gated } /// Undo one yarn-berry vendored package: restore the recorded lock entry, @@ -2515,6 +2564,51 @@ __metadata: } } + /// #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 + /// `vendor_lock_entry_not_found` with a `yarn install` remedy that can't + /// help. Control: a copy holding another package is still "not found". + #[tokio::test] + async fn issue_939_only_other_name_copies_are_refused_as_not_rewritable() { + let ws = "vendor-spike%40workspace%3A."; + let key = format!("lp2@file:./forks/left-pad::locator={ws}"); + let lock = B3_BEFORE_LOCK.replace( + "\"left-pad@npm:1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n", + &format!( + "\"{key}\":\n version: 1.3.0\n \ + resolution: \"lp2@file:./forks/left-pad#./forks/left-pad::hash=1a2b3c&locator={ws}\"\n" + ), + ); + assert!( + lock.contains(&key) && !lock.contains("left-pad@npm:"), + "{lock}" + ); + for (fork, code) in [ + ("left-pad", "vendor_lock_entry_not_rewritable"), + ("other-pkg", "vendor_lock_entry_not_found"), + ] { + let fx = fixture_with(B3_BEFORE_PKG, &lock).await; + let dir = fx.tmp.path().join("forks/left-pad"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("package.json"), + format!(r#"{{"name":"{fork}","version":"1.3.0"}}"#), + ) + .await + .unwrap(); + let detail = expect_refused(fx.vendor(false).await, code); + if code == "vendor_lock_entry_not_rewritable" { + assert!(detail.contains(&key), "{detail}"); + assert!(detail.contains("yarn install` will not help"), "{detail}"); + } + fx.assert_untouched().await; + let pre = + preflight_packages(fx.tmp.path(), &[("pkg:npm/left-pad@1.3.0", &fx.record)]).await; + assert_eq!(pre, vec![Err(code)], "{fork}"); + } + } + /// When the ONLY entry for the patched version is an alias descriptor, /// the refusal must say so — the generic "make sure the package is /// installed" detail would send the user to a `yarn install` that diff --git a/crates/socket-patch-core/src/vendor/yarn_copies.rs b/crates/socket-patch-core/src/vendor/yarn_copies.rs index 049c9290b..383c5985c 100644 --- a/crates/socket-patch-core/src/vendor/yarn_copies.rs +++ b/crates/socket-patch-core/src/vendor/yarn_copies.rs @@ -69,12 +69,25 @@ pub(crate) async fn other_name_copies( }; let mut out = Vec::new(); for (key, source, lock_version) in candidates { + // Only a `portal:` / `link:` copy needs its manifest for the + // version; any other copy of another version is no copy of this + // one, and is never read (a `file:` tarball is unpacked to read). + if !matches!(source, CopyRef::Linked(_)) && lock_version != version { + continue; + } + // A path into `.socket/` is socket-patch's own wiring, which + // discovery attributes as such, never a user's copy. + if source + .read_path() + .is_some_and(|rel| rel.starts_with(".socket/")) + { + continue; + } let bytes = match source.read_path() { Some(rel) => read_regular_to_bytes(&project_root.join(rel)).await.ok(), None => None, }; - if source - .identity_of(&lock_version, bytes) + if copy_identity(&source, &lock_version, bytes) .await .is_some_and(|(n, v)| n == name && v == version) { @@ -84,6 +97,25 @@ pub(crate) async fn other_name_copies( out } +/// [`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. +pub(crate) async fn copy_identity( + source: &CopyRef, + lock_version: &str, + bytes: Option>, +) -> Option<(String, String)> { + if !matches!(source, CopyRef::Tarball(_)) { + return source.identity(lock_version, bytes.as_deref()); + } + let source = source.clone(); + let lock_version = lock_version.to_string(); + tokio::task::spawn_blocking(move || source.identity(&lock_version, bytes.as_deref())) + .await + .ok() + .flatten() +} + /// Where a copy installs from, for a vendored warning. pub(crate) fn copy_origin(source: &CopyRef) -> &'static str { match source { diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 0e8aef30c..649aeb0eb 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -865,7 +865,9 @@ async fn record_copies(ctx: &DiscoverCtx<'_>, copies: Vec, out: &mut D Some(rel) => ctx.read_advisory_bytes(&rel).await, None => None, }; - let Some((name, version)) = copy.source.identity_of(©.version, bytes).await else { + let Some((name, version)) = + crate::vendor::yarn_copies::copy_identity(©.source, ©.version, bytes).await + else { continue; }; out.unpatched_copy( From 36b49c65d83f8659599c97ae3058a85f7e1f0e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:09:25 +0000 Subject: [PATCH 7/7] Document the berry not-rewritable refusal A berry package installed only under other dependency names is now refused as vendor_lock_entry_not_rewritable; say so beside the other renamed-copy behavior. Assisted-by: Claude Code:claude-opus-5-5 --- docs/ecosystems.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ecosystems.md b/docs/ecosystems.md index f43fa0199..adfc35a62 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -289,7 +289,9 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: 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 - `vex` never attests the package from that lock while the copy is there. + `vex` never attests the package from that lock while the copy is there. When the + package is installed only through such copies, vendoring refuses with + `vendor_lock_entry_not_rewritable` naming them, since `yarn install` can't help. - **yarn classic and yarn 2+** — installing with yarn 2+ (berry) migrates a classic (v1) `yarn.lock` and re-resolves every entry from the registry, dropping hosted pins and vendored wiring alike, so the packages install unpatched. A run that leaves such a pin