diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..89a82860f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -120,7 +120,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the npm registry, following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 5507bb977..a42400b7e 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -69,9 +69,11 @@ type VendorStepResult = Result<(bool, Envelope), VendorStepError>; /// Action values are part of the CLI contract: `would_vendor` (no ledger /// entry), `already_vendored` (entry at this uuid), `would_revendor` + /// `oldUuid` (entry at an older uuid), and — additive — `would_refuse` + -/// `errorCode` + `error` for npm purls the wet run's Bun or vlt preflight +/// `errorCode` + `error` for npm purls the wet run's Bun, vlt or npm +/// package-lock preflight /// ([`crate::commands::bun_preflight::BunVendorRefusal`], -/// [`crate::commands::vlt_preflight`]) would refuse before any download. +/// [`crate::commands::vlt_preflight`], [`npm_lock_refusal`]) would refuse +/// before any download. /// The preview stays a ledger classification otherwise (engine refusals /// outside the preflights are not predicted), and `would_refuse` never /// flips the run's status or exit code. The preflights (the only disk @@ -88,6 +90,7 @@ pub(crate) async fn preview_vendor_json( bun_vendor_preflight_with_ledger(cwd, selected, state.as_ref().map(|s| &s.entries)).await; let vlt_refusals = vlt_vendor_preflight_selected(cwd, selected, state.as_ref().map(|s| &s.entries)).await; + let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; let state = state.unwrap_or_default(); let mut patches: Vec = selected .iter() @@ -108,6 +111,13 @@ pub(crate) async fn preview_vendor_json( "errorCode": r.code, "error": r.detail, }) } + _ if p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some() => { + let (code, detail) = npm_lock_refusal.as_ref().expect("checked by the guard"); + serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_refuse", + "errorCode": code, "error": detail, + }) + } Some(e) if e.uuid == p.uuid => serde_json::json!({ "purl": p.purl, "uuid": p.uuid, "action": "already_vendored", }), @@ -124,7 +134,7 @@ pub(crate) async fn preview_vendor_json( serde_json::json!({ "dryRun": true, "patches": patches }) } -/// The purls of `selected` the wet run's Bun or vlt preflight would refuse +/// The purls of `selected` the wet run's Bun, vlt or npm package-lock preflight would refuse /// before any download (the `would_refuse` rows of /// [`preview_vendor_json`]): the vendored planning pass, so a refused NEW /// patch holds no rollout slot. @@ -137,16 +147,32 @@ pub(super) async fn preflight_refused_purls( bun_vendor_preflight_with_ledger(cwd, selected, state.as_ref().map(|s| &s.entries)).await; let vlt_refusals = vlt_vendor_preflight_selected(cwd, selected, state.as_ref().map(|s| &s.entries)).await; + let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; selected .iter() .filter(|p| { refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) || vlt_refusal_for(&vlt_refusals, &p.purl).is_some() + || (p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some()) }) .map(|p| p.purl.clone()) .collect() } +/// The npm package-lock backend's project-level refusal (a lock that is +/// not v2/v3, see [`socket_patch_core::vendor::npm_lock_vendor_preflight`]), +/// which refuses every npm purl of the project before any download or +/// takeover. Read only when the selection holds an npm purl. +async fn npm_lock_refusal( + cwd: &Path, + selected: &[PatchSearchResult], +) -> Option<(&'static str, String)> { + if !selected.iter().any(|p| p.purl.starts_with("pkg:npm/")) { + return None; + } + socket_patch_core::vendor::npm_lock_vendor_preflight(cwd).await +} + /// Human rendering of the vendored dry-run preview's `would_refuse` records /// (see [`preview_vendor_json`]): the count line above it still says /// "would download and vendor", so name what the wet run would refuse and diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 59be95b85..599a8d57a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -2207,14 +2207,16 @@ pub(crate) async fn vendor_records_reusing( .find(|pin| canonical_purl(&pin.purl) == canonical_purl(purl)) }; - // Yarn berry takeover preflight (see - // `socket_patch_core::vendor::yarn_berry_vendor_preflight`): the berry - // backend's project-level refusals (mixed line endings in yarn.lock or - // package.json, cacheKey, `.yarnrc.yml` compressionLevel), computed at + // Yarn berry / npm package-lock takeover preflight (see + // `socket_patch_core::vendor::yarn_berry_vendor_preflight` and + // `npm_lock_vendor_preflight`): the backend's project-level refusals + // (berry: mixed line endings in yarn.lock or package.json, cacheKey, + // `.yarnrc.yml` compressionLevel; package-lock: a lock that is not + // v2/v3, #659), computed at // most once per run and only when a hosted-claimed npm purl reaches the // takeover below, which must refuse such a purl BEFORE reverting its // hosted edits. - let berry_takeover_refusal: tokio::sync::OnceCell> = + let npm_takeover_refusal: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); let pipenv_version = tokio::sync::OnceCell::new(); // The vlt store entries each hosted→vendored takeover unpinned, healed @@ -2410,9 +2412,21 @@ pub(crate) async fn vendor_records_reusing( } } if candidate.starts_with("pkg:npm/") { - let project = berry_takeover_refusal - .get_or_init(|| { - socket_patch_core::vendor::yarn_berry_vendor_preflight(&common.cwd) + let project = npm_takeover_refusal + .get_or_init(|| async { + match socket_patch_core::vendor::yarn_berry_vendor_preflight( + &common.cwd, + ) + .await + { + Some(refusal) => Some(refusal), + None => { + socket_patch_core::vendor::npm_lock_vendor_preflight( + &common.cwd, + ) + .await + } + } }) .await .clone(); diff --git a/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs new file mode 100644 index 000000000..d2a20b9dd --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs @@ -0,0 +1,398 @@ +//! Hermetic hosted → vendored takeover tests through the built binary for +//! an npm project whose lock the vendored backend refuses: a +//! lockfileVersion-1 `package-lock.json` / `npm-shrinkwrap.json` (npm 6). +//! +//! Hosted mode accepts a v1 lock, the npm vendored backend does not +//! (`vendor_lockfile_version_unsupported`). `scan`/`get --mode vendored` +//! over such a hosted pin used to restore the upstream registry entry +//! FIRST and only then reach the backend's version gate, so the run +//! failed with the hosted pin already gone and the project went back to +//! unpatched (#659). The gate must run before the restore, so the +//! refused purl stays hosted; a v2 lock still takes over. +//! +//! The API and the npm registry are wiremock; no npm binary is needed. +//! Every child process gets the ambient `SOCKET_*` vars scrubbed and +//! telemetry hard-disabled; each test runs in its own tempdir. + +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const NAME: &str = "left-pad"; +const VERSION: &str = "1.3.0"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const HOSTED_URL: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const UPSTREAM_TARBALL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const UPSTREAM_SHA512: &str = "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; +const V1_CODE: &str = "vendor_lockfile_version_unsupported"; + +// ───────────────────────────── fixture ───────────────────────────── + +/// The pristine lock npm `lock_version` writes for a root project +/// depending on `left-pad@1.3.0`. +fn pristine_lock(lock_version: u64) -> Value { + let dep = json!({ + "version": VERSION, + "resolved": UPSTREAM_TARBALL, + "integrity": UPSTREAM_SHA512, + }); + if lock_version == 1 { + json!({ + "name": "v6", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { NAME: dep }, + }) + } else { + json!({ + "name": "v6", + "version": "1.0.0", + "lockfileVersion": lock_version, + "requires": true, + "packages": { + "": { "name": "v6", "version": "1.0.0", "dependencies": { NAME: VERSION } }, + "node_modules/left-pad": dep, + }, + }) + } +} + +/// package.json, the installed (unpatched) copy and the pristine lock +/// named `lock_name`. +fn write_npm_project(root: &Path, lock_name: &str, lock_version: u64) { + std::fs::write( + root.join("package.json"), + format!( + r#"{{"name":"v6","version":"1.0.0","private":true,"dependencies":{{"{NAME}":"{VERSION}"}}}}"# + ), + ) + .unwrap(); + let pkg = root.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"), ORIG_INDEX).unwrap(); + let mut lock = serde_json::to_string_pretty(&pristine_lock(lock_version)).unwrap(); + lock.push('\n'); + std::fs::write(root.join(lock_name), lock).unwrap(); +} + +fn patch_record() -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "npm v1 takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +fn patch_view() -> Value { + let mut view = patch_record(); + view["purl"] = json!(PURL); + view["publishedAt"] = json!("2024-01-01T00:00:00Z"); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + view +} + +/// The hosted-mode API (discovery + by-package + grant + view) for the one +/// patch over `PURL`, plus the npm registry's version document the +/// upstream restore re-resolves the pristine entry from. +async fn mock_api(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "npm v1 takeover fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(patch_view())) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": UPSTREAM_TARBALL, "integrity": UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built binary with every ambient `SOCKET_*` var scrubbed and the +/// npm registry pointed at the mock. Returns `(exit_code, envelope)`. +fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_NPM_REGISTRY", registry); + let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); + let out = cmd.output().expect("spawn socket-patch binary"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + println!("[{}] stderr:\n{stderr}", args.first().unwrap_or(&"?")); + } + let env: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { + panic!("{args:?} must emit a JSON envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (out.status.code().unwrap_or(-1), env) +} + +/// `scan --mode ` (or, with `get`, `get --mode `) +/// against the mock API. +fn run_mode(cwd: &Path, api: &str, command: &str, mode: &str, extra: &[&str]) -> (i32, Value) { + let mut args = vec![command]; + if command == "get" { + args.push(PURL); + } + args.extend([ + "--mode", + mode, + "--json", + "--yes", + "--api-url", + api, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + cwd.to_str().unwrap(), + ]); + let fixture = (mode == "vendored").then(|| prebuilt_common::Server::view(patch_view())); + if let Some(fixture) = &fixture { + args.extend(["--vendor-url", &fixture.uri]); + } + args.extend_from_slice(extra); + run_json(cwd, api, &args) +} + +/// The vendor events: top-level for `vendor`, under `vendor` for the +/// `scan`/`get` envelopes that embed the vendor step. +fn events(envelope: &Value) -> Vec { + envelope["events"] + .as_array() + .or_else(|| envelope["vendor"]["events"].as_array()) + .cloned() + .unwrap_or_default() +} + +fn has_event_code(envelope: &Value, code: &str) -> bool { + events(envelope).iter().any(|e| e["errorCode"] == code) + || envelope.to_string().contains(&format!("\"{code}\"")) +} + +/// The hosted project: pristine lock, then a real `scan --mode hosted`. +/// Returns `(lock text, .npmrc text)` as hosted mode left them. +fn host_project(root: &Path, api: &str, lock_name: &str, lock_version: u64) -> (String, String) { + write_npm_project(root, lock_name, lock_version); + let (code, env) = run_mode(root, api, "scan", "hosted", &[]); + assert_eq!(code, 0, "hosted scan must succeed: {env:#}"); + let lock = std::fs::read_to_string(root.join(lock_name)).unwrap(); + assert!( + lock.contains(HOSTED_URL), + "hosted mode must pin the {lock_name} v{lock_version} entry:\n{lock}\n{env:#}" + ); + let npmrc = std::fs::read_to_string(root.join(".npmrc")).unwrap_or_default(); + (lock, npmrc) +} + +/// A refused takeover leaves every byte of the hosted wiring in place and +/// writes no vendored state. +fn assert_still_hosted(root: &Path, lock_name: &str, lock: &str, npmrc: &str) { + assert_eq!( + std::fs::read_to_string(root.join(lock_name)).unwrap(), + lock, + "{lock_name} must keep the hosted pin byte-for-byte" + ); + assert_eq!( + std::fs::read_to_string(root.join(".npmrc")).unwrap_or_default(), + npmrc, + ".npmrc must stay as hosted mode wrote it" + ); + assert!( + !root.join(".socket/vendor/npm").exists(), + "a refused run must not stage or pack an artifact" + ); +} + +/// The wet vendored run over a hosted v1 pin: refused with the backend's +/// own code BEFORE the takeover restores anything. +fn assert_refused_before_unhosting(env: &Value, code: i32) { + assert_eq!(code, 1, "the refusal fails the run: {env:#}"); + let failed = events(env) + .into_iter() + .find(|e| e["action"] == "failed" && e["errorCode"] == V1_CODE) + .unwrap_or_else(|| panic!("expected a failed `{V1_CODE}` event: {env:#}")); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert!( + failed["error"] + .as_str() + .is_some_and(|d| d.contains("lockfileVersion Some(1)")), + "the detail is the backend's own words: {env:#}" + ); + assert!( + !has_event_code(env, "vendor_takeover_reverted_redirect"), + "the hosted pin must not be restored before the refusal: {env:#}" + ); + assert!(!has_event_code(env, "redirect_revert_failed"), "{env:#}"); +} + +// ───────────────────────────── scenarios ───────────────────────────── + +/// #659: `scan --mode vendored` over a hosted v1 `package-lock.json`. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_v1_package_lock_keeps_the_hosted_pin() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (lock, npmrc) = host_project(root, &server.uri(), "package-lock.json", 1); + + // The dry run previews the refusal instead of "would vendor". + let (code, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); + assert!( + has_event_code(&env, V1_CODE), + "the dry run must preview the refusal (exit {code}): {env:#}" + ); + assert_still_hosted(root, "package-lock.json", &lock, &npmrc); + + let (code, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_refused_before_unhosting(&env, code); + assert_still_hosted(root, "package-lock.json", &lock, &npmrc); +} + +/// #659: `get --mode vendored`, same project shape. +#[tokio::test(flavor = "multi_thread")] +async fn get_vendored_over_hosted_v1_package_lock_keeps_the_hosted_pin() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (lock, npmrc) = host_project(root, &server.uri(), "package-lock.json", 1); + + let (code, env) = run_mode(root, &server.uri(), "get", "vendored", &[]); + assert_refused_before_unhosting(&env, code); + assert_still_hosted(root, "package-lock.json", &lock, &npmrc); +} + +/// #659: the `npm-shrinkwrap.json` v1 variant. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_v1_shrinkwrap_keeps_the_hosted_pin() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let (lock, npmrc) = host_project(root, &server.uri(), "npm-shrinkwrap.json", 1); + + let (code, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_refused_before_unhosting(&env, code); + assert_still_hosted(root, "npm-shrinkwrap.json", &lock, &npmrc); +} + +/// Control: a v2 lock is supported by both modes, so the takeover still +/// restores the registry entry and vendors it. +#[tokio::test(flavor = "multi_thread")] +async fn scan_vendored_over_hosted_v2_package_lock_still_takes_over() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + host_project(root, &server.uri(), "package-lock.json", 2); + + let (code, env) = run_mode(root, &server.uri(), "scan", "vendored", &["--dry-run"]); + assert_eq!(code, 0, "{env:#}"); + assert!(!has_event_code(&env, V1_CODE), "{env:#}"); + + let (code, env) = run_mode(root, &server.uri(), "scan", "vendored", &[]); + assert_eq!(code, 0, "the v2 takeover must succeed: {env:#}"); + assert!( + has_event_code(&env, "vendor_takeover_reverted_redirect"), + "{env:#}" + ); + assert!(!has_event_code(&env, V1_CODE), "{env:#}"); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!(!lock.contains(HOSTED_URL), "{lock}"); + assert!( + lock.contains(&format!(".socket/vendor/npm/{UUID}/")), + "the lock must point at the vendored artifact:\n{lock}" + ); +} diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index a2592e400..749560d2b 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -129,6 +129,7 @@ pub use verify::{ }; // The hosted→vendored takeover refuses a berry project the backend would // refuse BEFORE it reverts the hosted redirect. +pub use npm_lock::npm_lock_vendor_preflight; pub use yarn_berry_lock::{yarn_berry_vendor_preflight, yarn_berry_vendor_target_preflight}; use std::collections::{HashMap, HashSet}; diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index b1911ba90..6845e96fa 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -417,6 +417,41 @@ pub async fn vendor_npm<'a>( done(result, Some(entry), warnings) } +/// The project-level refusal [`vendor_npm`]'s step 2 raises whatever the +/// purl: the primary lock (`npm-shrinkwrap.json`, else `package-lock.json`) +/// is not parseable JSON or not a v2/v3 lock. `None` unless the project's +/// npm flavor is package-lock (the probe `vendor_npm_any` routes on) and +/// that lock fails the gate; a missing or unreadable lock is left to the +/// backend's own refusal. +/// +/// For the hosted→vendored mode takeover (`vendor`, `scan`/`get --mode +/// vendored` over a hosted pin): the takeover restores the pin's upstream +/// registry entry BEFORE this backend runs, and the restore keeps a v1 lock +/// v1 — so without this preflight the refusal would land after the hosted +/// pin was gone, leaving the package unpatched in both modes (#659). +/// Returns `(code, detail)`, exactly the refusal the backend would raise. +pub async fn npm_lock_vendor_preflight(project_root: &Path) -> Option<(&'static str, String)> { + use super::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor}; + if !matches!( + detect_npm_lock_flavor(project_root).await, + Ok((NpmLockFlavor::PackageLock, _)) + ) { + return None; + } + let (lock_name, lock_bytes, _) = select_lockfile(project_root).await.ok()??; + let gate = match LOCK_MEMO.parse(&lock_bytes, || parse_json_manifest(&lock_bytes)) { + Ok(lock) => lock_version_gate(&lock, &lock_name).err(), + Err(e) => Some(Box::new(refused( + "vendor_lockfile_version_unsupported", + format!("{lock_name} is not parseable JSON: {e}"), + ))), + }; + match *gate? { + VendorOutcome::Refused { code, detail } => Some((code, detail)), + _ => None, + } +} + /// The lock version gate of [`vendor_npm`]'s step 2: only v2/v3 locks with /// a `packages` object are rewritten. `Ok` is the parsed `lockfileVersion`. fn lock_version_gate(lock: &Value, lock_name: &str) -> Result, Box> { @@ -2332,6 +2367,44 @@ mod tests { ); } + /// The takeover preflight raises exactly the backend's own version + /// refusal on a v1 lock (#659), before any write. + #[tokio::test] + async fn preflight_matches_the_backend_v1_refusal() { + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 1, + "dependencies": { + "left-pad": { "version": "1.3.0", "resolved": REG_RESOLVED, "integrity": "sha512-orig==" } + } + }); + let fx = fixture_with("left-pad", "1.3.0", lock).await; + let (code, detail) = npm_lock_vendor_preflight(fx.root()) + .await + .expect("a v1 lock is refused"); + assert_eq!(code, "vendor_lockfile_version_unsupported"); + assert_eq!( + detail, + expect_refused( + fx.vendor(false).await, + "vendor_lockfile_version_unsupported" + ) + ); + } + + /// A supported lock, and a project of another npm flavor, pass the + /// preflight. + #[tokio::test] + async fn preflight_passes_v3_and_other_flavors() { + let fx = fixture().await; + assert_eq!(npm_lock_vendor_preflight(fx.root()).await, None); + let yarn = tempfile::tempdir().unwrap(); + std::fs::write(yarn.path().join("package.json"), b"{}").unwrap(); + std::fs::write(yarn.path().join("yarn.lock"), b"# yarn lockfile v1\n").unwrap(); + assert_eq!(npm_lock_vendor_preflight(yarn.path()).await, None); + } + /// A merge-conflicted / truncated package-lock.json must refuse before /// any project write. Reusing the `vendor_lockfile_version_unsupported` /// code for a parse failure is the cross-backend convention (see diff --git a/docs/testing/npm-compatibility.md b/docs/testing/npm-compatibility.md index e8c1a05fa..acd5aad9a 100644 --- a/docs/testing/npm-compatibility.md +++ b/docs/testing/npm-compatibility.md @@ -14,7 +14,7 @@ Measured against the real releases (Node 24.21 on macOS, 2026-09-22): | npm | `npm install` writes | `npm shrinkwrap` | Hosted install of a redirected lock | Vendored install | | --- | --- | --- | --- | --- | -| 6.14.18 | lockfileVersion 1 | renames the lock | **fails closed**: EINTEGRITY — npm 6 fetches registry dependencies from the configured registry and ignores `resolved`, so the patched sha512 pin rejects the registry bytes (`redirect_npm_legacy_client` warns) | a v1 lock is refused (`vendor_lockfile_version_unsupported`); npm 6 DOES install a vendored **v2** lock (written by npm 7+) from its legacy `dependencies` mirror | +| 6.14.18 | lockfileVersion 1 | renames the lock | **fails closed**: EINTEGRITY — npm 6 fetches registry dependencies from the configured registry and ignores `resolved`, so the patched sha512 pin rejects the registry bytes (`redirect_npm_legacy_client` warns) | a v1 lock is refused (`vendor_lockfile_version_unsupported`), and a hosted → vendored takeover refuses it before restoring the hosted pin, so the package stays hosted; npm 6 DOES install a vendored **v2** lock (written by npm 7+) from its legacy `dependencies` mirror | | 7.0.0, 7.24.2, 8.19.4 | lockfileVersion 2 (+ v1 mirror) | renames the lock | patched | patched | | 9.0.0, 9.9.4, 10.9.9, 11.20.0 | lockfileVersion 3 | renames the lock | patched | patched | | 12.0.0, 12.1.0 | lockfileVersion 3 | **removed** — a committed shrinkwrap gets a package-lock.json twin on first install, and installs read the twin | patched with a plain `npm ci` — the hosted run writes `allow-remote=all` to the project `.npmrc` (`redirect_npm_allow_remote` warns on every npm hosted run); the same checkout WITHOUT that `.npmrc` is refused EALLOWREMOTE | patched (both locks are rewired in the dual-lock state) |