From b9cc9b4320e6e3d2392510d9ed1689dcd193bf40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:26:42 +0000 Subject: [PATCH 1/4] Start fix for #1976 Assisted-by: Claude Code:claude-opus-5-5 From a6d4dc3dd2b6f9c9b36147b681d936939550e403 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:43:22 +0000 Subject: [PATCH 2/4] Refuse npm package-lock entries before download A vendored run refuses a package its backend cannot wire from the lock text alone before it fetches the patch view. npm package-lock projects were left out, so a package installed from a URL or git spec, or a workspace member, was downloaded first and only then refused. lock_text_refusals now answers for package-lock too, in the backend's own words, so the download phase skips the fetch (#1976). Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vendor/mod.rs | 5 +- .../src/vendor/npm_flavor.rs | 116 +++++++++++++++--- 2 files changed, 105 insertions(+), 16 deletions(-) diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 481effdb7..981d4ad67 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -932,8 +932,9 @@ pub async fn service_preflight( /// The lock-text refusals among `candidates` (purls with the uuid of the /// patch to vendor): each purl its backend refuses on the project's lock -/// and manifest text alone, with the backend's `(code, detail)` — the pnpm, -/// yarn classic and yarn berry gates ([`npm_flavor::lock_text_refusals`]) +/// and manifest text alone, with the backend's `(code, detail)` — the npm +/// package-lock, pnpm, yarn classic and yarn berry gates +/// ([`npm_flavor::lock_text_refusals`]) /// and cargo's locked-version gate ([`cargo::lock_text_refusal`]). None of /// them reads the patch, so the answer needs no view: a vendored run asks /// before it fetches views and pristine sources, and a package that will diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 153255f5a..50e7f7bfb 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -645,11 +645,13 @@ pub async fn preflight_packages( } } -/// The lock-text refusals [`vendor_npm_any`] raises for `packages` in a -/// pnpm, yarn classic or yarn berry project — each package's `(code, -/// detail)` when its backend refuses it on the project's lock and manifest -/// text alone, `None` otherwise (and for every package of any other -/// flavor, or a project the flavor probe refuses). +/// The lock-text refusals [`vendor_npm_any`] raises for `packages` in an +/// npm package-lock, pnpm, yarn classic or yarn berry project — each +/// package's `(code, detail)` when its backend refuses it on the project's +/// lock and manifest text alone, `None` otherwise (and for every package of +/// any other flavor, or a project the flavor probe refuses). Package-lock +/// joined in #1976: its per-entry refusals (a URL / git spec copy, a +/// workspace member) were otherwise found only after the view fetch. /// /// Those backends evaluate every such gate — the coordinates, the lock and /// manifest reads, override conflicts, the entry present and rewritable — @@ -669,7 +671,10 @@ pub async fn lock_text_refusals( }; if !matches!( flavor, - NpmLockFlavor::Pnpm | NpmLockFlavor::YarnClassic | NpmLockFlavor::YarnBerry + NpmLockFlavor::PackageLock + | NpmLockFlavor::Pnpm + | NpmLockFlavor::YarnClassic + | NpmLockFlavor::YarnBerry ) { return vec![None; packages.len()]; } @@ -1004,10 +1009,98 @@ mod lock_text_refusal_tests { } } - /// Only the pnpm / yarn classic / yarn berry gates answer: a - /// package-lock project refuses nothing here, whatever its backend does. + /// A package-lock project whose lock pins `left-pad@1.3.0` with + /// `entry` (under `key`) beside a plain registry `is-number@7.0.0`. + fn package_lock_project(root: &Path, spec: &str, key: &str, entry: serde_json::Value) { + let manifest = serde_json::json!({ + "name": "t", "version": "0.0.0", + "dependencies": { "left-pad": spec, "is-number": "7.0.0" }, + }); + std::fs::write(root.join("package.json"), manifest.to_string()).unwrap(); + let lock = serde_json::json!({ + "name": "t", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": { + "": { "name": "t", "version": "0.0.0", + "dependencies": { "left-pad": spec, "is-number": "7.0.0" } }, + key: entry, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-AAAA==" + } + } + }); + std::fs::write(root.join("package-lock.json"), lock.to_string()).unwrap(); + } + + /// #1976: the package-lock backend's per-entry refusals (an entry + /// installed from a URL or git spec, a workspace member) are answered + /// here too, in the backend's own words, while the registry copy beside + /// it is wireable. The vendored preview, rollout planning and download + /// phase all ask this, so such a package no longer previews as + /// vendorable, takes a `--max-new-patches` slot or has its view fetched. #[tokio::test] - async fn other_flavors_refuse_nothing_early() { + async fn package_lock_entry_refusals_are_the_backends_own_words() { + let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + let git = "git+ssh://git@github.com/stevemao/left-pad.git#ff8e7ba"; + let shapes = [ + ( + url, + "node_modules/left-pad", + serde_json::json!({ "version": "1.3.0", "resolved": url, "integrity": "sha512-orig==" }), + "vendor_lock_entry_not_rewritable", + ), + ( + "github:stevemao/left-pad#v1.3.0", + "node_modules/left-pad", + serde_json::json!({ "version": "1.3.0", "resolved": git }), + "vendor_lock_entry_not_rewritable", + ), + ( + "1.3.0", + "packages/left-pad", + serde_json::json!({ "name": "left-pad", "version": "1.3.0" }), + "vendor_workspace_member", + ), + ]; + for (spec, key, entry, code) in shapes { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + package_lock_project(root, spec, key, entry); + let rec = record(); + let purls = ["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@7.0.0"]; + let packages: Vec<(&str, &PatchRecord)> = purls.iter().map(|p| (*p, &rec)).collect(); + let refusals = lock_text_refusals(root, &packages).await; + assert_eq!(refusals[1], None, "{spec}: is-number is wireable"); + let nowhere = root.join("not-installed"); + let sources = PatchSources { + blobs_path: &nowhere, + mem_blobs: None, + }; + let backend = crate::vendor::test_support::vendor_npm_any( + purls[0], + nowhere.as_path(), + root, + &rec, + &sources, + "t", + false, + false, + None, + ) + .await; + let VendorOutcome::Refused { code: got, detail } = backend else { + panic!("{spec}: the backend must refuse left-pad"); + }; + assert_eq!(got, code, "{spec}"); + assert_eq!(refusals[0], Some((got, detail)), "{spec}"); + } + } + + /// A project with no npm lock refuses nothing here: the flavor probe + /// decides first, and the run-level refusals are not lock-text gates. + #[tokio::test] + async fn a_lockless_project_refuses_nothing_early() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write( @@ -1015,11 +1108,6 @@ mod lock_text_refusal_tests { r#"{"name":"t","version":"0.0.0"}"#, ) .unwrap(); - std::fs::write( - root.join("package-lock.json"), - r#"{"name":"t","version":"0.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"t","version":"0.0.0"}}}"#, - ) - .unwrap(); let rec = record(); assert_eq!( lock_text_refusals(root, &[("pkg:npm/pkg-z@1.0.0", &rec)]).await, From 6bf0703a7e0fff204c228004b3156df49b166a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:45:57 +0000 Subject: [PATCH 3/4] Preview npm lock-entry refusals in vendored runs A vendored dry run and the --max-new-patches rollout plan never asked which packages the backend refuses on the lock text alone. An npm package installed from a URL or git spec, or a workspace member, previewed as vendorable and took the only rollout slot on every run, so a fixable patch ranked below it was deferred forever. Both now ask the same lock-text refusals the download phase raises, with the same scope, so such a package previews would_refuse with the backend's code and holds no rollout slot (#1976). Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/scan/vendor_flow.rs | 237 +++++++++++++++++- 2 files changed, 234 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 8e533b2c2..114c44871 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -347,7 +347,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, staged cross-mode takeover, lockfile rewrite (no ledger, v5.0), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `error: {code: "lock_held" | "lock_io", message}` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs, no ledger** — the lockfile edits are the persistence. JSON: the same hosted events (`details.mode: "hosted"`) and `redirect` payload as scan's, after the narrowing `skipped` events (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. * **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; no blob staging; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: the same events as `scan --mode vendored` (download events, then the vendor engine's, all `details.mode: "vendored"`; the human `[fetch]` line of a re-vendor reads ` (replacing )`); a vendor-step error keeps the events recorded before it and sets `status: "error"` + `error: {code, message}` (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor errors → `partialFailure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes a `failed` event (`errorCode: `, `error: `) with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the refusals are `failed` events (no `downloaded` event), the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partialFailure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with `status: "error"`, `error: {code, message}` and the patch's one `failed` event (same code and detail) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as a `skipped` event with the refusal's code (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/vendor/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. -**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result is a `failed` event (`errorCode: `, `error: `) with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor-engine event for it (a lockfile-only package among them gets no `vendor_fetched_missing` event: it is never fetched). Exit 1 and `status: "partialFailure"`, and when every selected package is refused this way the human `scan --mode vendored` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --mode vendored` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the lockfiles pin hosted keeps the loop's refusal (its takeover restore rewrites the lock the gates read); other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor/vendor_rerun_no_network_e2e.rs`. +**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in an **npm package-lock, pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the package-lock / pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable — for package-lock, an entry whose every copy is installed from a URL / git / `file:` spec or bundled, shrinkwrapped or linked is `vendor_lock_entry_not_rewritable`, and a workspace member is `vendor_workspace_member` (#1976)) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result is a `failed` event (`errorCode: `, `error: `) with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor-engine event for it (a lockfile-only package among them gets no `vendor_fetched_missing` event: it is never fetched). Exit 1 and `status: "partialFailure"`, and when every selected package is refused this way the human `scan --mode vendored` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --mode vendored` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the lockfiles pin hosted keeps the loop's refusal (its takeover restore rewrites the lock the gates read); other flavors (pnpm-legacy, bun) and ecosystems are untouched. `--dry-run` previews the same refusals (same scope, hosted pins and same-uuid ledger entries left out) as `would_refuse` rows with the backend's code, and the `--max-new-patches` rollout plan gives such a NEW patch no slot (#1976). `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor/vendor_rerun_no_network_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` events (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with status **`notInstalled`** (the `skipped` events, plus any warnings) — never `noMatch`, which remains pinned to the package-name path (exact names; near names are only suggested). PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `notInstalled` — consumers dispatch on each `skipped` event's `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `release_narrowing` entries in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). * **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selectionRequired` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `notInstalled` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `dryRun: true` with a `verified` event per patch a wet run would record (`oldUuid` on a would-be update), `skipped` / `already_in_manifest` for one already recorded, plus the narrowing `skipped` events; exit 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 8c16f1962..ee9e4e0e6 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -177,7 +177,9 @@ impl VendorPreview { /// [`crate::commands::vlt_preflight`], [`npm_lock_refusal`]) would refuse /// before any download, or whose hosted pnpm pin the takeover would fail /// to replace ([`hosted_pnpm_refusals`]: the pnpm backend's lock-text -/// refusal, #853), and would-refuse for PyPI purls the PyPI backend's +/// refusal, #853), or whose lock entry the backend refuses on the lock +/// text alone ([`lock_text_refusals`]: a package-lock entry installed from +/// a URL or git spec, a workspace member, #1976), and would-refuse for PyPI purls the PyPI backend's /// wiring guards refuse ([`pypi_refusals`], #891). `origins` are the /// run's `--patch-server-url` origins. The preview stays a ledger /// classification otherwise (engine refusals outside the preflights are @@ -207,6 +209,7 @@ pub(crate) async fn preview_vendor( let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; let state = state.unwrap_or_default(); let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, origins, &state).await; + let lock_text_refusals = lock_text_refusals(cwd, selected, origins, &state).await; let pypi_refusals = pypi_refusals(cwd, selected, origins, takeover_refusals).await; let refuse = |code: &str, detail: &str| PreviewVerdict::WouldRefuse { code: code.to_string(), @@ -234,6 +237,10 @@ pub(crate) async fn preview_vendor( let (code, detail) = npm_lock_refusal.as_ref().expect("checked by the guard"); refuse(code, detail) } + _ if lock_text_refusals.contains_key(&p.purl) => { + let (code, detail) = &lock_text_refusals[&p.purl]; + refuse(code, detail) + } _ if takeover_refusals.contains_key(&p.purl) => { let (code, detail) = &takeover_refusals[&p.purl]; refuse(code, detail) @@ -342,7 +349,9 @@ pub(crate) fn merge_vendor_envelope(env: &mut Envelope, venv: Envelope) { } /// The purls of `selected` the wet run's Bun, vlt or npm package-lock -/// preflight, the hosted pnpm takeover's lock-text gates, or the gem +/// preflight, the backends' per-entry lock-text gates +/// ([`lock_text_refusals`], #1976), the hosted pnpm takeover's lock-text +/// gates, or the gem /// hosted→vendored takeover gate, 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. @@ -358,8 +367,9 @@ pub(super) async fn preflight_refused_purls( 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 origins = crate::commands::hosted_unwind::patch_server_origins(common); - let pnpm_refusals = - hosted_pnpm_refusals(cwd, selected, &origins, &state.unwrap_or_default()).await; + let state = state.unwrap_or_default(); + let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, &origins, &state).await; + let lock_text_refusals = lock_text_refusals(cwd, selected, &origins, &state).await; let gem_refusals = crate::commands::vendor::gem_takeover_preview_refusals( common, selected.iter().map(|p| p.purl.as_str()), @@ -372,6 +382,7 @@ pub(super) async fn preflight_refused_purls( || vlt_refusal_for(&vlt_refusals, &p.purl).is_some() || (p.purl.starts_with("pkg:npm/") && npm_lock_refusal.is_some()) || pnpm_refusals.contains_key(&p.purl) + || lock_text_refusals.contains_key(&p.purl) || gem_refusals.contains_key(&p.purl) }) .map(|p| p.purl.clone()) @@ -392,6 +403,61 @@ async fn npm_lock_refusal( socket_patch_core::vendor::npm_lock_vendor_preflight(cwd).await } +/// The purls of `selected` the vendor backend refuses on the project's +/// lock and manifest text alone, with the backend's `(code, detail)`: the +/// npm package-lock, pnpm, yarn classic and yarn berry per-entry gates and +/// cargo's locked-version gate +/// ([`socket_patch_core::vendor::lock_text_refusals`]). These are the +/// refusals the wet run's download phase raises before it fetches a view +/// ([`crate::commands::agent_download::lock_text_refusals_for`]), so the +/// preview and the rollout plan must name them too (#1976): an npm package +/// installed from a URL or git spec, or a workspace member, otherwise +/// previews as vendorable and takes a `--max-new-patches` slot on every +/// run. As in the download phase, a purl the lockfiles pin hosted is left +/// to its takeover (which restores the upstream entry first, see +/// [`hosted_pnpm_refusals`]), a purl the ledger already holds at this uuid +/// is not asked, and only a package the vendor loop would hand to its +/// backend is kept ([`crate::commands::vendor::lock_refusals_reaching_backend`]). +async fn lock_text_refusals( + cwd: &Path, + selected: &[PatchSearchResult], + origins: &[String], + state: &VendorState, +) -> HashMap { + let gated = + |p: &&PatchSearchResult| p.purl.starts_with("pkg:npm/") || p.purl.starts_with("pkg:cargo/"); + if !selected.iter().any(|p| gated(&p)) { + return HashMap::new(); + } + let claimed = + crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await; + let candidates: Vec<(&str, &str)> = selected + .iter() + .filter(gated) + .filter(|p| !claimed.contains(&PurlKey::new(&p.purl))) + .filter(|p| lookup_entry(&state.entries, &p.purl).is_none_or(|e| e.uuid != p.uuid)) + .map(|p| (p.purl.as_str(), p.uuid.as_str())) + .collect(); + if candidates.is_empty() { + return HashMap::new(); + } + let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await; + let options = socket_patch_core::crawlers::CrawlerOptions { + cwd: cwd.to_path_buf(), + global: false, + global_prefix: None, + }; + crate::commands::vendor::lock_refusals_reaching_backend( + cwd, + refused, + &state.entries, + |purls| async move { + crate::commands::vendor::installed_purls(&options, &purls, None).await + }, + ) + .await +} + /// The lock-text refusals a hosted → vendored takeover of `selected` meets /// in a pnpm project (#853), keyed by the selected purl: each npm purl the /// lockfiles pin hosted (see [`crate::commands::agent_download::hosted_claimed_purls`]) @@ -2097,3 +2163,166 @@ mod merge_tests { assert!(env.error.is_none()); } } + +/// #1976: the npm package-lock backend's per-entry refusals reach the +/// dry-run preview and the rollout planning pass. +#[cfg(test)] +mod npm_lock_entry_refusal_tests { + use super::*; + use std::path::Path; + + const UUID: &str = "22222222-2222-4222-8222-222222222222"; + const LEFT_PAD: &str = "pkg:npm/left-pad@1.3.0"; + const IS_NUMBER: &str = "pkg:npm/is-number@7.0.0"; + const URL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + + fn sel(purl: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: UUID.into(), + purl: purl.into(), + published_at: "2024-01-01T00:00:00Z".into(), + description: String::new(), + license: "MIT".into(), + tier: "free".into(), + vulnerabilities: HashMap::new(), + } + } + + fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + fn installed(root: &Path, dir: &str, name: &str, version: &str) { + write( + &root.join(dir).join("package.json"), + &serde_json::json!({ "name": name, "version": version }).to_string(), + ); + } + + /// The issue's three shapes: left-pad installed from a URL spec, from + /// a git spec, or as a workspace member, beside a registry is-number. + /// Returns the code the wet run refuses left-pad with. + fn project(root: &Path, shape: &str) -> &'static str { + let is_number = serde_json::json!({ + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-AAAA==" + }); + let (spec, extra, workspaces, code) = match shape { + "url" => ( + URL, + serde_json::json!({ "node_modules/left-pad": { + "version": "1.3.0", "resolved": URL, "integrity": "sha512-orig==" } }), + None, + "vendor_lock_entry_not_rewritable", + ), + "git" => ( + "github:stevemao/left-pad#v1.3.0", + serde_json::json!({ "node_modules/left-pad": { + "version": "1.3.0", + "resolved": "git+ssh://git@github.com/stevemao/left-pad.git#ff8e7ba" } }), + None, + "vendor_lock_entry_not_rewritable", + ), + "workspace" => ( + "1.3.0", + serde_json::json!({ + "packages/left-pad": { "name": "left-pad", "version": "1.3.0" }, + "node_modules/left-pad": { "resolved": "packages/left-pad", "link": true }, + }), + Some(serde_json::json!(["packages/*"])), + "vendor_workspace_member", + ), + other => panic!("unknown shape {other}"), + }; + let mut root_entry = serde_json::json!({ + "name": "app", "version": "1.0.0", + "dependencies": { "is-number": "7.0.0" }, + }); + if workspaces.is_none() { + root_entry["dependencies"]["left-pad"] = spec.into(); + } + let mut manifest = root_entry.clone(); + if let Some(ws) = &workspaces { + root_entry["workspaces"] = ws.clone(); + manifest["workspaces"] = ws.clone(); + } + let mut packages = + serde_json::json!({ "": root_entry, "node_modules/is-number": is_number }); + for (k, v) in extra.as_object().unwrap() { + packages[k] = v.clone(); + } + let lock = serde_json::json!({ + "name": "app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, + "packages": packages, + }); + write(&root.join("package.json"), &manifest.to_string()); + write(&root.join("package-lock.json"), &lock.to_string()); + installed(root, "node_modules/is-number", "is-number", "7.0.0"); + if workspaces.is_some() { + installed(root, "packages/left-pad", "left-pad", "1.3.0"); + #[cfg(unix)] + std::os::unix::fs::symlink("../packages/left-pad", root.join("node_modules/left-pad")) + .unwrap(); + #[cfg(not(unix))] + installed(root, "node_modules/left-pad", "left-pad", "1.3.0"); + } else { + installed(root, "node_modules/left-pad", "left-pad", "1.3.0"); + } + code + } + + /// The dry-run preview names left-pad `would_refuse` with the wet + /// run's code, and keeps the registry is-number `would_vendor`. + #[tokio::test] + async fn preview_refuses_a_package_lock_entry_the_backend_refuses() { + for shape in ["url", "git", "workspace"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let code = project(root, shape); + let preview = + preview_vendor(root, &[sel(LEFT_PAD), sel(IS_NUMBER)], &[], &HashMap::new()).await; + let verdict = |purl: &str| { + &preview + .rows + .iter() + .find(|r| r.purl == purl) + .unwrap_or_else(|| panic!("{shape}: no row for {purl}")) + .verdict + }; + match verdict(LEFT_PAD) { + PreviewVerdict::WouldRefuse { code: got, detail } => { + assert_eq!(got, code, "{shape}: {detail}"); + } + other => panic!("{shape}: left-pad must preview would_refuse, got {other:?}"), + } + assert!( + matches!(verdict(IS_NUMBER), PreviewVerdict::WouldVendor), + "{shape}: is-number stays vendorable" + ); + assert_eq!(preview.refused_count(), 1, "{shape}"); + } + } + + /// The rollout planning pass: the refused left-pad holds no + /// `--max-new-patches` slot, so the vendorable is-number gets it. + #[tokio::test] + async fn planning_drops_a_package_lock_entry_the_backend_refuses() { + for shape in ["url", "git", "workspace"] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + project(root, shape); + let common = GlobalArgs { + cwd: root.to_path_buf(), + ..GlobalArgs::default() + }; + let refused = preflight_refused_purls(&common, &[sel(LEFT_PAD), sel(IS_NUMBER)]).await; + assert_eq!( + refused, + HashSet::from([LEFT_PAD.to_string()]), + "{shape}: only left-pad is refused" + ); + } + } +} From 6879d83d4c01714212dc9ebdca968a6ccfc5a09e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:19:37 +0000 Subject: [PATCH 4/4] Share the lock-text refusal scope with downloads The preview and rollout plan read the lock-text refusals through their own copy of the download phase's filters, and the copies disagreed on the ledger: a same-uuid entry written by plain vendor previewed as already vendored while the wet run still refused it. Both now go through one helper, unclaimed_lock_text_refusals, so the dry run and the wet run ask the same purls. npm purls are not asked again when the project-level package-lock refusal already covers them, and the contract's dry-run error-code row names the new codes. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/agent_download.rs | 71 ++++++++++---- .../src/commands/scan/vendor_flow.rs | 96 ++++++++++++------- 3 files changed, 112 insertions(+), 57 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 114c44871..13f5c333e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1618,7 +1618,7 @@ command's integration tests check its output. | `vendor_bun_workspace_unsupported` | `failed` | vendor / scan / get `--mode vendored` (bun): the text lock holds `workspace:` packages and its `lockfileVersion` is below 2 — Bun 1.2–1.3 resolve a workspace member's local-tarball path relative to the member; a committed version-2 lock is the proof every consumer runs Bun ≥ 1.4 (deliberate over-approximation: root-only declared packages would install on version 1 too). Detail names the version integer and a version-specific remedy: delete `bun.lock` and re-lock with Bun ≥ 1.4 (an in-place `bun install` keeps the existing version) — then, for a version-1 lock, "or use `--mode hosted`, which accepts version-1 workspace locks"; for a version-0 lock, "or delete `bun.lock`, re-lock with Bun ≥ 1.2 (which writes lockfileVersion 1) and use `--mode hosted`" (hosted refuses version-0 workspace locks, so a bare hosted pointer would send the user into a second refusal). Refused before any write — in the pre-download preflight on `get`/`scan` (see `vendor_bun_lockb_invalid` for the placements); in the shared preflight that `vendor` and the vendor step run BEFORE a hosted → vendored takeover's revert (a hosted-redirected purl stays hosted-wired, ledger and lock untouched; `vendor --dry-run` previews the same `failed` code); and in the engine when the run would write a NEW local tuple. Exempt: purls the vendor ledger wires at the selected uuid, purls whose every `bun.lock` instance is already a `.socket/vendor/npm/` tuple (any uuid), in-sync re-runs and `repair` redownloads. | | `vendor_lockfile_missing` / `vendor_lockfile_version_unsupported` (bun preflight placement) | `failed` | scan / get `--mode vendored` (bun): the pre-download preflight found `bun.lock` unreadable / at a `lockfileVersion` other than 0, 1 or 2 (a newer version: update socket-patch; no integer: re-lock with Bun ≥ 1.2 — the same text as hosted's `redirect_bun_lock_unsupported`) or outside bun's single-line `packages` grammar. Same placements as `vendor_bun_lockb_invalid`; nothing fetched, no patch record. An unreadable `.socket/vendor/state.json` met by the same preflight is `vendor_state_unreadable` (see that row), never one of these. | | `bun_lockb_invalid` | scan `warnings[]` (run-level) | scan (every mode): the native binary inventory could not parse or read `bun.lockb`; detail names the format or filesystem error. Also printed as `Warning: …` on stderr. Exit and status remain unchanged. The warning is retained on empty and non-empty scans; valid binary locks are inventoried normally without a runtime or install. | -| (Bun / pnpm-takeover / PyPI refusal codes) | `skipped` (dry-run preview, `details.mode: "vendored"`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl (`errorCode` one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger), or the pnpm backend would refuse the takeover of its hosted pin (`errorCode` that backend's lock-text code, #853), or the PyPI vendored backend's wiring guards would refuse this PyPI purl (`errorCode` that backend's code, #891); `reason` carries the detail. Exit 0 / `status: "success"`, nothing written. (v5.0: replaces the `would_refuse` preview action.) | +| (Bun / pnpm-takeover / lock-text / PyPI refusal codes) | `skipped` (dry-run preview, `details.mode: "vendored"`) | scan `--mode vendored --dry-run` / get `--mode vendored --dry-run`: the wet run's Bun preflight would refuse this npm purl (`errorCode` one of the four Bun lock codes above, or `vendor_state_unreadable` for an unreadable vendor ledger), or the pnpm backend would refuse the takeover of its hosted pin (`errorCode` that backend's lock-text code, #853), or the npm package-lock / pnpm / yarn / cargo backend would refuse this purl on its lock text alone (`errorCode` that backend's code, such as `vendor_lock_entry_not_rewritable` or `vendor_workspace_member`; the download phase's "Lock-text refusals before the download" scope, #1976), or the PyPI vendored backend's wiring guards would refuse this PyPI purl (`errorCode` that backend's code, #891); `reason` carries the detail. Exit 0 / `status: "success"`, nothing written. (v5.0: replaces the `would_refuse` preview action.) | | `cargo_wiring_migrated` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a pre-v5 `.cargo/config.toml` / `.cargo/config` vendored `[patch.crates-io]` entry was moved into the workspace-root `Cargo.toml` (dry run: "would move"); the ledger entry is rewritten to name `Cargo.toml` (lock originals kept). A vendor re-run that migrates reports the package `applied`, not `already_vendored`. | | `cargo_legacy_wiring_kept` | vendor: `failed`; repair: `skipped` (warning) | vendor / scan / get `--mode vendored` (v5.0): the pre-v5 config entry could not be removed after the manifest took the wiring — the run is unwound (manifest, lock and copy as before) and the package fails, since a kept entry would double-wire the crate and, on a uuid bump, point at a copy the stale sweep deletes; the code prefixes the error detail. repair: the move was refused (e.g. an unparseable `Cargo.toml`, a user entry for the crate, or an unremovable legacy entry — the manifest edit is unwound); left in place. | | `cargo_version_tagged` | `skipped` (advisory note) | vendor / scan / get `--mode vendored` / repair (v5.0): a vendored copy and its detached Cargo.lock entry were (re)tagged `+socket.` — a copy vendored before tagged versions, or a lock entry tagged for another uuid while the wiring points at this copy (dry run: "would tag"). A vendor re-run that tags reports the package `applied`. | diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 44bfbbe8a..0e5874c44 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -769,8 +769,9 @@ impl VendorRefusals<'_> { pub(crate) type LockRefusals = HashMap; /// The lock-text refusals of the vendored download phase (see -/// [`socket_patch_core::vendor::lock_text_refusals`]: the pnpm / yarn -/// classic / yarn berry gates and cargo's locked-version gate), over the +/// [`socket_patch_core::vendor::lock_text_refusals`]: the npm +/// package-lock / pnpm / yarn classic / yarn berry gates and cargo's +/// locked-version gate, through [`unclaimed_lock_text_refusals`]), over the /// patches the phase would otherwise fetch a view for — past the Bun /// refusal and the ledger's idempotency skip, which take precedence in the /// fetch loop. A purl the lockfiles pin hosted is left to the vendor loop: @@ -808,23 +809,15 @@ pub(crate) async fn lock_text_refusals_for( .is_none() }) .collect(); - let candidates: Vec<(&str, &str)> = fetchable - .iter() - .filter(|sr| !claimed.contains(&PurlKey::new(&sr.purl))) - .map(|sr| (sr.purl.as_str(), sr.uuid.as_str())) - .collect(); - let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await; - let options = params.crawler_options(); - let mut refusals = - crate::commands::vendor::lock_refusals_reaching_backend( - cwd, - refused, - &ledger.entries, - |purls| async move { - crate::commands::vendor::installed_purls(&options, &purls, prior).await - }, - ) - .await; + let mut refusals = unclaimed_lock_text_refusals( + cwd, + &fetchable, + &claimed, + ledger, + params.crawler_options(), + prior, + ) + .await; // A hosted gem the takeover will refuse (#775) is refused here too, so // its view is never fetched for a package the run cannot vendor. The // download phase only runs online (`--offline` refuses `get` and `scan` @@ -845,6 +838,46 @@ pub(crate) async fn lock_text_refusals_for( refusals } +/// The lock-text half of [`lock_text_refusals_for`], shared with the +/// vendored dry-run preview and rollout plan (`scan/vendor_flow.rs`) so the +/// dry run and the wet run refuse exactly the same purls (#1976): the +/// purls of `selected` the vendor backend refuses on the project's lock +/// text alone ([`socket_patch_core::vendor::lock_text_refusals`]), leaving +/// out a purl the lockfiles pin hosted (`claimed`: its takeover restores +/// the upstream entry first), one the ledger's idempotency skip settles +/// ([`detached_ledger_record`]), and one the vendor loop would never hand +/// to its backend ([`crate::commands::vendor::lock_refusals_reaching_backend`]; +/// `options` and `prior` drive its installed-copy lookup). +pub(crate) async fn unclaimed_lock_text_refusals( + cwd: &Path, + selected: &[&PatchSearchResult], + claimed: &std::collections::HashSet, + ledger: &VendorState, + options: CrawlerOptions, + prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, +) -> LockRefusals { + let candidates: Vec<(&str, &str)> = selected + .iter() + .filter(|sr| !claimed.contains(&PurlKey::new(&sr.purl))) + .filter(|sr| { + detached_ledger_record(RecordStore::Ledger(&ledger.entries), &sr.purl, &sr.uuid) + .is_none() + }) + .map(|sr| (sr.purl.as_str(), sr.uuid.as_str())) + .collect(); + if candidates.is_empty() { + return LockRefusals::new(); + } + let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await; + crate::commands::vendor::lock_refusals_reaching_backend( + cwd, + refused, + &ledger.entries, + |purls| async move { crate::commands::vendor::installed_purls(&options, &purls, prior).await }, + ) + .await +} + /// The purl keys the project's lockfiles pin hosted (a patch-server /// tarball, Socket's own or one of `origins`): the purls whose vendored /// run is a hosted → vendored takeover. 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 ee9e4e0e6..6037d4cce 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -186,8 +186,10 @@ impl VendorPreview { /// not predicted), and a would-refuse never flips the run's status or exit /// code. The preflights (the only disk access besides the ledger; the PyPI /// one may also probe `pipenv --version` and list Hatch environments, as -/// the wet run's guards do) run only when the selection holds an npm or -/// PyPI purl. +/// the wet run's guards do; the lock-text gates read the lock, the +/// manifest and, for a refused package the lock does not resolve to a +/// registry source, the installed tree) run only when the selection holds +/// an npm, PyPI or cargo purl. /// `takeover_refusals` adds the hosted→vendored takeover refusals the /// caller resolved (the gem gates of /// [`crate::commands::vendor::gem_takeover_preview_refusals`]), keyed by @@ -209,7 +211,8 @@ pub(crate) async fn preview_vendor( let npm_lock_refusal = npm_lock_refusal(cwd, selected).await; let state = state.unwrap_or_default(); let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, origins, &state).await; - let lock_text_refusals = lock_text_refusals(cwd, selected, origins, &state).await; + let lock_text_refusals = + lock_text_refusals(cwd, selected, origins, &state, npm_lock_refusal.is_some()).await; let pypi_refusals = pypi_refusals(cwd, selected, origins, takeover_refusals).await; let refuse = |code: &str, detail: &str| PreviewVerdict::WouldRefuse { code: code.to_string(), @@ -369,7 +372,8 @@ pub(super) async fn preflight_refused_purls( let origins = crate::commands::hosted_unwind::patch_server_origins(common); let state = state.unwrap_or_default(); let pnpm_refusals = hosted_pnpm_refusals(cwd, selected, &origins, &state).await; - let lock_text_refusals = lock_text_refusals(cwd, selected, &origins, &state).await; + let lock_text_refusals = + lock_text_refusals(cwd, selected, &origins, &state, npm_lock_refusal.is_some()).await; let gem_refusals = crate::commands::vendor::gem_takeover_preview_refusals( common, selected.iter().map(|p| p.purl.as_str()), @@ -406,54 +410,41 @@ async fn npm_lock_refusal( /// The purls of `selected` the vendor backend refuses on the project's /// lock and manifest text alone, with the backend's `(code, detail)`: the /// npm package-lock, pnpm, yarn classic and yarn berry per-entry gates and -/// cargo's locked-version gate -/// ([`socket_patch_core::vendor::lock_text_refusals`]). These are the -/// refusals the wet run's download phase raises before it fetches a view -/// ([`crate::commands::agent_download::lock_text_refusals_for`]), so the -/// preview and the rollout plan must name them too (#1976): an npm package -/// installed from a URL or git spec, or a workspace member, otherwise -/// previews as vendorable and takes a `--max-new-patches` slot on every -/// run. As in the download phase, a purl the lockfiles pin hosted is left -/// to its takeover (which restores the upstream entry first, see -/// [`hosted_pnpm_refusals`]), a purl the ledger already holds at this uuid -/// is not asked, and only a package the vendor loop would hand to its -/// backend is kept ([`crate::commands::vendor::lock_refusals_reaching_backend`]). +/// cargo's locked-version gate. These are the refusals the wet run's +/// download phase raises before it fetches a view, read through the same +/// [`crate::commands::agent_download::unclaimed_lock_text_refusals`] (same +/// hosted-pin, ledger and reaches-the-backend scope), so the preview and +/// the rollout plan name them too (#1976): an npm package installed from a +/// URL or git spec, or a workspace member, otherwise previews as vendorable +/// and takes a `--max-new-patches` slot on every run. npm purls are not +/// asked when `npm_lock_refused` (the project-level package-lock refusal +/// already refuses every one of them). async fn lock_text_refusals( cwd: &Path, selected: &[PatchSearchResult], origins: &[String], state: &VendorState, + npm_lock_refused: bool, ) -> HashMap { - let gated = - |p: &&PatchSearchResult| p.purl.starts_with("pkg:npm/") || p.purl.starts_with("pkg:cargo/"); - if !selected.iter().any(|p| gated(&p)) { - return HashMap::new(); - } - let claimed = - crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await; - let candidates: Vec<(&str, &str)> = selected + let gated: Vec<&PatchSearchResult> = selected .iter() - .filter(gated) - .filter(|p| !claimed.contains(&PurlKey::new(&p.purl))) - .filter(|p| lookup_entry(&state.entries, &p.purl).is_none_or(|e| e.uuid != p.uuid)) - .map(|p| (p.purl.as_str(), p.uuid.as_str())) + .filter(|p| { + (p.purl.starts_with("pkg:npm/") && !npm_lock_refused) + || p.purl.starts_with("pkg:cargo/") + }) .collect(); - if candidates.is_empty() { + if gated.is_empty() { return HashMap::new(); } - let refused = socket_patch_core::vendor::lock_text_refusals(cwd, &candidates).await; + let claimed = + crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await; let options = socket_patch_core::crawlers::CrawlerOptions { cwd: cwd.to_path_buf(), global: false, global_prefix: None, }; - crate::commands::vendor::lock_refusals_reaching_backend( - cwd, - refused, - &state.entries, - |purls| async move { - crate::commands::vendor::installed_purls(&options, &purls, None).await - }, + crate::commands::agent_download::unclaimed_lock_text_refusals( + cwd, &gated, &claimed, state, options, None, ) .await } @@ -2305,6 +2296,37 @@ mod npm_lock_entry_refusal_tests { } } + /// The ledger's scope is the download phase's: only a detached entry + /// carrying its record settles a purl, so a same-uuid entry `vendor` + /// wrote (not detached) is still asked, and the preview agrees with the + /// wet run's refusal instead of saying `already_vendored`. + #[tokio::test] + async fn preview_asks_a_same_uuid_entry_the_wet_run_still_gates() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let code = project(root, "url"); + write( + &root.join(".socket/vendor/state.json"), + &serde_json::json!({ + "version": 1, + "entries": { LEFT_PAD: { + "ecosystem": "npm", "basePurl": LEFT_PAD, "uuid": UUID, + "artifact": { "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") }, + "wiring": [], + }} + }) + .to_string(), + ); + let state = load_state(root).await.expect("the seeded ledger parses"); + let entry = lookup_entry(&state.entries, LEFT_PAD).expect("seeded entry"); + assert!(entry.uuid == UUID && !entry.detached); + let preview = preview_vendor(root, &[sel(LEFT_PAD)], &[], &HashMap::new()).await; + match &preview.rows[0].verdict { + PreviewVerdict::WouldRefuse { code: got, .. } => assert_eq!(got, code), + other => panic!("left-pad must preview would_refuse, got {other:?}"), + } + } + /// The rollout planning pass: the refused left-pad holds no /// `--max-new-patches` slot, so the vendorable is-number gets it. #[tokio::test]