From 8b0102e213149f3ebfe27e3323b1a44ce5df59b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:23:13 +0000 Subject: [PATCH 1/4] Start fix for #1534 Assisted-by: Claude Code:claude-opus-5-5 From c3da1ad6ea7c6de3c12e80d3a18634e429100ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:33:26 +0000 Subject: [PATCH 2/4] Test vendored scans over nested npm projects A root scan --mode vendored over a repository with an independent nested npm project (tools/x with its own lock) fails every run with vendor_lock_entry_not_found and drops its in-run VEX (#1534). These tests capture that, plus the scan . tools/x form and two controls: a package the root also locks, and a workspace member's stray lock. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/scan/main.rs | 1 + .../tests/scan/scan_nested_project_e2e.rs | 420 ++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs diff --git a/crates/socket-patch-cli/tests/scan/main.rs b/crates/socket-patch-cli/tests/scan/main.rs index 3074772ec..b9d4e031b 100644 --- a/crates/socket-patch-cli/tests/scan/main.rs +++ b/crates/socket-patch-cli/tests/scan/main.rs @@ -21,6 +21,7 @@ mod hosted_yarn_classic_sha1; mod scan_batch_sizing_e2e; mod scan_ecosystems_scope_e2e; mod scan_invariants; +mod scan_nested_project_e2e; mod scan_ordered_concurrency_e2e; mod scan_paths_e2e; mod scan_sync_e2e; diff --git a/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs new file mode 100644 index 000000000..7c4e6366b --- /dev/null +++ b/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs @@ -0,0 +1,420 @@ +//! #1534: a root `scan --mode vendored` over a repository that holds an +//! independent nested npm project (`tools/x/` with its own lock and +//! `node_modules`). The crawl reads `tools/x/node_modules`, but the root's +//! lock does not install it, so the root run must not hand those copies to +//! the root lock backend: it warns `nested_project_skipped` (naming the +//! project and the PATH to pass) instead of failing every run with +//! `vendor_lock_entry_not_found`, and the in-run VEX is still written. + +use crate::common::{binary, git_sha256, hermetic_command}; + +#[path = "../prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG_SLUG: &str = "test-org"; +const ROOT_PURL: &str = "pkg:npm/root-dep@1.0.0"; +const ROOT_UUID: &str = "11111111-1111-4111-8111-111111111111"; +const NESTED_PURL: &str = "pkg:npm/left-pad@1.3.0"; +const NESTED_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const BEFORE: &[u8] = b"before\n"; +const AFTER: &[u8] = b"after\n"; +/// base64 of AFTER, inlined as the view response's blobContent. +const AFTER_B64: &str = "YWZ0ZXIK"; +const WARNING: &str = "nested_project_skipped"; + +/// Write `name@version` installed under `/node_modules`. +fn install(dir: &Path, name: &str, version: &str) { + let pkg = dir.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), BEFORE).unwrap(); +} + +/// A v3 package-lock for project `project` whose only dependency is +/// `name@version`. +fn write_project(dir: &Path, project: &str, name: &str, version: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write( + dir.join("package.json"), + serde_json::json!({ + "name": project, + "version": "1.0.0", + "private": true, + "dependencies": { name: version }, + }) + .to_string(), + ) + .unwrap(); + let lock = serde_json::json!({ + "name": project, + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": project, + "version": "1.0.0", + "dependencies": { name: version } + }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz"), + "integrity": "sha512-orig==", + } + } + }); + let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); + bytes.push(b'\n'); + std::fs::write(dir.join("package-lock.json"), bytes).unwrap(); + install(dir, name, version); +} + +/// The repository root owns `root-dep@1.0.0`; `tools/x` is an independent +/// npm project (its own lock, not a workspace member) that installs +/// `left-pad@1.3.0`, which the root does not depend on. +fn write_repo(root: &Path) { + std::fs::create_dir_all(root.join(".git")).unwrap(); + write_project(root, "root", "root-dep", "1.0.0"); + write_project(&root.join("tools/x"), "x", "left-pad", "1.3.0"); +} + +fn encode(purl: &str) -> String { + purl.replace(':', "%3A") + .replace('/', "%2F") + .replace('@', "%40") +} + +async fn mount_patch(mock: &MockServer, purl: &str, uuid: &str) { + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", + encode(purl) + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": uuid, + "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "Vendor patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + let view = serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + "blobContent": AFTER_B64, + } }, + "vulnerabilities": { + "GHSA-aaaa-bbbb-cccc": { + "cves": ["CVE-2026-0001"], + "summary": "s", + "severity": "high", + "description": "d" + } + }, + "description": "Vendor patch", + "license": "MIT", + "tier": "free", + }); + prebuilt_common::mount_view(mock, &view, None).await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(mock) + .await; +} + +/// The batch endpoint: like the real API, it answers only for the purls +/// the request asks about (a `scan . tools/x` leg queries its own copies). +struct BatchOffers(Vec<(String, String)>); + +impl wiremock::Respond for BatchOffers { + fn respond(&self, request: &wiremock::Request) -> ResponseTemplate { + let body = String::from_utf8_lossy(&request.body); + let packages: Vec<_> = self + .0 + .iter() + .filter(|(purl, _)| body.contains(purl.as_str())) + .map(|(purl, uuid)| { + serde_json::json!({ + "purl": purl, + "patches": [{ + "uuid": uuid, + "purl": purl, + "tier": "free", + "cveIds": ["CVE-2026-0001"], + "ghsaIds": ["GHSA-aaaa-bbbb-cccc"], + "severity": "high", + "title": "vendor target" + }] + }) + }) + .collect(); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": packages, + "canAccessPaidPatches": false, + })) + } +} + +/// Batch discovery offers a patch for each `(purl, uuid)`; each has its +/// by-package search and view. +async fn mount_offers(mock: &MockServer, offers: &[(&str, &str)]) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(BatchOffers( + offers + .iter() + .map(|(p, u)| (p.to_string(), u.to_string())) + .collect(), + )) + .mount(mock) + .await; + for (purl, uuid) in offers { + mount_patch(mock, purl, uuid).await; + } +} + +/// [`mount_offers`] for both packages of [`write_repo`]. +async fn mount_api(mock: &MockServer) { + mount_offers(mock, &[(ROOT_PURL, ROOT_UUID), (NESTED_PURL, NESTED_UUID)]).await; +} + +fn run_scan(root: &Path, mock_uri: &str, mode: &str, extra: &[&str]) -> (i32, String, String) { + let mut cmd = hermetic_command(&binary()); + cmd.args([ + "scan", + "--json", + "--mode", + mode, + "--yes", + "--api-url", + mock_uri, + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ]) + .args(extra) + .current_dir(root); + let out = cmd.output().expect("run"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be one JSON envelope ({e}): {stdout}")) +} + +/// Every event in the envelope (top level and the nested vendor block). +fn events(v: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + for key in ["patches", "events"] { + if let Some(list) = v[key].as_array() { + out.extend(list.iter().cloned()); + } + if let Some(list) = v["vendor"][key].as_array() { + out.extend(list.iter().cloned()); + } + } + out +} + +fn warning<'a>(v: &'a serde_json::Value, code: &str) -> Option<&'a serde_json::Value> { + v["warnings"].as_array()?.iter().find(|w| w["code"] == code) +} + +fn assert_nested_skipped(v: &serde_json::Value) { + let w = warning(v, WARNING).unwrap_or_else(|| panic!("no {WARNING} warning: {v:#}")); + let detail = w["detail"].as_str().unwrap(); + assert!(detail.contains(NESTED_PURL), "{detail}"); + assert!(detail.contains("tools/x"), "names the project: {detail}"); + assert!( + detail.contains("scan --mode vendored tools/x"), + "names the PATH to pass: {detail}" + ); + let failed: Vec<_> = events(v) + .into_iter() + .filter(|e| e["action"] == "failed") + .collect(); + assert!(failed.is_empty(), "no failed event: {failed:#?}\n{v:#}"); +} + +/// The bug: run 1 (and every later run) ended `partialFailure`, exit 1, +/// with `vendor_lock_entry_not_found` for the nested project's copy. +#[tokio::test] +async fn root_vendored_scan_warns_about_a_nested_projects_copies() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_repo(root); + let nested_lock = std::fs::read(root.join("tools/x/package-lock.json")).unwrap(); + let mock = MockServer::start().await; + mount_api(&mock).await; + + for run in 1..=2 { + let (code, stdout, stderr) = run_scan(root, &mock.uri(), "vendored", &[]); + let v = envelope(&stdout); + assert_eq!( + code, 0, + "run {run}: exit 0\nstdout={stdout}\nstderr={stderr}" + ); + assert_eq!(v["status"], "success", "run {run}: {v:#}"); + assert_nested_skipped(&v); + } + // The root's own package was vendored; the nested lock is untouched. + let root_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!(root_lock.contains(".socket/vendor"), "{root_lock}"); + assert_eq!( + std::fs::read(root.join("tools/x/package-lock.json")).unwrap(), + nested_lock + ); +} + +/// The in-run VEX is written (it was dropped with the exit-1 run). +#[tokio::test] +async fn root_vendored_scan_with_vex_writes_the_document() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_repo(root); + let mock = MockServer::start().await; + mount_api(&mock).await; + + let out = root.join("out.vex.json"); + let out_arg = out.to_str().unwrap(); + let (code, stdout, stderr) = run_scan( + root, + &mock.uri(), + "vendored", + &["--vex", out_arg, "--vex-product", "pkg:npm/root@1.0.0"], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert_nested_skipped(&envelope(&stdout)); + let doc = std::fs::read_to_string(&out).expect("the in-run VEX is written"); + assert!( + !doc.contains("left-pad"), + "attests only the root's package: {doc}" + ); +} + +/// `scan --mode vendored . tools/x`: the `tools/x` leg vendors the nested +/// project, and the `.` leg no longer fails on its copy. +#[tokio::test] +async fn vendored_scan_of_root_and_nested_path_succeeds() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_repo(root); + let mock = MockServer::start().await; + mount_api(&mock).await; + + let mut cmd = hermetic_command(&binary()); + cmd.args([ + "scan", + "--mode", + "vendored", + "--yes", + "--api-url", + &mock.uri(), + "--api-token", + "fake-token", + "--org", + ORG_SLUG, + ".", + "tools/x", + ]) + .current_dir(root); + let out = cmd.output().expect("run"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "stdout={}\nstderr={stderr}", + String::from_utf8_lossy(&out.stdout) + ); + let nested_lock = std::fs::read_to_string(root.join("tools/x/package-lock.json")).unwrap(); + assert!(nested_lock.contains(".socket/vendor"), "{nested_lock}"); +} + +/// A copy the root's own lock installs stays the root's, even when the +/// crawl kept the nested project's copy of the same package. +#[tokio::test] +async fn package_the_root_also_locks_is_still_vendored_at_the_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + write_project(root, "root", "left-pad", "1.3.0"); + write_project(&root.join("tools/x"), "x", "left-pad", "1.3.0"); + let mock = MockServer::start().await; + mount_offers(&mock, &[(NESTED_PURL, NESTED_UUID)]).await; + + let (code, stdout, stderr) = run_scan(root, &mock.uri(), "vendored", &[]); + let v = envelope(&stdout); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!(warning(&v, WARNING).is_none(), "{v:#}"); + let root_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!(root_lock.contains(".socket/vendor"), "{root_lock}"); +} + +/// A workspace member's stray lock does not make it an independent +/// project: npm installs the member from the root's lock. +#[tokio::test] +async fn workspace_member_with_stray_lock_is_not_a_nested_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir_all(root.join(".git")).unwrap(); + write_project(&root.join("packages/a"), "a", "left-pad", "1.3.0"); + std::fs::write( + root.join("package.json"), + r#"{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}"#, + ) + .unwrap(); + let lock = serde_json::json!({ + "name": "root", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }, + "node_modules/a": { "resolved": "packages/a", "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": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-orig==", + } + } + }); + std::fs::write( + root.join("package-lock.json"), + serde_json::to_vec_pretty(&lock).unwrap(), + ) + .unwrap(); + let mock = MockServer::start().await; + mount_offers(&mock, &[(NESTED_PURL, NESTED_UUID)]).await; + + let (_, stdout, _) = run_scan(root, &mock.uri(), "vendored", &[]); + let v = envelope(&stdout); + assert!(warning(&v, WARNING).is_none(), "{v:#}"); +} From 2c3e064ea415dd082e5b05bd44b8f247d8d5745f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:33:26 +0000 Subject: [PATCH 3/4] Leave nested npm projects' copies to their run Hosted and vendored scans rewire only the scan root's lockfiles, but the crawl also reads node_modules of nested, independent npm projects. A vendored root scan handed those copies to the root lock backend, which refused them, so the run failed with exit 1 every time and wrote no in-run VEX, with a remedy (npm install) that could not clear it. Now a patched npm package is left out of a hosted or vendored run when the root's lockfiles do not list it, its copy sits in a nested project with its own lock (not a workspace member whose lock is ignored), and the root's own install has no copy. One nested_project_skipped warning per project names it and the PATH to pass. Exit and status unchanged. Fixes #1534 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 1 + .../src/commands/scan/discovery.rs | 111 ++++++++++++++++++ .../socket-patch-cli/src/commands/scan/mod.rs | 26 ++++ .../src/hosted/governing_root.rs | 95 +++++++++++++++ docs/ecosystems.md | 9 ++ 5 files changed, 242 insertions(+) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index de68e3dd4..695ee163a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1553,6 +1553,7 @@ command's integration tests check its output. | `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. | | `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `-.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. | | `redirect_npm_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (`package-lock.json` / `npm-shrinkwrap.json`): the project patches the granted `name@version` itself with npm ≥ 12.1's native `npm patch` (a root `package.json` `patchedDependencies` key for `name@version` or the bare name, or a `packages` entry carrying npm's `patched` record, which npm writes in a lockfileVersion 4 lock). npm applies the user's diff on top of whatever tarball the lock names and fails the install `EPATCHFAILED` when it no longer applies, so a hosted pin would break every later `npm ci` / `npm install` (or install bytes VEX can never attest); the dep is left on its registry entry in every present npm lock instead (#711). Per-dep; the detail names the key or lock entry and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); when an earlier hosted run already pinned the entry, the detail says so and names `socket-patch rollback ` to restore the registry entry instead of claiming it is unchanged; the in-run VEX never assumes the uuid applied, and no sibling lock confirms it. Vendored mode refuses the lockfileVersion 4 lock `vendor_lockfile_version_unsupported`, its detail naming `npm patch` / `patchedDependencies`. Exit 0. | +| `nested_project_skipped` | `warnings[]` (warning) | scan `--mode hosted` / `--mode vendored` (npm family): the crawl found a patched package only in the `node_modules` of a nested, independent npm project below the scan root (a directory with its own npm-family lock that is not a workspace member whose package manager ignores that lock), the root's lockfiles do not list it, and the root's own install holds no copy. A hosted or vendored run rewires only the root's lockfiles, so the package is left out of the run (no `packages[]` row, no event, no VEX statement) instead of failing `vendor_lock_entry_not_found` or skipping `redirect_unconfirmed`. One warning per nested project; the detail names the purls, the project directory and the PATH to pass (`socket-patch scan --mode `). Exit and status unchanged (#1534). | | `redirect_vlt_lock_unsupported` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`skipped` (hosted event)). Exit 0. | | `redirect_takeover_kept_vendored` | `warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `skipped` (hosted event). Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. | | `redirect_takeover_not_pinned` | `skipped` (hosted event) | scan/get `--mode hosted`: the skip reason of a retracted takeover when no rewriter warning names the cause. | diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 4fda59f40..bedc44586 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -577,6 +577,117 @@ pub(super) fn severity_order(s: &str) -> u8 { socket_patch_core::api::ranking::severity_order(Some(s)) } +/// Warning code: a hosted or vendored scan left patched npm packages that +/// only a nested, independent npm project installs (#1534). +pub(crate) const NESTED_PROJECT_SKIPPED: &str = "nested_project_skipped"; + +/// #1534: the patched npm packages a hosted or vendored scan of `cwd` +/// must leave to a nested project. The crawl reads every `node_modules` +/// below the root, including those of a nested project with its own lock +/// (`tools/x/` holding `package-lock.json`), but such a run rewires only +/// the root's locks. A package is left out when the root's lock inventory +/// (`locked`) does not list it, its crawled copy sits in a nested +/// project ([`nested_npm_project`]), and no `node_modules` of the root's +/// own install holds another copy. Returns the purls to drop and one +/// `(code, detail)` warning per nested project, naming the PATH to pass. +/// +/// [`nested_npm_project`]: socket_patch_core::hosted::governing_root::nested_npm_project +pub(crate) async fn nested_project_skips( + cwd: &std::path::Path, + mode: &str, + options: &socket_patch_core::crawlers::types::CrawlerOptions, + crawled: &[socket_patch_core::crawlers::types::CrawledPackage], + locked: &[LockfileEntry], + with_patches: &[BatchPackagePatches], +) -> (HashSet, Vec<(String, String)>) { + use socket_patch_core::hosted::governing_root::nested_npm_project; + use std::collections::BTreeMap; + use std::path::PathBuf; + + let locked: HashSet = locked + .iter() + .filter(|e| e.ecosystem == "npm") + .map(|e| PurlKey::new(&e.purl)) + .collect(); + let paths: HashMap = crawled + .iter() + .filter(|p| p.purl.starts_with("pkg:npm/")) + .map(|p| (PurlKey::new(&p.purl), p.path.as_path())) + .collect(); + // Candidate purl -> the nested project holding its crawled copy. + let mut candidates: BTreeMap = BTreeMap::new(); + for pkg in with_patches { + let key = PurlKey::new(&pkg.purl); + if !pkg.purl.starts_with("pkg:npm/") || locked.contains(&key) { + continue; + } + let Some(path) = paths.get(&key) else { + continue; + }; + if let Some(project) = nested_npm_project(cwd, path).await { + candidates.insert(pkg.purl.clone(), project); + } + } + if candidates.is_empty() { + return Default::default(); + } + // The crawl keeps one copy per purl: a copy in the root's own install + // keeps the package the root's, whatever copy the crawl kept. + let crawler = socket_patch_core::crawlers::NpmCrawler::new(); + let wanted: Vec = candidates.keys().cloned().collect(); + for nm_root in crawler + .get_node_modules_paths(options) + .await + .unwrap_or_default() + { + if nested_npm_project(cwd, &nm_root).await.is_some() { + continue; + } + if let Ok(found) = crawler.find_by_purls(&nm_root, &wanted).await { + let rooted: HashSet = found + .into_iter() + .filter(|(_, copies)| !copies.is_empty()) + .map(|(purl, _)| PurlKey::new(&purl)) + .collect(); + candidates.retain(|purl, _| !rooted.contains(&PurlKey::new(purl))); + } + } + let canonical = std::fs::canonicalize(cwd).ok(); + let mut by_project: BTreeMap> = BTreeMap::new(); + let mut skipped = HashSet::new(); + for (purl, project) in candidates { + let rel = std::iter::once(cwd) + .chain(canonical.as_deref()) + .find_map(|base| project.strip_prefix(base).ok()) + .unwrap_or(&project) + .to_string_lossy() + .replace('\\', "/"); + skipped.insert(PurlKey::new(&purl)); + by_project.entry(rel).or_default().push(purl); + } + let warnings = by_project + .into_iter() + .map(|(rel, purls)| { + ( + NESTED_PROJECT_SKIPPED.to_string(), + format!( + "{} {} installed only by {rel}, a separate npm project with its own \ + lockfile; this run rewires only this directory's lockfile, so {} left \ + alone; run `socket-patch scan --mode {mode} {rel}` to patch that project", + purls.join(", "), + if purls.len() == 1 { "is" } else { "are" }, + if purls.len() == 1 { + "it is" + } else { + "they are" + }, + ), + ) + }) + .collect(); + (skipped, warnings) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index f49734e66..09b0a359c 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2503,6 +2503,32 @@ async fn run_scan( &all_purls, &mut lockfile_only.purls, ); + // #1534: hosted and vendored runs rewire only this root's locks, so a + // patched copy only a nested npm project installs is left to that + // project's own run, with a warning, not handed to this root's backend. + if (hosted || vendor) + && !args.common.is_global() + && args.common.ecosystem_selected(Ecosystem::Npm) + { + let (skipped, warnings) = Box::pin(discovery::nested_project_skips( + &args.common.cwd, + if hosted { "hosted" } else { "vendored" }, + &crawler_options, + &filtered_crawled, + &lockfile_only.entries, + &all_packages_with_patches, + )) + .await; + if !skipped.is_empty() { + all_packages_with_patches.retain(|p| !skipped.contains(&PurlKey::new(&p.purl))); + if !args.common.json { + for (_, detail) in &warnings { + status.println(format!("Warning: {detail}")); + } + } + layout_refusals.extend(warnings); + } + } // If every batch errored, surface a full scan failure rather than // silently reporting zero patches. diff --git a/crates/socket-patch-core/src/hosted/governing_root.rs b/crates/socket-patch-core/src/hosted/governing_root.rs index 480abc4dc..57c755073 100644 --- a/crates/socket-patch-core/src/hosted/governing_root.rs +++ b/crates/socket-patch-core/src/hosted/governing_root.rs @@ -456,6 +456,41 @@ async fn vlt_lock_root( Some((ancestor, vec![VLT_LOCK])) } +/// The independent npm-family project below `scan_root` whose +/// `node_modules` holds the crawled copy at `copy` (#1534): the deepest +/// directory between `scan_root` (exclusive) and the copy's first +/// `node_modules` that holds an npm-family lock of its own and is not a +/// workspace member whose package manager ignores that lock +/// ([`member_stray_lock`]). `None` when the copy belongs to `scan_root`'s +/// own install (its `node_modules`, or a workspace member without a lock +/// of its own). `copy` is matched against `scan_root` as given and +/// canonicalized. +/// +/// A hosted or vendored run rewires only `scan_root`'s locks, so such a +/// copy is reached by passing the nested project as a PATH instead. +pub async fn nested_npm_project(scan_root: &Path, copy: &Path) -> Option { + let canonical = tokio::fs::canonicalize(scan_root).await.ok(); + let (base, rel) = std::iter::once(scan_root) + .chain(canonical.as_deref()) + .find_map(|dir| copy.strip_prefix(dir).ok().map(|rel| (dir, rel)))?; + let parts: Vec<&std::ffi::OsStr> = rel + .components() + .map_while(|c| match c { + std::path::Component::Normal(part) => Some(part), + _ => None, + }) + .collect(); + let depth = parts.iter().position(|p| *p == "node_modules")?; + for k in (1..=depth).rev() { + let mut dir = base.to_path_buf(); + dir.extend(&parts[..k]); + if has_own_npm_family_lock(&dir) && member_stray_lock(&dir).await.is_none() { + return Some(dir); + } + } + None +} + /// The hosted refusal for [`member_stray_lock`]. async fn member_stray_lock_refusal(root: &Path) -> Option<(PathBuf, Refusal)> { let stray = member_stray_lock(root).await?; @@ -885,6 +920,66 @@ mod tests { .map(|r| r.code) } + /// #1534: a crawled copy belongs to the deepest directory below the + /// scan root that is an independent npm-family lock root; the root's + /// own `node_modules`, a lockless workspace member and a member whose + /// lock npm ignores all stay the root's. + #[tokio::test] + async fn nested_npm_project_finds_the_independent_lock_root() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "package.json", + r#"{"name":"root","workspaces":["packages/*"]}"#, + ); + write(root, "package-lock.json", "{}"); + // An independent project, with a lockless sub-package of its own. + write(root, "tools/x/package.json", r#"{"name":"x"}"#); + write(root, "tools/x/package-lock.json", "{}"); + write(root, "tools/x/sub/package.json", r#"{"name":"sub"}"#); + // A yarn project is independent too. + write(root, "examples/y/yarn.lock", ""); + // Workspace members: one lockless, one with a stray npm lock. + write(root, "packages/a/package.json", r#"{"name":"a"}"#); + write(root, "packages/b/package.json", r#"{"name":"b"}"#); + write(root, "packages/b/package-lock.json", "{}"); + + let owner = |rel: &str| { + let copy = root.join(rel); + async move { nested_npm_project(root, ©).await } + }; + assert_eq!(owner("node_modules/left-pad").await, None); + assert_eq!( + owner("node_modules/a/node_modules/left-pad").await, + None, + "inside a node_modules is a package, not a project" + ); + assert_eq!( + owner("tools/x/node_modules/left-pad").await, + Some(root.join("tools/x")) + ); + assert_eq!( + owner("tools/x/node_modules/.pnpm/left-pad@1.3.0/node_modules/left-pad").await, + Some(root.join("tools/x")) + ); + assert_eq!( + owner("tools/x/sub/node_modules/left-pad").await, + Some(root.join("tools/x")), + "a lockless directory belongs to the nearest lock root" + ); + assert_eq!( + owner("examples/y/node_modules/left-pad").await, + Some(root.join("examples/y")) + ); + assert_eq!(owner("packages/a/node_modules/left-pad").await, None); + assert_eq!(owner("packages/b/node_modules/left-pad").await, None); + assert_eq!( + nested_npm_project(root, Path::new("/elsewhere/node_modules/x")).await, + None + ); + } + /// #590: a pnpm workspace member has no lock; the root's lock governs it. #[tokio::test] async fn pnpm_workspace_member_is_refused() { diff --git a/docs/ecosystems.md b/docs/ecosystems.md index dd73bbbe8..485fe81db 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -425,6 +425,15 @@ other tools that follow the convention: neither that directory's own crawled, even when it is tagged itself. A `CACHEDIR.TAG` that lacks the signature, is a directory or is a symlink prunes nothing. +Agent and report-only scans patch every crawled copy in place. Hosted and +vendored scans rewire only the scan root's lockfiles, so a patched package +that only a nested, independent npm project installs (a directory below +the root with its own lockfile, such as `tools/x/` holding +`package-lock.json`, that is not a workspace member installed from the +root's lock) is left alone with a `nested_project_skipped` warning. The +warning names the project directory; pass it as a PATH +(`socket-patch scan --mode vendored tools/x`) to patch that project. + Inside each `node_modules`, the package stores of isolated layouts are walked too, since they are the only home of transitive dependencies: pnpm's virtual store (`node_modules/.pnpm`, pnpm <= 3's From 695464d24177acb732c79e4d8f291e0f0d2cd7f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:15:02 +0000 Subject: [PATCH 4/4] Name the nested project's PATH from the shell Review follow-ups for the nested_project_skipped warning: - the PATH it suggests is relative to the directory the command runs in (or absolute), not to a PATH-list leg or --cwd, and is quoted when a shell would split it; - an unreadable node_modules keeps every package the root's instead of dropping one the root may install; - npm purls are classified through Ecosystem::from_purl, the remedy spells each mode out so the remedy-command check parses it, and the e2e reads the envelope through common::envelope. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/discovery.rs | 110 +++++++++++++----- .../tests/scan/scan_nested_project_e2e.rs | 64 +++++----- 2 files changed, 115 insertions(+), 59 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index bedc44586..6000d2885 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -609,16 +609,22 @@ pub(crate) async fn nested_project_skips( .filter(|e| e.ecosystem == "npm") .map(|e| PurlKey::new(&e.purl)) .collect(); + let npm = |purl: &str| { + matches!( + socket_patch_core::crawlers::Ecosystem::from_purl(purl), + Some(socket_patch_core::crawlers::Ecosystem::Npm) + ) + }; let paths: HashMap = crawled .iter() - .filter(|p| p.purl.starts_with("pkg:npm/")) + .filter(|p| npm(&p.purl)) .map(|p| (PurlKey::new(&p.purl), p.path.as_path())) .collect(); // Candidate purl -> the nested project holding its crawled copy. let mut candidates: BTreeMap = BTreeMap::new(); for pkg in with_patches { let key = PurlKey::new(&pkg.purl); - if !pkg.purl.starts_with("pkg:npm/") || locked.contains(&key) { + if !npm(&pkg.purl) || locked.contains(&key) { continue; } let Some(path) = paths.get(&key) else { @@ -633,54 +639,71 @@ pub(crate) async fn nested_project_skips( } // The crawl keeps one copy per purl: a copy in the root's own install // keeps the package the root's, whatever copy the crawl kept. + // An unreadable tree keeps every candidate the root's (the backend + // then reports it), rather than dropping a package the root installs. let crawler = socket_patch_core::crawlers::NpmCrawler::new(); let wanted: Vec = candidates.keys().cloned().collect(); - for nm_root in crawler - .get_node_modules_paths(options) - .await - .unwrap_or_default() - { + let Ok(nm_roots) = crawler.get_node_modules_paths(options).await else { + return Default::default(); + }; + for nm_root in nm_roots { if nested_npm_project(cwd, &nm_root).await.is_some() { continue; } - if let Ok(found) = crawler.find_by_purls(&nm_root, &wanted).await { - let rooted: HashSet = found - .into_iter() - .filter(|(_, copies)| !copies.is_empty()) - .map(|(purl, _)| PurlKey::new(&purl)) - .collect(); - candidates.retain(|purl, _| !rooted.contains(&PurlKey::new(purl))); - } - } - let canonical = std::fs::canonicalize(cwd).ok(); + let Ok(found) = crawler.find_by_purls(&nm_root, &wanted).await else { + return Default::default(); + }; + let rooted: HashSet = found + .into_iter() + .filter(|(_, copies)| !copies.is_empty()) + .map(|(purl, _)| PurlKey::new(&purl)) + .collect(); + candidates.retain(|purl, _| !rooted.contains(&PurlKey::new(purl))); + } + // The PATH as the user's shell sees it: relative to the directory the + // command runs in (a PATH-list leg or `--cwd` scans elsewhere), else + // absolute. + let shell_dir = std::env::current_dir() + .ok() + .map(|d| std::fs::canonicalize(&d).unwrap_or(d)); let mut by_project: BTreeMap> = BTreeMap::new(); let mut skipped = HashSet::new(); for (purl, project) in candidates { - let rel = std::iter::once(cwd) - .chain(canonical.as_deref()) - .find_map(|base| project.strip_prefix(base).ok()) + let project = std::fs::canonicalize(&project).unwrap_or(project); + let shown = shell_dir + .as_deref() + .and_then(|base| project.strip_prefix(base).ok()) + .filter(|rel| !rel.as_os_str().is_empty()) .unwrap_or(&project) .to_string_lossy() .replace('\\', "/"); skipped.insert(PurlKey::new(&purl)); - by_project.entry(rel).or_default().push(purl); + by_project.entry(shown).or_default().push(purl); } + // Spelled out per mode so the remedy-command check can parse each. + let rerun = |dir: &str| { + if mode == "hosted" { + format!("`socket-patch scan --mode hosted {dir}`") + } else { + format!("`socket-patch scan --mode vendored {dir}`") + } + }; let warnings = by_project .into_iter() - .map(|(rel, purls)| { + .map(|(dir, purls)| { + let (verb, left) = if purls.len() == 1 { + ("is", "it is") + } else { + ("are", "they are") + }; ( NESTED_PROJECT_SKIPPED.to_string(), format!( - "{} {} installed only by {rel}, a separate npm project with its own \ - lockfile; this run rewires only this directory's lockfile, so {} left \ - alone; run `socket-patch scan --mode {mode} {rel}` to patch that project", + "{} {verb} installed only by {dir}, a separate npm project with its own \ + lockfile; this run rewires only this directory's lockfile, so {left} left \ + alone; run {} to patch that project", purls.join(", "), - if purls.len() == 1 { "is" } else { "are" }, - if purls.len() == 1 { - "it is" - } else { - "they are" - }, + rerun(&shell_word(&dir)), ), ) }) @@ -688,9 +711,34 @@ pub(crate) async fn nested_project_skips( (skipped, warnings) } +/// `word` as one POSIX shell word: single-quoted unless it is plain. +fn shell_word(word: &str) -> String { + let plain = !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_./-:@%+=,".contains(c)); + if plain { + word.to_string() + } else { + format!("'{}'", word.replace('\'', r"'\''")) + } +} + #[cfg(test)] mod tests { use super::*; + + /// #1534: the PATH in the `nested_project_skipped` remedy survives a + /// copy-paste into a POSIX shell. + #[test] + fn shell_word_quotes_only_what_the_shell_would_split() { + assert_eq!(shell_word("tools/x"), "tools/x"); + assert_eq!(shell_word("/repo/@scope/x-1.0"), "/repo/@scope/x-1.0"); + assert_eq!(shell_word("tools/my x"), "'tools/my x'"); + assert_eq!(shell_word("it's"), r"'it'\''s'"); + assert_eq!(shell_word("$HOME"), "'$HOME'"); + assert_eq!(shell_word(""), "''"); + } use socket_patch_core::api::types::BatchPatchInfo; use std::borrow::Cow; diff --git a/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs b/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs index 7c4e6366b..f942fe749 100644 --- a/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs +++ b/crates/socket-patch-cli/tests/scan/scan_nested_project_e2e.rs @@ -6,6 +6,7 @@ //! project and the PATH to pass) instead of failing every run with //! `vendor_lock_entry_not_found`, and the in-run VEX is still written. +use crate::common::envelope::{all_codes, parse_json_envelope}; use crate::common::{binary, git_sha256, hermetic_command}; #[path = "../prebuilt_common/mod.rs"] @@ -223,25 +224,6 @@ fn run_scan(root: &Path, mock_uri: &str, mode: &str, extra: &[&str]) -> (i32, St ) } -fn envelope(stdout: &str) -> serde_json::Value { - serde_json::from_str(stdout.trim()) - .unwrap_or_else(|e| panic!("stdout must be one JSON envelope ({e}): {stdout}")) -} - -/// Every event in the envelope (top level and the nested vendor block). -fn events(v: &serde_json::Value) -> Vec { - let mut out = Vec::new(); - for key in ["patches", "events"] { - if let Some(list) = v[key].as_array() { - out.extend(list.iter().cloned()); - } - if let Some(list) = v["vendor"][key].as_array() { - out.extend(list.iter().cloned()); - } - } - out -} - fn warning<'a>(v: &'a serde_json::Value, code: &str) -> Option<&'a serde_json::Value> { v["warnings"].as_array()?.iter().find(|w| w["code"] == code) } @@ -255,11 +237,12 @@ fn assert_nested_skipped(v: &serde_json::Value) { detail.contains("scan --mode vendored tools/x"), "names the PATH to pass: {detail}" ); - let failed: Vec<_> = events(v) - .into_iter() - .filter(|e| e["action"] == "failed") - .collect(); - assert!(failed.is_empty(), "no failed event: {failed:#?}\n{v:#}"); + assert!( + !all_codes(v) + .iter() + .any(|c| c == "vendor_lock_entry_not_found"), + "no failed event: {v:#}" + ); } /// The bug: run 1 (and every later run) ended `partialFailure`, exit 1, @@ -275,7 +258,7 @@ async fn root_vendored_scan_warns_about_a_nested_projects_copies() { for run in 1..=2 { let (code, stdout, stderr) = run_scan(root, &mock.uri(), "vendored", &[]); - let v = envelope(&stdout); + let v = parse_json_envelope(&stdout); assert_eq!( code, 0, "run {run}: exit 0\nstdout={stdout}\nstderr={stderr}" @@ -310,7 +293,7 @@ async fn root_vendored_scan_with_vex_writes_the_document() { &["--vex", out_arg, "--vex-product", "pkg:npm/root@1.0.0"], ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert_nested_skipped(&envelope(&stdout)); + assert_nested_skipped(&parse_json_envelope(&stdout)); let doc = std::fs::read_to_string(&out).expect("the in-run VEX is written"); assert!( !doc.contains("left-pad"), @@ -369,7 +352,7 @@ async fn package_the_root_also_locks_is_still_vendored_at_the_root() { mount_offers(&mock, &[(NESTED_PURL, NESTED_UUID)]).await; let (code, stdout, stderr) = run_scan(root, &mock.uri(), "vendored", &[]); - let v = envelope(&stdout); + let v = parse_json_envelope(&stdout); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!(warning(&v, WARNING).is_none(), "{v:#}"); let root_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); @@ -415,6 +398,31 @@ async fn workspace_member_with_stray_lock_is_not_a_nested_project() { mount_offers(&mock, &[(NESTED_PURL, NESTED_UUID)]).await; let (_, stdout, _) = run_scan(root, &mock.uri(), "vendored", &[]); - let v = envelope(&stdout); + let v = parse_json_envelope(&stdout); assert!(warning(&v, WARNING).is_none(), "{v:#}"); } + +/// The PATH the warning names is relative to where the command runs, not +/// to `--cwd`: re-running it from the same shell reaches the project. +#[tokio::test] +async fn warning_names_the_path_from_the_shells_directory() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("repo"); + write_repo(&root); + let mock = MockServer::start().await; + mount_api(&mock).await; + + let root_arg = root.to_str().unwrap(); + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), "vendored", &["--cwd", root_arg]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_json_envelope(&stdout); + let detail = warning(&v, WARNING).unwrap()["detail"] + .as_str() + .unwrap() + .to_string(); + assert!( + detail.contains("scan --mode vendored repo/tools/x`"), + "{detail}" + ); +}