From 7202e0843557e30bcaf2cdc8a0e67a1bbf0353cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:22:23 +0000 Subject: [PATCH 1/3] Start fix for #1511 Assisted-by: Claude Code:claude-opus-5-5 From 5bb379e27605d9525cfc3d9197971a6df129960e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:32:13 +0000 Subject: [PATCH 2/3] Don't attest npm beside a file: dir copy Vendored `scan --vex`, a standalone `vex` and `vendor --check` read the npm lock through discovery, which skipped a `file:` directory dependency: npm records it as a link entry with no version, so discovery never saw that it holds the patched release. VEX then said `not_affected` while that workspace member loaded the unpatched directory. Discovery now resolves such a link to its source directory's entry, as the hosted rewriter does since #1482, and withholds the reference with `patched_ref_unattributable`. The lookup moves to vendor::npm_origin so both readers share it. Fixes #1511 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/in_process_vendor.rs | 127 ++++++++++++++++++ .../src/patch/redirect/mod.rs | 30 +---- .../src/vendor/npm_origin.rs | 23 ++++ .../socket-patch-core/src/vex/discover/npm.rs | 96 ++++++++++++- 4 files changed, 245 insertions(+), 31 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 84200d6fa..4e7a973f2 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3123,6 +3123,133 @@ async fn vendor_json_vex_warnings_ride_in_envelope() { ); } +/// REGRESSION (#1511): vendored npm's half of #1481. Under +/// `install-strategy=nested` one workspace member's registry copy is +/// vendored while another member depends on a `file:` directory holding +/// the same release. npm links that directory in place of a registry +/// copy, so it stays unpatched: neither the run's `--vex` nor a standalone +/// `vex` may attest the purl. +#[tokio::test] +async fn vendor_npm_file_directory_copy_is_not_attested() { + let fx = npm_fixture(); + // VEX refuses a metadata-less patch, so give it a vulnerability. + let mut manifest: Value = + serde_json::from_slice(&std::fs::read(fx.manifest_path()).unwrap()).unwrap(); + for (_, patch) in manifest["patches"].as_object_mut().unwrap() { + patch["vulnerabilities"] = json!({ + "GHSA-file-dir-copy": { + "cves": ["CVE-2024-15110"], + "summary": "file: directory copy fixture", + "severity": "high", + "description": "d", + } + }); + } + std::fs::write( + fx.manifest_path(), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + // npm 10's nested-workspace lock: `a`'s registry copy, and `b`'s + // versionless link to `lpdir`, whose own entry carries the release. + let mut lock = fx.lock_value(); + let packages = lock["packages"].as_object_mut().unwrap(); + let registry = packages.remove("node_modules/left-pad").unwrap(); + packages.insert( + "".into(), + json!({ + "name": "fixture", "version": "1.0.0", "workspaces": ["packages/*"] + }), + ); + packages.insert( + "lpdir".into(), + json!({ "name": "left-pad", "version": "1.3.0" }), + ); + packages.insert( + "node_modules/a".into(), + json!({ "resolved": "packages/a", "link": true }), + ); + packages.insert( + "node_modules/b".into(), + json!({ "resolved": "packages/b", "link": true }), + ); + packages.insert( + "packages/a".into(), + json!({ + "name": "a", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0" } + }), + ); + packages.insert("packages/a/node_modules/left-pad".into(), registry); + packages.insert( + "packages/b".into(), + json!({ + "name": "b", "version": "1.0.0", "dependencies": { "left-pad": "file:../../lpdir" } + }), + ); + packages.insert( + "packages/b/node_modules/left-pad".into(), + json!({ "resolved": "lpdir", "link": true }), + ); + std::fs::write(fx.lock_path(), serde_json::to_vec_pretty(&lock).unwrap()).unwrap(); + // The installed tree: `a`'s registry copy, `b`'s link target. + for dir in ["packages/a/node_modules/left-pad", "lpdir"] { + let pkg = fx.root().join(dir); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + } + + let vex_path = fx.root().join("vendored.vex.json"); + let (code, env) = vendor_cli( + fx.root(), + &["--vex", vex_path.to_str().expect("vex path is UTF-8")], + ); + let wired = fx.lock_value(); + assert_eq!( + wired["packages"]["packages/a/node_modules/left-pad"]["resolved"], + json!(format!("file:{}", rel_tgz())), + "the registry copy is still vendored: {env}" + ); + // No document is written when no statement survives, so a missing + // file is the expected "attests nothing" outcome. + let attested = |path: &Path| { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .is_some_and(|doc| doc.to_string().contains(PURL)) + }; + assert!( + !attested(&vex_path), + "in-run VEX must not attest a purl whose file: directory copy stays unpatched \ + (exit {code}): {env}" + ); + + let standalone = fx.root().join("standalone.vex.json"); + let (_, stdout, stderr) = run_cli( + fx.root(), + &[ + "vex", + "--cwd", + fx.root().to_str().unwrap(), + "--output", + standalone.to_str().unwrap(), + ], + &[], + ); + assert!( + !attested(&standalone), + "standalone vex must not attest it either\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("`packages/b/node_modules/left-pad`") && stderr.contains("UNPATCHED"), + "the unpatched copy is named\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); +} + // ───────────────────────────────────────────────────────────────────── // 12. fail-closed --vendor-source=service refuses --offline // ───────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4e2981668..d58fe30a8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -30,7 +30,7 @@ use crate::utils::line_endings::LineEndings; use crate::vendor::common::{parse_json_text, JsonLayout}; use crate::vendor::lock_inventory::{npm_lock_entries, NpmLockEntry, NpmLockSection}; use crate::vendor::npm_origin::{ - npm_non_registry_entries, npm_shrinkwrapped_entries, NpmOverrides, + npm_link_source_identity, npm_non_registry_entries, npm_shrinkwrapped_entries, NpmOverrides, }; mod bun_binary; @@ -1404,29 +1404,6 @@ impl From> for NpmLockTarget { } } -/// The `(name, version)` of the source directory an npm `packages` link -/// entry (at `pointer`) points at: the entry its `resolved` path names, -/// by that entry's `name` field, else its folder name, which keeps an -/// `@scope/` parent as npm's `nameFromFolder` does (npm omits `name` when -/// they agree). `None` when the lock records no version. -fn npm_link_source_identity(lock: &Value, pointer: &str) -> Option<(String, String)> { - let resolved = lock.pointer(pointer)?.get("resolved")?.as_str()?; - let source = lock.get("packages")?.get(resolved)?; - let version = source.get("version")?.as_str()?; - let name = match source.get("name").and_then(Value::as_str) { - Some(name) => name.to_string(), - None => { - let mut segments = resolved.rsplit('/'); - let base = segments.next().unwrap_or(resolved); - match segments.next() { - Some(scope) if scope.starts_with('@') => format!("{scope}/{base}"), - _ => base.to_string(), - } - } - }; - Some((name, version.to_string())) -} - /// Rewrite a single npm lockfile (`package-lock.json` or `npm-shrinkwrap.json`) /// in place. Factored out of `rewrite_npm_lock` so co-present locks each get /// the identical override rewrite (see the dual-lock note there). @@ -1470,7 +1447,10 @@ fn rewrite_one_npm_lock( .iter_mut() .filter(|t| t.link && t.version.is_none() && non_registry.contains_key(&t.key)) { - if let Some((name, version)) = npm_link_source_identity(&lock, &target.pointer) { + if let Some((name, version)) = lock + .pointer(&target.pointer) + .and_then(|link| npm_link_source_identity(&lock, link)) + { target.name = name; target.version = Some(version); } diff --git a/crates/socket-patch-core/src/vendor/npm_origin.rs b/crates/socket-patch-core/src/vendor/npm_origin.rs index 362bf57bd..717fdd1ec 100644 --- a/crates/socket-patch-core/src/vendor/npm_origin.rs +++ b/crates/socket-patch-core/src/vendor/npm_origin.rs @@ -412,6 +412,29 @@ pub(crate) fn npm_non_registry_entries( out } +/// The `(name, version)` of the source directory an npm `packages` link +/// entry points at: the entry its `resolved` path names, +/// by that entry's `name` field, else its folder name, which keeps an +/// `@scope/` parent as npm's `nameFromFolder` does (npm omits `name` when +/// they agree). `None` when the lock records no version. +pub(crate) fn npm_link_source_identity(lock: &Value, link: &Value) -> Option<(String, String)> { + let resolved = link.get("resolved")?.as_str()?; + let source = lock.get("packages")?.get(resolved)?; + let version = source.get("version")?.as_str()?; + let name = match source.get("name").and_then(Value::as_str) { + Some(name) => name.to_string(), + None => { + let mut segments = resolved.rsplit('/'); + let base = segments.next().unwrap_or(resolved); + match segments.next() { + Some(scope) if scope.starts_with('@') => format!("{scope}/{base}"), + _ => base.to_string(), + } + } + }; + Some((name, version.to_string())) +} + /// Every `packages` key installed beneath a package that ships its own /// `npm-shrinkwrap.json` (the lock marks that ancestor `"hasShrinkwrap": /// true`, e.g. `firebase-tools`, `netlify-cli`), mapped to the outermost diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 7698ca4b0..49c9ef0c2 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -69,7 +69,7 @@ use crate::vendor::lock_inventory::{ NpmLockNode, }; use crate::vendor::npm_origin::{ - npm_non_registry_entries, npm_shrinkwrapped_entries, NpmOverrides, + npm_link_source_identity, npm_non_registry_entries, npm_shrinkwrapped_entries, NpmOverrides, }; pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { @@ -469,11 +469,17 @@ fn drop_non_registry_installs( .get("name") .and_then(Value::as_str) .unwrap_or(key_name); - let Some(purl) = entry - .get("version") - .and_then(Value::as_str) - .and_then(|v| npm_purl(name, v)) - else { + let purl = match entry.get("version").and_then(Value::as_str) { + Some(version) => npm_purl(name, version), + // A `file:` directory dependency is a versionless link entry + // whose source directory's own entry carries the package + // (#1511), as the hosted rewriter reads it (#1481). + None if entry.get("link").and_then(Value::as_bool) == Some(true) => { + npm_link_source_identity(doc, entry).and_then(|(n, v)| npm_purl(&n, &v)) + } + None => None, + }; + let Some(purl) = purl else { continue; }; read.mentioned.insert(purl.clone()); @@ -2452,6 +2458,84 @@ mod tests { assert_eq!(run(&p).await.refs.len(), 1); } + /// #1511: npm records a `file:` DIRECTORY dependency as a versionless + /// link entry whose source directory's own entry carries the package. + /// Under `install-strategy=nested` one workspace member's registry copy + /// is wired while another member's `file:` directory copy of the same + /// release installs from that directory, unpatched: the ref is withheld + /// (#1482 is the hosted rewriter's half). Controls: a directory holding + /// ANOTHER version, and a plain workspace-member link, contest nothing. + #[tokio::test] + async fn a_file_directory_link_copy_of_the_same_release_is_not_attested() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let vendored = format!("file:.socket/vendor/npm/{UUID_B}/left-pad-1.3.0.tgz"); + let lock = |resolved: &str, source: serde_json::Value| { + lock_with_packages(serde_json::json!({ + "": { "name": "app", "version": "1.0.0", "workspaces": ["packages/*"] }, + "lpdir": source, + "node_modules/a": { "resolved": "packages/a", "link": true }, + "node_modules/b": { "resolved": "packages/b", "link": true }, + "packages/a": { "name": "a", "version": "1.0.0", + "dependencies": { "left-pad": "1.3.0" } }, + "packages/a/node_modules/left-pad": { + "version": "1.3.0", "resolved": resolved, "integrity": SRI + }, + "packages/b": { "name": "b", "version": "1.0.0", + "dependencies": { "left-pad": "file:../../lpdir" } }, + "packages/b/node_modules/left-pad": { "resolved": "lpdir", "link": true }, + })) + }; + // npm omits the source's `name` when it matches the folder name, so + // both spellings are covered. + let named = serde_json::json!({ "name": "left-pad", "version": "1.3.0" }); + let unnamed = serde_json::json!({ "version": "1.3.0" }); + for (wiring, resolved) in [("hosted", &hosted), ("vendored", &vendored)] { + for (label, source) in [("named", &named), ("folder-named", &unnamed)] { + let p = Project::new(); + let text = lock(resolved, source.clone()); + // The folder-named source spells its identity by the + // directory, so name the directory after the package. + let text = if label == "folder-named" { + text.replace("lpdir", "left-pad") + } else { + text + }; + p.write("package-lock.json", text); + let out = run(&p).await; + assert!( + out.refs.is_empty(), + "{wiring} / {label}: {} refs", + out.refs.len() + ); + let diag = out + .diagnostics + .iter() + .find(|d| d.code == DIAG_REF_UNATTRIBUTABLE) + .unwrap_or_else(|| panic!("{wiring} / {label}: {:?}", out.diagnostics)); + assert!( + diag.detail.contains("packages/b/node_modules/left-pad"), + "{wiring} / {label}: {}", + diag.detail + ); + // The registry copy's wiring is the rewriters' own output + // beside a copy they skip: still unwound (#828). + assert_eq!(out.shadowed.len(), 1, "{wiring} / {label}"); + } + } + // Control: the directory holds another release. + let p = Project::new(); + p.write( + "package-lock.json", + lock( + &vendored, + serde_json::json!({ "name": "left-pad", "version": "1.2.0" }), + ), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{:?}", out.diagnostics); + assert!(!diag_codes(&out).contains(&DIAG_REF_UNATTRIBUTABLE)); + } + /// Negative shapes: a uuid on a foreign host, a placeholder token, the /// root and workspace-member keys, and an escaping vendored path. #[tokio::test] From 7b7f58c5feb380178069220c1f45dae1a4d2fcfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 00:45:31 +0000 Subject: [PATCH 3/3] Name the unpatched copy in vendor --check With the #1511 fix, `vendor --check` on a project whose vendored npm package also installs from a `file:` directory failed with "wiring missing: no lockfile or config references ... re-run socket-patch vendor". The lock does reference the artifact, and re-vendoring cannot reach that copy, so the advice looped. The same held for a bundled, git, url or `file:` tarball copy. The check now reports `copy unpatched:` with the lock entry that installs the unpatched copy, and says to depend on the registry release there instead. Discovery's link lookup is also limited to entries npm installs from a `file:` spec, as in the hosted rewriter, and the regression tests gain a workspace-member control and assert the in-run VEX warning. Refs #1511 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 5 ++- .../socket-patch-cli/src/commands/vendor.rs | 17 +++++++ .../tests/in_process_vendor.rs | 31 ++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 20 +++++++++ .../socket-patch-core/src/vex/discover/npm.rs | 45 ++++++++++++++----- 5 files changed, 105 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index e4dc11faa..304a83fa3 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -2023,7 +2023,10 @@ wiring: an entry whose lockfile or config no longer references its `npm install` re-resolved it) fails by the same liveness rule as `vex`'s `vendor_unwired`. The reason names the cause: another lock resolving the same version from elsewhere (`wiring contested`, naming both locks; delete the one -the project does not install from), or, for npm and PyPI, a dependency no lock +the project does not install from), an unpatched copy of the same version +that no rewire reaches installing beside the wiring, such as a bundled copy or +an npm git / url / `file:` dependency (`copy unpatched`, naming that lock +entry; re-vendoring cannot reach it, #1511), or, for npm and PyPI, a dependency no lock resolves any more (`dependency removed`; `scan --mode vendored --prune` reverts the entry), or, for PyPI, a project file that still names the artifact while nothing installs from it (`wiring incomplete`, naming the file; re-lock, then diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index f34123a42..3516e7c49 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -464,6 +464,23 @@ async fn unwired_check_failure( c.other.display(), ); } + if let Some((r, diag)) = discovery.vendored_shadow(&entry.base_purl, &entry.uuid) { + // Re-vendoring cannot reach that copy: name it instead (#1511). + let copy = diag.map_or_else( + || { + format!( + "{} wires {dir}, but the build also installs an unpatched copy of the \ + same version", + r.source_file.display() + ) + }, + |d| d.detail.clone(), + ); + return format!( + "copy unpatched: {copy}; re-vendoring cannot reach that copy, so depend on the \ + registry release there (or remove that copy) to have it patched" + ); + } // Only the npm-family and Python extractors record every lock entry // (`resolved_elsewhere`), so only there does "no lock resolves it" // prove the dependency is gone rather than unreadable. diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 4e7a973f2..bd3377f85 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -3214,6 +3214,22 @@ async fn vendor_npm_file_directory_copy_is_not_attested() { json!(format!("file:{}", rel_tgz())), "the registry copy is still vendored: {env}" ); + // The in-run VEX ran and withheld the ref, naming the unpatched copy; + // with nothing left to attest it fails closed, as for a `file:` + // tarball or URL copy. + assert_eq!(code, 1, "{env}"); + assert_eq!(env["error"]["code"], "no_applicable_patches", "{env}"); + assert!( + env["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| { + w["code"] == "patched_ref_unattributable" + && w["detail"] + .as_str() + .is_some_and(|d| d.contains("`packages/b/node_modules/left-pad`")) + })), + "{env}" + ); // No document is written when no statement survives, so a missing // file is the expected "attests nothing" outcome. let attested = |path: &Path| { @@ -3224,10 +3240,21 @@ async fn vendor_npm_file_directory_copy_is_not_attested() { }; assert!( !attested(&vex_path), - "in-run VEX must not attest a purl whose file: directory copy stays unpatched \ - (exit {code}): {env}" + "in-run VEX must not attest a purl whose file: directory copy stays unpatched: {env}" ); + // `vendor --check` fails, naming the copy rather than claiming nothing + // wires the artifact and prescribing a re-vendor that cannot reach it. + let (check, check_env) = vendor_cli(fx.root(), &["--check"]); + assert_eq!(check, 1, "{check_env}"); + let reason = check_env["events"][0]["reason"] + .as_str() + .unwrap_or_default(); + assert!( + reason.starts_with("copy unpatched:") + && reason.contains("`packages/b/node_modules/left-pad`"), + "{check_env}" + ); let standalone = fx.root().join("standalone.vex.json"); let (_, stdout, stderr) = run_cli( fx.root(), diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 05731aa72..63cb35d7a 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1048,6 +1048,26 @@ impl Discovery { }) } + /// The withholding that killed a VENDORED ledger claim because the + /// build also installs an unpatched copy of the same version that no + /// rewire reaches (a bundled copy, or one npm installs from a git / + /// url / `file:` spec): the wiring ref moved to [`Discovery::shadowed`], + /// with the [`DIAG_REF_UNATTRIBUTABLE`] diagnostic naming that copy + /// when one does. Lets a reader of a dead claim name the copy instead + /// of saying nothing wires the artifact (#1511). + pub fn vendored_shadow(&self, purl: &str, uuid: &str) -> Option<(&PatchedRef, Option<&Diag>)> { + let key = canonical_base_purl(purl); + let shadowed = self.shadowed.iter().find(|r| { + r.uuid == uuid && r.mode == WiringMode::Vendored && PurlKey::same(&r.purl, &key) + })?; + let diag = self.diagnostics.iter().find(|d| { + d.code == DIAG_REF_UNATTRIBUTABLE + && d.file == shadowed.source_file + && d.detail.contains(shadowed.purl.as_str()) + }); + Some((shadowed, diag)) + } + /// Whether any lock discovery read resolves `purl` (any spelling) at /// all: wired to a Socket patch, contested, or from elsewhere /// ([`Discovery::resolved_elsewhere`]). diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 49c9ef0c2..7720d5e5f 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -464,21 +464,26 @@ fn drop_non_registry_installs( let mut unpatched: Vec<(String, &str, &str)> = Vec::new(); for (key, why) in &elsewhere { let entry = &doc["packages"][key.as_str()]; - let key_name = key.rsplit_once("node_modules/").map_or("", |(_, n)| n); - let name = entry - .get("name") - .and_then(Value::as_str) - .unwrap_or(key_name); - let purl = match entry.get("version").and_then(Value::as_str) { - Some(version) => npm_purl(name, version), + let identity = match entry.get("version").and_then(Value::as_str) { + Some(version) => { + let key_name = key.rsplit_once("node_modules/").map_or("", |(_, n)| n); + let name = entry + .get("name") + .and_then(Value::as_str) + .unwrap_or(key_name); + Some((name.to_string(), version.to_string())) + } // A `file:` directory dependency is a versionless link entry // whose source directory's own entry carries the package // (#1511), as the hosted rewriter reads it (#1481). - None if entry.get("link").and_then(Value::as_bool) == Some(true) => { - npm_link_source_identity(doc, entry).and_then(|(n, v)| npm_purl(&n, &v)) + None if foreign.contains(key) + && entry.get("link").and_then(Value::as_bool) == Some(true) => + { + npm_link_source_identity(doc, entry) } None => None, }; + let purl = identity.and_then(|(name, version)| npm_purl(&name, &version)); let Some(purl) = purl else { continue; }; @@ -2464,7 +2469,8 @@ mod tests { /// is wired while another member's `file:` directory copy of the same /// release installs from that directory, unpatched: the ref is withheld /// (#1482 is the hosted rewriter's half). Controls: a directory holding - /// ANOTHER version, and a plain workspace-member link, contest nothing. + /// ANOTHER version, and a workspace member linked without a `file:` + /// spec, contest nothing. #[tokio::test] async fn a_file_directory_link_copy_of_the_same_release_is_not_attested() { let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); @@ -2534,6 +2540,25 @@ mod tests { let out = run(&p).await; assert_eq!(out.refs.len(), 1, "{:?}", out.diagnostics); assert!(!diag_codes(&out).contains(&DIAG_REF_UNATTRIBUTABLE)); + // Control: a workspace member that is itself `left-pad@1.3.0`, + // linked by the workspace (no `file:` spec), is first-party source. + let p = Project::new(); + p.write( + "package-lock.json", + lock_with_packages(serde_json::json!({ + "": { "name": "app", "version": "1.0.0", "workspaces": ["packages/*"] }, + "node_modules/a": { "resolved": "packages/a", "link": true }, + "node_modules/left-pad": { "resolved": "packages/left-pad", "link": true }, + "packages/a": { "name": "a", "version": "1.0.0", + "dependencies": { "left-pad": "1.3.0" } }, + "packages/a/node_modules/left-pad": { + "version": "1.3.0", "resolved": vendored, "integrity": SRI + }, + "packages/left-pad": { "name": "left-pad", "version": "1.3.0" }, + })), + ); + let out = run(&p).await; + assert_eq!(out.refs.len(), 1, "{:?}", out.diagnostics); } /// Negative shapes: a uuid on a foreign host, a placeholder token, the