diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a92c2aea..cde137b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,6 +123,11 @@ limits, and required install commands. not build from a previous patch's modified bytes. Verified service artifacts keep their identity; integrity failures do not fall through to a local rebuild. Repair rebuilds against recorded pins and reports unavailable inputs. +- VEX no longer attests an npm package that also ships a bundled, unpatched + copy of the same `name@version` (`inBundle: true`, or v1 `bundled: true`). + npm unpacks that copy from the parent's tarball, so no rewire reaches it; the + reference is now reported `patched_ref_unattributable`, naming the bundled + copy, in hosted and vendored mode (#325). - API throttling uses bounded retries, failed queries appear in JSON diagnostics, and hosted reference resolution handles batches larger than 500 patches. - Transient apply locks are removed on normal command exit; no-op scans and full diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bcbf0fa68..c053cac30 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -374,7 +374,7 @@ Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. -* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. +* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 45b2ae4b2..54b7e3adc 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -1618,6 +1618,61 @@ fn a_sibling_lock_resolving_the_registry_contests_a_ledger_record() { } } +/// REGRESSION (#325): the hosted rewriter rewires the hoisted +/// `left-pad@1.3.0` and skips a parent's bundled copy of the same version +/// (`redirect_npm_bundled_instance_skipped`: npm unpacks it from the +/// parent's tarball, so it stays unpatched). The ledger record must be dead +/// (`redirect_unwired`, naming the bundled copy), not attested from the +/// lock basis. Without the bundled copy the same ledger attests. +#[test] +fn hosted_npm_patch_with_an_unpatched_bundled_copy_is_not_attested() { + let purl = "pkg:npm/left-pad@1.3.0"; + for bundled in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_hosted_package_lock(cwd, &[("left-pad", "1.3.0", UUID)], true); + if bundled { + let lock_path = cwd.join("package-lock.json"); + let mut lock: Value = + serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap(); + let packages = lock["packages"].as_object_mut().unwrap(); + packages.insert( + "node_modules/bund".to_string(), + serde_json::json!({ "version": "1.0.0", "resolved": "file:bund-1.0.0.tgz" }), + ); + packages.insert( + "node_modules/bund/node_modules/left-pad".to_string(), + serde_json::json!({ + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "inBundle": true + }), + ); + std::fs::write(&lock_path, lock.to_string()).unwrap(); + } + write_redirect_ledger( + cwd, + &[( + purl, + make_record(UUID, &"b".repeat(64), "GHSA-bndl-host", &["CVE-2026-325"]), + )], + &[("package-lock.json", "npm_lock_entry")], + ); + let (code, env) = vex_json(cwd, &["--offline", "--no-verify"]); + if !bundled { + assert_eq!(code, Some(0), "control: {env}"); + continue; + } + assert_eq!(code, Some(1), "{env}"); + assert_eq!(skipped_reason(&env, purl), "redirect_unwired", "{env}"); + assert!( + env.to_string() + .contains("node_modules/bund/node_modules/left-pad"), + "the bundled copy is named: {env}" + ); + } +} + /// REGRESSION: a hosted pin with NO lock at all is the rewriters' ordinary /// output, not a stale shape — `rewrite_nuget` edits only nuget.config for a /// project without RestorePackagesWithLockFile, `rewrite_cargo` only diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 72541c569..d33961cb4 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -1343,6 +1343,135 @@ fn vendored_live_tree_out_of_sync_warns_but_attests() { ); } +/// REGRESSION (#325): the lock rewires the hoisted `lodash@4.17.21` to the +/// vendored tarball, but a parent package also BUNDLES `lodash@4.17.21` +/// (`inBundle: true`). npm unpacks that copy from the parent's tarball, so +/// no rewire reaches it and the build ships it unpatched. `vex` must not +/// attest the purl, from the lock basis (no `node_modules`) or after an +/// install whose hoisted copy is patched and bundled copy is not. The +/// same lock without the bundled copy is the control and still attests. +#[test] +fn vendored_npm_patch_with_an_unpatched_bundled_copy_is_not_attested() { + let purl = "pkg:npm/lodash@4.17.21"; + let uuid = "0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a"; + let patched = b"patched npm bytes\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + for (label, bundled, installed) in [ + ("control, lock basis", false, false), + ("bundled, lock basis", true, false), + ("bundled, installed", true, true), + ] { + let tmp = tempfile::tempdir().expect("create tempdir"); + let cwd = tmp.path(); + let rel = format!(".socket/vendor/npm/{uuid}/lodash-4.17.21.tgz"); + let sha256 = sha256_hex(&write_member_tgz( + &cwd.join(&rel), + "package/index.js", + patched, + )); + let record = make_record( + uuid, + "package/index.js", + &after_hash, + "GHSA-bndl-aaaa", + &["CVE-2026-325"], + ); + let wiring = write_matrix_wiring(cwd, "npm", uuid, &rel); + if bundled { + let lock_path = cwd.join("package-lock.json"); + let mut lock: Value = + serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap(); + let packages = lock["packages"].as_object_mut().unwrap(); + packages.insert( + "node_modules/bund".to_string(), + serde_json::json!({ "version": "1.0.0", "resolved": "file:bund-1.0.0.tgz" }), + ); + packages.insert( + "node_modules/bund/node_modules/lodash".to_string(), + serde_json::json!({ + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-T1JJR0lOQUw=", + "inBundle": true + }), + ); + std::fs::write(&lock_path, lock.to_string()).unwrap(); + } + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + detached_matrix_entry("npm", purl, uuid, &rel, sha256, record, wiring), + ); + let dir = cwd.join(".socket/vendor"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .unwrap(); + if installed { + for (pkg_dir, bytes) in [ + ("node_modules/lodash", &patched[..]), + ( + "node_modules/bund/node_modules/lodash", + b"original unpatched bytes\n", + ), + ] { + let nm = cwd.join(pkg_dir); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write( + nm.join("package.json"), + r#"{"name":"lodash","version":"4.17.21"}"#, + ) + .unwrap(); + std::fs::write(nm.join("index.js"), bytes).unwrap(); + } + } + + let vex_path = cwd.join("out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + let env: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "{label}: envelope JSON on stdout ({e}): {}", + String::from_utf8_lossy(&out.stdout) + ) + }); + if !bundled { + assert!(out.status.success(), "{label}: {env}"); + let doc: Value = + serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + assert_eq!( + doc["statements"].as_array().unwrap().len(), + 1, + "{label}: {doc}" + ); + continue; + } + assert_eq!(out.status.code(), Some(1), "{label}: {env}"); + assert!( + !vex_path.exists(), + "{label}: no VEX document may attest the purl: {env}" + ); + assert!( + env.to_string() + .contains("node_modules/bund/node_modules/lodash"), + "{label}: the envelope names the bundled copy: {env}" + ); + } +} + // ────────────────────────────────────────────────────────────────────── // 8. an applied, byte-verified agent-mode patch attests whether or not its // ecosystem has an install hook (there is no setup-state filter). diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 78c68fb5a..6e93465e3 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -70,7 +70,7 @@ pub(crate) mod vlt; pub(crate) mod wired; pub(crate) mod yarn; -pub(crate) use self::npm::{npm_lock_nodes, NpmLockNode}; +pub(crate) use self::npm::{npm_lock_bundled_nodes, npm_lock_nodes, NpmLockNode}; #[cfg(test)] pub(crate) use self::npm_family::inventory_npm_lock; pub(crate) use self::pypi::pipfile_lock_entries; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs index af514bd5e..932ad56dc 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs @@ -44,20 +44,55 @@ const MAX_LEGACY_NPM_DEPTH: usize = 64; /// through nested `dependencies`, `bundled: true` entries skipped (their /// nested trees are still walked). pub(crate) fn npm_lock_nodes(doc: &Value) -> Vec> { + walk_npm_lock(doc, Bundled::Skip) + .into_iter() + .map(|(_, node)| node) + .collect() +} + +/// The BUNDLED entries of a parsed npm lock, each with where the lock puts +/// it: `inBundle: true` in `packages` (lockfileVersion 2/3; the location is +/// the `packages` key), `bundled: true` in the v1 `dependencies` tree (the +/// location is the `>`-joined chain of dependency names). These are exactly +/// the entries [`npm_lock_nodes`] skips for being bundled, read from the +/// same tree npm reads. npm unpacks them from the parent package's own +/// tarball, so a Socket rewire never reaches them: a bundled copy of a +/// patched `name@version` stays unpatched in the install (the rewriters +/// warn `*_bundled_instance_skipped`), and lockfile discovery +/// (`vex::discover::npm`) weighs it against the rewired entries. +pub(crate) fn npm_lock_bundled_nodes(doc: &Value) -> Vec<(String, NpmLockNode<'_>)> { + walk_npm_lock(doc, Bundled::Only) +} + +/// Which side of the bundled split [`walk_npm_lock`] returns. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Bundled { + Skip, + Only, +} + +/// [`npm_lock_nodes`] / [`npm_lock_bundled_nodes`]: one walk, split on the +/// bundled flag. Locations are built only for [`Bundled::Only`] (empty +/// otherwise), so the common walk allocates nothing extra. +fn walk_npm_lock(doc: &Value, bundled: Bundled) -> Vec<(String, NpmLockNode<'_>)> { let mut out = Vec::new(); if let Some(packages) = doc.get("packages").and_then(Value::as_object) { for (key, node) in packages { let Some((_, key_name)) = key.rsplit_once("node_modules/") else { continue; }; - if npm_flag(node, "link") || npm_flag(node, "inBundle") { + if npm_flag(node, "link") || npm_flag(node, "inBundle") != (bundled == Bundled::Only) { continue; } let name = node.get("name").and_then(Value::as_str).unwrap_or(key_name); - out.push(NpmLockNode::of(name, node)); + let location = match bundled { + Bundled::Only => key.clone(), + Bundled::Skip => String::new(), + }; + out.push((location, NpmLockNode::of(name, node))); } } else if let Some(deps) = doc.get("dependencies").and_then(Value::as_object) { - walk_npm_legacy_dependencies(deps, 0, &mut out); + walk_npm_legacy_dependencies(deps, 0, bundled, "", &mut out); } out } @@ -89,17 +124,24 @@ fn npm_flag(node: &Value, key: &str) -> bool { fn walk_npm_legacy_dependencies<'a>( deps: &'a serde_json::Map, depth: usize, - out: &mut Vec>, + bundled: Bundled, + parent: &str, + out: &mut Vec<(String, NpmLockNode<'a>)>, ) { if depth > MAX_LEGACY_NPM_DEPTH { return; } for (name, node) in deps { - if !npm_flag(node, "bundled") { - out.push(NpmLockNode::of(name, node)); + let location = match bundled { + Bundled::Only if parent.is_empty() => name.clone(), + Bundled::Only => format!("{parent} > {name}"), + Bundled::Skip => String::new(), + }; + if npm_flag(node, "bundled") == (bundled == Bundled::Only) { + out.push((location.clone(), NpmLockNode::of(name, node))); } if let Some(nested) = node.get("dependencies").and_then(Value::as_object) { - walk_npm_legacy_dependencies(nested, depth + 1, out); + walk_npm_legacy_dependencies(nested, depth + 1, bundled, &location, out); } } } diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 8c13232ad..a6d0ca005 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -18,6 +18,9 @@ //! (workspace members — source dirs) are skipped, as are `link: true` and //! `inBundle: true` entries: npm installs those from elsewhere, so a //! Socket URL written there wires nothing (the rewriters refuse them too). +//! A bundled entry is still an install of its `name@version`, unpacked +//! unpatched from the parent's tarball, so it CONTESTS a ref for the same +//! version in either npm lock ([`push_uncontested`]) and in any other lock. //! * `dependencies` (lockfileVersion 1 ONLY): keyed by name, recursive //! through nested `dependencies`; `bundled: true` skipped for the same //! reason as `inBundle`. A v2 lock's `dependencies` is a legacy mirror @@ -37,7 +40,7 @@ //! after the entry it rewires, so a leaf naming a DIFFERENT package than //! the entry is not Socket-written and is diagnosed, not trusted. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; @@ -52,7 +55,9 @@ use crate::formats::pnpm::{ }; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::pnpm::rush_lock_rels; -use crate::vendor::lock_inventory::{npm_lock_nodes, LockIntegrity, NpmLockNode}; +use crate::vendor::lock_inventory::{ + npm_lock_bundled_nodes, npm_lock_nodes, LockIntegrity, NpmLockNode, +}; pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let mut locks: Vec = Vec::new(); @@ -66,11 +71,13 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { } /// What one parsed npm lock wires, plus the packages it resolves ELSEWHERE -/// (an entry whose `resolved` is not a Socket reference). +/// (an entry whose `resolved` is not a Socket reference) and the packages it +/// installs BUNDLED (purl → the first bundled entry's lock location). struct NpmLockRefs { file: &'static str, refs: Vec, unwired: BTreeSet, + bundled: BTreeMap, } /// Push every ref no OTHER npm lock contests. npm <= 11 installs from @@ -84,6 +91,10 @@ struct NpmLockRefs { /// another npm installs). The lock that does not mention the package at all /// contests nothing. Two locks wiring DIFFERENT patches are both emitted /// (the CLI's `wiring_conflict` gate). +/// +/// A bundled copy of the ref's `name@version` in either lock contests it +/// too, the same lock included (#325): the rewired entry and the bundled +/// copy install side by side, and the bundled one stays unpatched. fn push_uncontested(locks: Vec, out: &mut Discovery) { let wired: Vec> = locks .iter() @@ -91,6 +102,27 @@ fn push_uncontested(locks: Vec, out: &mut Discovery) { .collect(); for (i, lock) in locks.iter().enumerate() { for r in &lock.refs { + // A bundled copy of the same `name@version` in EITHER lock of the + // pair: npm unpacks it from the parent's tarball, so it installs + // unpatched beside the rewired entry whichever lock npm reads. + let bundled_in = locks + .iter() + .find_map(|l| l.bundled.get(&r.purl).map(|loc| (l.file, loc))); + if let Some((bundle_file, location)) = bundled_in { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + lock.file, + format!( + "{}: {} is wired to Socket patch {} but {bundle_file} also installs a \ + bundled copy of it at {location:?} (npm unpacks bundled \ + dependencies from the parent package's tarball, so no rewire \ + reaches it and that copy stays unpatched); the patch is not \ + attested while the build ships unpatched bytes of this version", + lock.file, r.purl, r.uuid, + ), + ); + continue; + } let contested_by = locks.iter().enumerate().find(|(j, other)| { *j != i && other.unwired.contains(&r.purl) && !wired[*j].contains(&r.purl) }); @@ -127,6 +159,7 @@ async fn extract_package_lock( file, refs: Vec::new(), unwired: BTreeSet::new(), + bundled: BTreeMap::new(), }; let doc: Value = match parse_json(file, &bytes) { Ok(doc) => doc, @@ -144,6 +177,17 @@ async fn extract_package_lock( for node in npm_lock_nodes(&doc) { entry_ref(ctx, file, &node, &mut read, out); } + // Bundled entries are never refs (a Socket url written there wires + // nothing), but each one IS an install of that `name@version` from a + // non-Socket source — the parent's tarball — so it contests a ref for + // the same version, here ([`push_uncontested`]) and in any other lock + // (the orchestrator). + for (location, node) in npm_lock_bundled_nodes(&doc) { + if let Some(purl) = node.version.and_then(|v| npm_purl(node.name, v)) { + out.resolved_elsewhere(file, Some(purl.clone())); + read.bundled.entry(purl).or_insert(location); + } + } Some(read) } @@ -840,6 +884,138 @@ mod tests { assert!(out.refs.is_empty(), "{:#?}", out.refs); } + /// The `DIAG_REF_UNATTRIBUTABLE` diagnostics that name a bundled copy. + fn bundled_contests(out: &Discovery) -> Vec<&Diag> { + out.diagnostics + .iter() + .filter(|d| d.code == DIAG_REF_UNATTRIBUTABLE && d.detail.contains("bundled")) + .collect() + } + + /// REGRESSION (#325): the rewired hoisted entry plus an `inBundle` copy + /// of the SAME `name@version` (npm unpacks it from the parent's tarball, + /// so no rewire reaches it and it stays unpatched) is NOT attested, in + /// either mode: the build ships unpatched bytes of that package. The + /// uuid stays recognized, so the ledger record is dead too, and a + /// bundled copy of a DIFFERENT version contests nothing. + #[tokio::test] + async fn bundled_copy_of_the_same_version_contests_the_wired_entry() { + 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 bundled = serde_json::json!({ + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-ORIG", + "inBundle": true, + }); + for (label, resolved, uuid, mode) in [ + ("hosted", hosted.clone(), UUID_A, WiringMode::Hosted), + ("vendored", vendored.clone(), UUID_B, WiringMode::Vendored), + ] { + let p = Project::new(); + p.write( + "package-lock.json", + lock_with_packages(serde_json::json!({ + "node_modules/left-pad": { "version": "1.3.0", "resolved": resolved, "integrity": SRI }, + "node_modules/bund": { "version": "1.0.0", "resolved": "file:bund-1.0.0.tgz" }, + "node_modules/bund/node_modules/left-pad": bundled, + })), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{label}: {:#?}", out.refs); + let contested = bundled_contests(&out); + assert_eq!(contested.len(), 1, "{label}: {:#?}", out.diagnostics); + assert!( + contested[0].detail.contains("pkg:npm/left-pad@1.3.0") + && contested[0] + .detail + .contains("node_modules/bund/node_modules/left-pad"), + "{label}: {:#?}", + contested[0] + ); + assert!(out.recognizes(uuid, mode), "{label}: {:#?}", out.recognized); + } + + // A bundled copy of another version is a different package: the + // wired entry is still attested. + let p = Project::new(); + p.write( + "package-lock.json", + lock_with_packages(serde_json::json!({ + "node_modules/left-pad": { "version": "1.3.0", "resolved": hosted, "integrity": SRI }, + "node_modules/bund/node_modules/left-pad": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.2.0.tgz", + "inBundle": true, + }, + })), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert!(bundled_contests(&out).is_empty(), "{:#?}", out.diagnostics); + } + + /// REGRESSION (#325), lockfileVersion 1: a `bundled: true` copy nested in + /// the parent's `dependencies` contests the wired top-level entry the + /// same way. + #[tokio::test] + async fn v1_bundled_copy_contests_the_wired_entry() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let p = Project::new(); + p.write( + "package-lock.json", + serde_json::json!({ + "lockfileVersion": 1, + "dependencies": { + "left-pad": { "version": "1.3.0", "resolved": url, "integrity": SRI }, + "bund": { + "version": "1.0.0", + "resolved": "file:bund-1.0.0.tgz", + "dependencies": { + "left-pad": { "version": "1.3.0", "bundled": true } + } + } + } + }) + .to_string(), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + assert_eq!(bundled_contests(&out).len(), 1, "{:#?}", out.diagnostics); + } + + /// REGRESSION (#325): the bundled copy sits in the OTHER npm lock of the + /// pair (npm <= 11 installs from the shrinkwrap, npm 12 from + /// package-lock.json). Either way that lock's install unpacks the + /// unpatched copy, so the ref is not attested. + #[tokio::test] + async fn bundled_copy_in_the_sibling_npm_lock_contests_the_ref() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let wired = serde_json::json!({ + "version": "1.3.0", "resolved": hosted, "integrity": SRI, + }); + let p = Project::new(); + p.write( + "package-lock.json", + lock_with_packages(serde_json::json!({ "node_modules/left-pad": wired.clone() })), + ); + p.write( + "npm-shrinkwrap.json", + lock_with_packages(serde_json::json!({ + "node_modules/left-pad": wired, + "node_modules/bund/node_modules/left-pad": { + "version": "1.3.0", "inBundle": true, + }, + })), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + assert_eq!(bundled_contests(&out).len(), 2, "{:#?}", out.diagnostics); + } + /// Negative shapes: a uuid on a foreign host, a placeholder token, the /// root and workspace-member keys, and an escaping vendored path. #[tokio::test]