From 42843efbb6874dcc3902bb5a30a2f964a864080c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 4 Nov 2026 11:27:21 -0600 Subject: [PATCH] Refuse vendored patches the same way everywhere A vendored run decides before any download which patches the engine would refuse, so it never fetches a view, asks for a grant or strips a hosted pin for a package it cannot vendor. That decision was assembled in the CLI from five sources (the Bun and vlt preflights, the package-lock project refusal, the lock-text gates and the pnpm takeover), and each entry point merged its own subset by hand. They had drifted: the download phase never asked the package-lock project refusal or the pnpm takeover gates, `get ` raised only the Bun and vlt refusals before the engine, and the vendor loop judged only Bun and vlt before its takeover. All of it now lives in one core function, `socket_patch_core::vendor::refusals::vendor_refusals`, which runs the checks in the engine's order and scopes the lock-text gates (hosted pins, the ledger's idempotency skip, packages that never reach their backend). The scan preview and rollout plan, the download phase, both `get` paths and the vendor loop call it. The CLI's bun_preflight.rs and vlt_preflight.rs are gone, and their tests moved to core. What users see: a v1 package-lock project, or a hosted pnpm pin whose takeover the pnpm gates refuse, is now refused before its patch view is fetched; `get --mode vendored` raises every such refusal with its pre-record error envelope and writes nothing; and `vendor` refuses them before restoring a hosted pin instead of restoring and rolling back. Codes and details are unchanged. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/vlt-compatibility.yml | 1 - crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/agent_download.rs | 276 ++---- .../src/commands/bun_preflight.rs | 384 -------- crates/socket-patch-cli/src/commands/get.rs | 132 ++- crates/socket-patch-cli/src/commands/mod.rs | 2 - .../src/commands/scan/vendor_flow.rs | 198 +--- .../socket-patch-cli/src/commands/vendor.rs | 238 ++--- .../src/commands/vlt_preflight.rs | 288 ------ .../in_process_vendor_npm_v1_takeover.rs | 46 + .../tests/manifest_path_ledger_root.rs | 1 - crates/socket-patch-core/src/vendor/mod.rs | 1 + .../socket-patch-core/src/vendor/refusals.rs | 930 ++++++++++++++++++ 13 files changed, 1311 insertions(+), 1188 deletions(-) delete mode 100644 crates/socket-patch-cli/src/commands/bun_preflight.rs delete mode 100644 crates/socket-patch-cli/src/commands/vlt_preflight.rs create mode 100644 crates/socket-patch-core/src/vendor/refusals.rs diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml index 26a51bb4e..7d68826dc 100644 --- a/.github/workflows/vlt-compatibility.yml +++ b/.github/workflows/vlt-compatibility.yml @@ -70,7 +70,6 @@ on: - 'crates/socket-patch-cli/src/commands/vendor.rs' - 'crates/socket-patch-cli/src/commands/vendored_backend/**' - 'crates/socket-patch-cli/src/commands/get.rs' - - 'crates/socket-patch-cli/src/commands/vlt_preflight.rs' - 'crates/socket-patch-cli/src/commands/scan/**' - 'crates/socket-patch-cli/src/commands/vex*' - 'crates/socket-patch-cli/tests/common/**' diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 847779694..13574a1da 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -349,7 +349,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 scan's vendored preview headline and lines (`[dry-run] Would download and vendor N patches. No changes made.`, with the already-vendored and would-refuse counts when non-zero; see `scan --mode vendored`) 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 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 detached ledger entries already holding this uuid's record, 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`. +**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 detached ledger entries already holding this uuid's record, 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. The npm package-lock project refusal (a primary lock that is not v2/v3, `vendor_lockfile_version_unsupported`, #659) and the lock-text refusals a hosted pnpm pin's takeover meets (see "Takeover reconciliation", #853) are decided here too, before the view, exactly as the `--dry-run` preview decides them; `vendor` judges every one of these refusals before its takeover and backend calls. On the **uuid path** (`get --mode vendored`) any of them on the looked-up patch exits 1 BEFORE the vendor step with the Bun preflight's pre-record envelope (`status: "error"`, `error: {code, message}`, the patch's one `failed` event) and writes nothing. All of them are one core decision (`socket_patch_core::vendor::refusals::vendor_refusals`) every entry point reads. 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/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 62c418341..ae3460bdb 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -18,6 +18,9 @@ use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::purl_key::PurlKey; +use socket_patch_core::vendor::refusals::{ + vendor_refusals, LedgerLoad, RefusalScope, VendorRefusal, VendorRefusals, +}; use socket_patch_core::vendor::{load_state, lookup_entry, VendorEntry, VendorState}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -25,11 +28,7 @@ use std::time::Duration; use crate::args::GlobalArgs; use crate::commands::apply::ApplyRunReport; -use crate::commands::bun_preflight::{bun_vendor_preflight_with_ledger, BunVendorRefusal}; use crate::commands::lock_cli::lock_failure; -use crate::commands::vlt_preflight::{ - vlt_refusal_for, vlt_vendor_preflight_selected, VltVendorRefusal, -}; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; use crate::json_envelope::{ Command as EnvelopeCommand, Envelope, EnvelopeError, PatchAction as EventAction, PatchEvent, @@ -747,152 +746,81 @@ pub(crate) const BLOB_WRITE_FAILED: &str = "blob_write_failed"; /// already records at the same uuid (agent mode still re-applies it). pub(crate) const ALREADY_IN_MANIFEST: &str = "already_in_manifest"; -/// The vendored-mode preflight verdicts the download phase refuses by -/// (Bun's project-level one, vlt's per purl); agent downloads pass none. -#[derive(Clone, Copy, Default)] -pub(crate) struct VendorRefusals<'a> { - pub(crate) bun: Option<&'a BunVendorRefusal>, - pub(crate) vlt: &'a [(String, VltVendorRefusal)], -} - -impl VendorRefusals<'_> { - fn for_purl(&self, purl: &str) -> Option<(&'static str, &str)> { - self.bun - .filter(|r| r.applies_to(purl)) - .map(|r| (r.code, r.detail.as_str())) - .or_else(|| vlt_refusal_for(self.vlt, purl).map(|r| (r.code, r.detail.as_str()))) - } +/// `selected` as the `(purl, uuid)` pairs the vendor refusals judge. +pub(crate) fn selection_pairs(selected: &[PatchSearchResult]) -> Vec<(&str, &str)> { + selected + .iter() + .map(|s| (s.purl.as_str(), s.uuid.as_str())) + .collect() } -/// Selected purls the vendor backend will refuse on the project's lock -/// text alone, with the backend's `(code, detail)`. -pub(crate) type LockRefusals = HashMap; - -/// The lock-text refusals of the vendored download phase (see -/// [`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: -/// its takeover restores the upstream lock entry first, and the restore -/// rewrites the very text the gates read. The one exception is a hosted -/// gem the takeover would refuse ([`gem_takeover_refusals_for`]), which -/// is refused here with the takeover's code instead of after its fetch. +/// Every refusal the vendored download phase raises before it fetches a +/// view: [`vendor_refusals`] over the selection, plus a hosted gem the +/// takeover would refuse (#775, [`gem_takeover_refusals_for`]), refused +/// here with the takeover's code instead of after its fetch. The download +/// phase only runs online (`--offline` refuses `get` and `scan` before it), +/// so the gem's dry-run restore may resolve the registry entry. `prior` is +/// scan's npm crawl, when the caller has it (the installed-copy lookup +/// reuses it). /// /// [`gem_takeover_refusals_for`]: crate::commands::vendor::gem_takeover_refusals_for -/// -/// Only a package the vendor loop would hand to its backend is refused -/// here (see [`crate::commands::vendor::lock_refusals_reaching_backend`]): -/// one installed on disk, or one the lockfile resolves to a verifiable -/// registry source. A package with neither — absent from the lock and not -/// installed — never reaches its backend: the loop reports it `skipped` / -/// `package_not_installed`, and so it still does. `prior` is scan's npm -/// crawl, when the caller has it (the installed-copy lookup reuses it). -pub(crate) async fn lock_text_refusals_for( +pub(crate) async fn vendored_download_refusals( params: &DownloadParams, selected: &[PatchSearchResult], - ledger: &VendorState, - bun_refusal: Option<&BunVendorRefusal>, + ledger: LedgerLoad<'_>, prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, -) -> LockRefusals { +) -> VendorRefusals { let cwd = params.cwd.as_path(); let origins = crate::commands::hosted_unwind::patch_server_origins_of(params.patch_server_url.as_deref()); let pins = hosted_pins(cwd, origins.clone()).await; - let claimed = claimed_purls(&pins); - let fetchable: Vec<&PatchSearchResult> = selected - .iter() - .filter(|sr| bun_refusal.filter(|r| r.applies_to(&sr.purl)).is_none()) - .filter(|sr| { - detached_ledger_record(RecordStore::Ledger(&ledger.entries), &sr.purl, &sr.uuid) - .is_none() - }) - .collect(); - let mut refusals = unclaimed_lock_text_refusals( + let options = params.crawler_options(); + let installed = crate::commands::vendor::installed_lookup(&options, prior); + let mut refusals = vendor_refusals( cwd, - &fetchable, - &claimed, + &selection_pairs(selected), ledger, - params.crawler_options(), - prior, + &RefusalScope { + hosted_pins: &pins, + installed: &installed, + }, ) .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` - // before it), so the dry-run restore may resolve the registry entry. - refusals.extend( - crate::commands::vendor::gem_takeover_refusals_for( - cwd, - fetchable - .iter() - .filter(|sr| claimed.contains(&PurlKey::new(&sr.purl))) - .map(|sr| sr.purl.as_str()), - &pins, - false, - origins, - ) - .await, - ); - 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 + let claimed = claimed_purls(&pins); + let empty = HashMap::new(); + let entries = ledger.unwrap_or(&empty); + let takeovers: Vec<&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() + !refusals.contains_key(&sr.purl) + && claimed.contains(&PurlKey::new(&sr.purl)) + && detached_ledger_record(RecordStore::Ledger(entries), &sr.purl, &sr.uuid) + .is_none() }) - .map(|sr| (sr.purl.as_str(), sr.uuid.as_str())) + .map(|sr| sr.purl.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( + let gem = crate::commands::vendor::gem_takeover_refusals_for( cwd, - refused, - &ledger.entries, - |purls| async move { crate::commands::vendor::installed_purls(&options, &purls, prior).await }, + takeovers.into_iter(), + &pins, + false, + origins, ) - .await + .await; + for (purl, (code, detail)) in gem { + refusals + .entry(purl) + .or_insert(VendorRefusal { code, detail }); + } + refusals } -/// 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. -/// The fetch-phase gate ([`lock_text_refusals_for`]) and the scan preview +/// The project's hosted pins: the purls whose vendored run is a hosted → +/// vendored takeover (a patch-server tarball, Socket's own or one of +/// `origins`). The download phase and the scan preview /// (`scan/vendor_flow.rs`) both read the takeovers through here, so the dry /// run and the wet run agree on which purls they are. -pub(crate) async fn hosted_claimed_purls( - cwd: &Path, - origins: Vec, -) -> std::collections::HashSet { - claimed_purls(&hosted_pins(cwd, origins).await) -} - -/// The project's hosted pins (see [`hosted_claimed_purls`]). -async fn hosted_pins( +pub(crate) async fn hosted_pins( cwd: &Path, origins: Vec, ) -> Vec { @@ -908,7 +836,7 @@ async fn hosted_pins( } /// The purl keys of `pins`. -fn claimed_purls( +pub(crate) fn claimed_purls( pins: &[socket_patch_core::patch::redirect::upstream::HostedPin], ) -> std::collections::HashSet { pins.iter().map(|pin| PurlKey::new(&pin.purl)).collect() @@ -931,7 +859,7 @@ pub(crate) fn detached_ledger_record<'a>( } /// The fetch loop both download engines share: installed-release -/// narrowing, the caller's Bun and vlt refusals, the per-store skip +/// narrowing, the caller's vendored refusals, the per-store skip /// decision, the view fetch (served from `prefetched` when the narrowing or /// the caller already holds the view), the no-applicable-files guardrail, /// optional blob persistence, and every per-patch failure record. Every @@ -943,8 +871,7 @@ pub(crate) async fn fetch_selected_patches( api_client: &ApiClient, store: RecordStore<'_>, blobs_dir: Option<&Path>, - refusals: VendorRefusals<'_>, - lock_refusals: &LockRefusals, + refusals: &VendorRefusals, mut prefetched: HashMap, ) -> FetchBatch { let quiet = params.quiet(); @@ -990,8 +917,8 @@ pub(crate) async fn fetch_selected_patches( // The view GETs the loop below makes — every patch past the refusal // and the ledger skip whose view is not already held in `prefetched` - // (the same three checks, in the loop's order, over inputs the loop - // never mutates) — run concurrently ahead of it, at most + // (the same checks, in the loop's order, over inputs the loop never + // mutates) — run concurrently ahead of it, at most // `api_concurrency` in flight, and come back in selection order. The // loop takes the next one where it would await the request, and each // request's `--debug` lines print there too, so stdout, the per-patch @@ -1000,9 +927,8 @@ pub(crate) async fn fetch_selected_patches( let to_fetch: Vec<&str> = selected .iter() .filter(|sr| { - refusals.for_purl(&sr.purl).is_none() + !refusals.contains_key(&sr.purl) && detached_ledger_record(store, &sr.purl, &sr.uuid).is_none() - && !lock_refusals.contains_key(&sr.purl) && !held.remove(sr.uuid.as_str()) }) .map(|sr| sr.uuid.as_str()) @@ -1020,9 +946,11 @@ pub(crate) async fn fetch_selected_patches( // Refusal FIRST (the dry-run preview's precedence): a preserved // ledger can name this exact uuid after `rollback --preserve-state` // unwired it, so UUID equality alone never exempts a purl — the - // lock-derived exemption inside `applies_to` decides. Code-tagged so - // a `--silent` operator can grep the stable code. - if let Some((code, detail)) = refusals.for_purl(purl) { + // refusals' own lock-derived exemption decides. The backend refuses + // the package before it reads the patch, so its view is never + // fetched (nor, downstream, its pristine source). Code-tagged so a + // `--silent` operator can grep the stable code. + if let Some(VendorRefusal { code, detail }) = refusals.get(purl) { batch.fail( params.json, Some(format!("[error] {purl} ({code}): {detail}")), @@ -1047,21 +975,6 @@ pub(crate) async fn fetch_selected_patches( continue; } - // Lock-text refusal (see `lock_text_refusals_for`): the vendor - // backend refuses this package on the project's lock alone, so its - // view is never fetched (nor, downstream, its pristine source) — - // reported with the backend's code and words, as the Bun refusal is. - if let Some((code, detail)) = lock_refusals.get(purl) { - batch.fail( - params.json, - Some(format!("[error] {purl} ({code}): {detail}")), - (purl, uuid), - code, - detail, - ); - continue; - } - // The view: from memory when the narrowing (or the uuid path's own // identifier fetch) already fetched it, else the network — the next // of the concurrent GETs above, which were planned for exactly @@ -1263,7 +1176,7 @@ fn release_warnings(warnings: Vec) -> Vec { pub(crate) const RELEASE_NARROWING: &str = "release_narrowing"; /// [`download_patch_records_with`], handing `prior` (scan's npm crawl of -/// the untouched tree) to the lock-text refusals' installed-copy lookup. +/// the untouched tree) to the refusals' installed-copy lookup. pub(crate) async fn download_patch_records_reusing( selected: &[PatchSearchResult], params: &DownloadParams, @@ -1271,32 +1184,22 @@ pub(crate) async fn download_patch_records_reusing( prefetched: HashMap, prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, ) -> DetachedDownload { - // The ledger load outcome is handed to the preflight AS a result: an + // The ledger load outcome is handed to the refusals AS a result: an // unreadable ledger must surface as `vendor_state_unreadable` from the // one refusal this phase emits (fail closed, nothing exempt), not be - // flattened into an empty ledger that then reports a Bun lock remedy. - // For the classification below it degrades to empty (no detached entry - // to reuse — the vendor step reports the corruption itself). + // flattened into an empty ledger that then reports a lock remedy. For + // the classification below it degrades to empty (no detached entry to + // reuse — the vendor step reports the corruption itself). This phase + // feeds the vendor engine, so it refuses what the engine would BEFORE + // fetching: otherwise the view is downloaded for nothing, and a package + // resolvable only through an unreadable lock inventory is misreported + // as `package_not_installed` instead of the real code. let vendor_state = load_state(¶ms.project_root()).await; - // Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor - // engine, so it must refuse the same projects BEFORE fetching — - // otherwise the view is downloaded for nothing and a package - // resolvable only through the unreadable bun.lockb inventory is - // misreported as `package_not_installed` instead of the real - // `vendor_bun_*` code. npm-only, so release narrowing (PyPI / RubyGems / - // Maven variants) cannot change its verdict. - let bun_refusal = bun_vendor_preflight_with_ledger( - ¶ms.cwd, - selected, - vendor_state.as_ref().map(|s| &s.entries), - ) - .await; - // The vlt twin: every lock-, manifest- and ledger-decidable vlt refusal - // (see `crate::commands::vlt_preflight`), per purl. - let vlt_refusals = vlt_vendor_preflight_selected( - ¶ms.cwd, + let refusals = vendored_download_refusals( + params, selected, vendor_state.as_ref().map(|s| &s.entries), + prior, ) .await; download_patch_records_preflighted( @@ -1305,33 +1208,24 @@ pub(crate) async fn download_patch_records_reusing( api_client, prefetched, vendor_state, - VendorRefusals { - bun: bun_refusal.as_ref(), - vlt: &vlt_refusals, - }, - prior, + &refusals, ) .await } /// [`download_patch_records_with`] after its two reads: the caller's own -/// ledger load and Bun preflight outcome. The `get ` path runs the -/// preflight itself (it owns the pre-record refusal shape) and hands the -/// UNFILTERED outcome down, so the lock is read once per run and the -/// refused-but-exempt case still reaches the per-purl `applies_to` gate. +/// ledger load and [`vendored_download_refusals`] outcome. The `get ` +/// path computes the refusals itself (it owns the pre-record refusal +/// shape) and hands them down, so the project is judged once per run. pub(crate) async fn download_patch_records_preflighted( selected: &[PatchSearchResult], params: &DownloadParams, api_client: &ApiClient, prefetched: HashMap, vendor_state: std::io::Result, - refusals: VendorRefusals<'_>, - prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, + refusals: &VendorRefusals, ) -> DetachedDownload { let vendor_state = vendor_state.unwrap_or_default(); - let lock_refusals = - lock_text_refusals_for(params, selected, &vendor_state, refusals.bun, prior).await; - let blobs_dir = params.socket_dir().join("blobs"); let batch = fetch_selected_patches( selected, @@ -1340,7 +1234,6 @@ pub(crate) async fn download_patch_records_preflighted( RecordStore::Ledger(&vendor_state.entries), params.persist_blobs.then_some(blobs_dir.as_path()), refusals, - &lock_refusals, prefetched, ) .await; @@ -1789,9 +1682,9 @@ pub async fn download_and_apply_patches_into( } }; - // No Bun preflight here: this is the agent (manifest) engine, and + // No vendored refusals here: this is the agent (manifest) engine, and // agent/save-only flows keep their record-only intent. The vendored - // download phase (`download_patch_records_with`) runs its own. + // download phase (`download_patch_records_with`) computes its own. let blobs_dir = socket_dir.join("blobs"); let batch = fetch_selected_patches( selected, @@ -1799,8 +1692,7 @@ pub async fn download_and_apply_patches_into( run.api_client, RecordStore::Manifest(&manifest), params.persist_blobs.then_some(blobs_dir.as_path()), - VendorRefusals::default(), - &HashMap::new(), + &VendorRefusals::new(), HashMap::new(), ) .await; diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs deleted file mode 100644 index 733ef9311..000000000 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! The Bun vendored-mode preflight shared by every path that feeds the -//! vendor engine: `scan --mode vendored`, `get … --mode vendored`, their -//! `--dry-run` previews, and the `vendor` command itself (BEFORE the -//! hosted→vendored takeover reverts anything). -//! -//! It runs [`preflight_vendor`] once, before any `/patches/view/` fetch and -//! before any write, so an incompatible Bun project never has a patch -//! downloaded, recorded, or its hosted redirect stripped, and every entry -//! point reports the same vendor code the engine would have emitted. -//! -//! [`preflight_vendor`]: socket_patch_core::vendor::bun_lock::preflight_vendor - -use std::collections::{HashMap, HashSet}; -use std::path::Path; - -use socket_patch_core::api::types::PatchSearchResult; -use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::vendor::load_state; -use socket_patch_core::vendor::state::VendorEntry; - -/// The caller's own `load_state` outcome, so an unreadable ledger is a fact -/// the refusal can report instead of a silently-emptied exemption set. -pub(crate) type LedgerLoad<'a> = Result<&'a HashMap, &'a std::io::Error>; - -/// The outcome of the preflight when the project is refused. -/// -/// `exempt` holds the selected purls the refusal must NOT pre-empt. A purl -/// is exempt only when `bun.lock` wires every instance of its -/// `name@version` to one of our `.socket/vendor/npm/` tuples, at any UUID -/// ([`wired_instances_all_ours`]). This matches the engine's workspace -/// gate: updating an already-local tuple introduces no new relative path. -/// A matching ledger UUID alone is insufficient: `rollback --preserve-state` -/// retains the entry after removing its wiring. -/// -/// An unreadable ledger exempts nothing (fail closed) and the refusal -/// becomes `vendor_state_unreadable`: the vendor step does not reliably -/// report the corrupt ledger itself (`get --mode vendored` returns -/// before it runs), so this refusal has to name the real problem rather -/// than send the operator off to re-lock bun.lock. -/// -/// [`wired_instances_all_ours`]: socket_patch_core::vendor::bun_lock::wired_instances_all_ours -pub(crate) struct BunVendorRefusal { - /// The vendor error code the engine would have emitted, or - /// `vendor_state_unreadable`. - pub(crate) code: &'static str, - pub(crate) detail: String, - exempt: HashSet, -} - -impl BunVendorRefusal { - /// npm-family only (no other ecosystem's backend consults `bun.lock`), - /// minus the already-vendored exemption. - pub(crate) fn applies_to(&self, purl: &str) -> bool { - is_npm(purl) && !self.exempt.contains(purl) - } -} - -fn is_npm(purl: &str) -> bool { - Ecosystem::from_purl(purl) == Some(Ecosystem::Npm) -} - -/// The preflight for `selected`, loading the vendor ledger only once the -/// project is known to refuse, so an accepted project never touches -/// `state.json`. -pub(crate) async fn bun_vendor_preflight( - cwd: &Path, - selected: &[PatchSearchResult], -) -> Option { - preflight_pairs(cwd, &selection_pairs(selected), None).await -} - -/// [`bun_vendor_preflight`] with the caller's ledger load. -pub(crate) async fn bun_vendor_preflight_with_ledger( - cwd: &Path, - selected: &[PatchSearchResult], - ledger: LedgerLoad<'_>, -) -> Option { - preflight_pairs(cwd, &selection_pairs(selected), Some(ledger)).await -} - -/// The preflight over bare `(purl, uuid)` pairs (the `vendor` command's -/// manifest records) with the caller's ledger load. -pub(crate) async fn bun_vendor_preflight_pairs( - cwd: &Path, - pairs: &[(&str, &str)], - ledger: LedgerLoad<'_>, -) -> Option { - preflight_pairs(cwd, pairs, Some(ledger)).await -} - -/// `ledger` is the caller's own load, or `None` to load it here once the -/// project is known to refuse. `None` when no npm purl is selected, a vlt -/// lock routes the project away from Bun, or the project is accepted. -async fn preflight_pairs( - cwd: &Path, - pairs: &[(&str, &str)], - ledger: Option>, -) -> Option { - if !pairs.iter().any(|(purl, _)| is_npm(purl)) { - return None; - } - // vlt wins the router (DESIGN D2): a Bun lock beside `vlt-lock.json` - // is never the one vendoring rewires. - if socket_patch_core::vendor::npm_flavor::vlt_routes(cwd) - .await - .is_some() - { - return None; - } - let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) - .await - .err()?; - let loaded; - let ledger = match ledger { - Some(ledger) => ledger, - None => { - loaded = load_state(cwd).await; - loaded.as_ref().map(|s| &s.entries) - } - }; - if let Err(e) = ledger { - return Some(BunVendorRefusal { - code: "vendor_state_unreadable", - detail: e.to_string(), - exempt: HashSet::new(), - }); - } - // The exemption exists only for the workspace gate: every other code - // means bun.lock could not be read or parsed, so nothing in it is ours. - let mut exempt = HashSet::new(); - if code == "vendor_bun_workspace_unsupported" { - for (purl, _) in pairs.iter().filter(|(purl, _)| is_npm(purl)) { - if socket_patch_core::vendor::bun_lock::wired_instances_all_ours(cwd, purl) - .await - .unwrap_or(false) - { - exempt.insert((*purl).to_string()); - } - } - } - Some(BunVendorRefusal { - code, - detail, - exempt, - }) -} - -fn selection_pairs(selected: &[PatchSearchResult]) -> Vec<(&str, &str)> { - selected - .iter() - .map(|s| (s.purl.as_str(), s.uuid.as_str())) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use socket_patch_core::api::types::VulnerabilityResponse; - - const UUID: &str = "22222222-2222-4222-8222-222222222222"; - const OTHER_UUID: &str = "33333333-3333-4333-8333-333333333333"; - const PURL: &str = "pkg:npm/covgap-bun@1.0.0"; - - fn sel(uuid: &str, purl: &str) -> PatchSearchResult { - PatchSearchResult { - uuid: uuid.into(), - purl: purl.into(), - published_at: "2024-01-01".into(), - description: String::new(), - license: "MIT".into(), - tier: "free".into(), - vulnerabilities: HashMap::::new(), - } - } - - /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture - /// grammar) resolving `covgap-bun@1.0.0` from the registry. - const BUN_V1_WORKSPACE_LOCK: &str = r#"{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "unit-fixture", - "dependencies": { - "consumer": "workspace:*", - }, - }, - "packages/consumer": { - "name": "consumer", - "version": "1.0.0", - "dependencies": { - "covgap-bun": "1.0.0", - }, - }, - }, - "packages": { - "consumer": ["consumer@workspace:packages/consumer"], - - "covgap-bun": ["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="], - } -} -"#; - - fn seed_bun_vendor_entry(root: &Path, purl: &str, uuid: &str) { - let vendor = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor).unwrap(); - std::fs::write( - vendor.join("state.json"), - serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, - "entries": { purl: { - "ecosystem": "npm", - "basePurl": purl, - "uuid": uuid, - "artifact": { - "path": format!(".socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz"), - }, - "wiring": [], - "flavor": "bun", - }} - })) - .unwrap(), - ) - .unwrap(); - } - - /// The lock as it reads once `covgap-bun` is vendored at `uuid`. - fn vendored_lock(uuid: &str) -> String { - BUN_V1_WORKSPACE_LOCK.replace( - r#"["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="]"#, - &format!( - r#"["covgap-bun@.socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz", {{}}, "sha512-OURS=="]"# - ), - ) - } - - /// `bun_vendor_preflight` never reads the lock when nothing selected is - /// npm (no needless I/O, no spurious refusal for other ecosystems); - /// a ledger alone never exempts; an unreadable ledger exempts nothing - /// (fail closed) AND is reported as the real problem - /// (`vendor_state_unreadable`), never as a Bun lock remedy. - #[tokio::test] - async fn preflight_scope_and_corrupt_ledger() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); - let pypi = vec![sel( - "11111111-1111-4111-8111-111111111111", - "pkg:pypi/only@1.0.0", - )]; - assert!( - bun_vendor_preflight(tmp.path(), &pypi).await.is_none(), - "no npm purl selected => no refusal" - ); - - let npm = vec![sel(UUID, PURL)]; - let refusal = bun_vendor_preflight(tmp.path(), &npm) - .await - .expect("malformed lockb project is refused"); - assert_eq!(refusal.code, "vendor_bun_lockb_invalid"); - assert!(refusal.applies_to(PURL)); - assert!(!refusal.applies_to("pkg:pypi/only@1.0.0")); - - // A ledger at this UUID cannot make a malformed binary lock vendorable. - seed_bun_vendor_entry(tmp.path(), PURL, UUID); - let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); - assert_eq!(refusal.code, "vendor_bun_lockb_invalid"); - assert!(refusal.applies_to(PURL), "the live lock must be compatible"); - - // …but a corrupt ledger exempts nothing and names itself. - std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap(); - let refusal = bun_vendor_preflight(tmp.path(), &npm).await.unwrap(); - assert_eq!( - refusal.code, "vendor_state_unreadable", - "the refusal must name the ledger, not the lock: {}", - refusal.detail - ); - assert!( - refusal.detail.contains("state.json"), - "the io/parse detail names the file: {}", - refusal.detail - ); - assert!( - refusal.applies_to(PURL), - "an unreadable ledger must not exempt (fail closed)" - ); - // The ledger-passing variant reports the same. - let err = std::io::Error::other("corrupt state.json: synthetic"); - let refusal = bun_vendor_preflight_with_ledger(tmp.path(), &npm, Err(&err)) - .await - .unwrap(); - assert_eq!(refusal.code, "vendor_state_unreadable"); - assert_eq!(refusal.detail, "corrupt state.json: synthetic"); - assert!(refusal.applies_to(PURL)); - } - - /// vlt wins the router: a Bun lock beside `vlt-lock.json`, readable or - /// not, is never judged by the Bun preflight. - #[tokio::test] - async fn a_vlt_lock_takes_the_project_out_of_bun_scope() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); - let npm = vec![sel(UUID, PURL)]; - assert!(bun_vendor_preflight(tmp.path(), &npm).await.is_some()); - for vlt in ["{\n \"lockfileVersion\": 1\n}\n", "\u{feff}{}"] { - std::fs::write(tmp.path().join("vlt-lock.json"), vlt).unwrap(); - assert!( - bun_vendor_preflight(tmp.path(), &npm).await.is_none(), - "{vlt:?}" - ); - } - } - - /// The lock-derived exemption: on a pre-v2 workspace lock whose every - /// instance of the purl is already ours, a SUPERSEDING uuid (ledger at - /// the old uuid) and a WIPED ledger are both exempt — the engine - /// re-vendors / stays in sync — while a fresh registry instance is - /// refused whatever the ledger says about other purls. - #[tokio::test] - async fn lock_derived_exemption_matches_the_engine_gate() { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); - let fresh = vec![sel(UUID, PURL)]; - let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); - assert_eq!(refusal.code, "vendor_bun_workspace_unsupported"); - assert!( - refusal.applies_to(PURL), - "a fresh registry instance is refused" - ); - - // A preserved ledger does not make registry wiring exempt. - seed_bun_vendor_entry(tmp.path(), PURL, UUID); - let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); - assert!(refusal.applies_to(PURL)); - - // Live vendored tuples remain exempt. - std::fs::write(tmp.path().join("bun.lock"), vendored_lock(UUID)).unwrap(); - let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); - assert!(!refusal.applies_to(PURL), "in-sync re-run is exempt"); - - // Superseding uuid: the ledger disagrees, the lock says ours → exempt. - let superseding = vec![sel(OTHER_UUID, PURL)]; - let refusal = bun_vendor_preflight(tmp.path(), &superseding) - .await - .unwrap(); - assert_eq!(refusal.code, "vendor_bun_workspace_unsupported"); - assert!( - !refusal.applies_to(PURL), - "a patch update on an already-vendored purl must not be refused" - ); - - // Wiped ledger: no entry at all, the lock alone exempts. - std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); - let refusal = bun_vendor_preflight(tmp.path(), &fresh).await.unwrap(); - assert!( - !refusal.applies_to(PURL), - "a lost ledger must not turn an in-sync project into a refusal" - ); - - // A different, still-registry purl in the same lock stays refused - // and a non-npm purl is never in scope. - let others = vec![ - sel(OTHER_UUID, "pkg:npm/other@2.0.0"), - sel(OTHER_UUID, "pkg:pypi/x@1.0.0"), - ]; - let refusal = bun_vendor_preflight(tmp.path(), &others).await.unwrap(); - assert!(refusal.applies_to("pkg:npm/other@2.0.0")); - assert!(!refusal.applies_to("pkg:pypi/x@1.0.0")); - - // The pairs form (the `vendor` command's view) agrees. - let pairs = [(PURL, OTHER_UUID), ("pkg:npm/other@2.0.0", OTHER_UUID)]; - let empty = HashMap::new(); - let refusal = bun_vendor_preflight_pairs(tmp.path(), &pairs, Ok(&empty)) - .await - .unwrap(); - assert!(!refusal.applies_to(PURL)); - assert!(refusal.applies_to("pkg:npm/other@2.0.0")); - assert!( - bun_vendor_preflight_pairs(tmp.path(), &[("pkg:cargo/x@1.0.0", UUID)], Ok(&empty)) - .await - .is_none(), - "no npm pair => no refusal" - ); - } -} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 517ddbdac..8f8b7053f 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -19,6 +19,7 @@ use socket_patch_core::utils::purl::{canonical_purl, normalize_purl, strip_purl_ use socket_patch_core::utils::purl_key::PurlKey; use socket_patch_core::utils::target::{Target, TargetKind}; use socket_patch_core::vendor::load_state; +use socket_patch_core::vendor::refusals::VendorRefusal; use std::collections::HashMap; use std::path::Path; use std::time::Duration; @@ -31,18 +32,14 @@ use crate::commands::agent_download::{ decide_patch_action, download_patch_records_preflighted, download_patch_records_reusing, filter_to_installed_releases, max_vuln_severity, nested_apply_args, patch_event_metadata, record_apply_outcome, report_lock_failure, run_nested_apply, run_outcome, tag_mode, - unwind_new_blobs, warn_if_manifest_gitignored, warn_on_vendored_uuid_drift, - write_all_patch_blobs, DetachedDownload, PatchAction, VendorRefusals, ALREADY_IN_MANIFEST, - BLOB_WRITE_FAILED, RELEASE_NARROWING, + unwind_new_blobs, vendored_download_refusals, warn_if_manifest_gitignored, + warn_on_vendored_uuid_drift, write_all_patch_blobs, DetachedDownload, PatchAction, + ALREADY_IN_MANIFEST, BLOB_WRITE_FAILED, RELEASE_NARROWING, }; pub use crate::commands::agent_download::{ download_and_apply_patches_into, download_and_apply_patches_with, DownloadParams, DownloadRun, }; use crate::commands::apply::ApplyRunReport; -use crate::commands::bun_preflight::{bun_vendor_preflight, BunVendorRefusal}; -use crate::commands::vlt_preflight::{ - vlt_refusal_for, vlt_vendor_preflight_selected, VltVendorRefusal, -}; use crate::ecosystem_dispatch::{crawl_ecosystems, find_packages_for_rollback, partition_purls}; use crate::json_envelope::{ usage_error, Command as JsonCommand, Envelope, EnvelopeError, PatchAction as EventAction, @@ -2265,9 +2262,10 @@ async fn run_get_hosted( /// `prefetched` is the `get ` path's already-fetched view: it resolved /// the identifier by fetching it (with the possibly-proxy-fallback client) /// and the engine serves the record from it instead of fetching again. That -/// path also refuses a Bun project BEFORE the engine, with the contract's -/// exact pre-record envelope, so a refused run writes nothing at all; the -/// search path lets the engine record the refusal per patch and still runs +/// path also raises any vendored refusal of that patch BEFORE the engine +/// (see [`vendored_download_refusals`]), with the contract's exact +/// pre-record envelope, so a refused run writes nothing at all; the search +/// path lets the download phase record the refusal per patch and still runs /// the vendor step (scan parity). #[allow(clippy::too_many_arguments)] async fn run_get_vendored( @@ -2295,16 +2293,24 @@ async fn run_get_vendored( return 0; } - // The uuid path's Bun preflight, run ONCE here and handed to the download - // phase below (which otherwise runs its own): the pre-record refusal - // shape is this path's, so it owns the read. - let mut bun_refusal: Option = None; - let mut vlt_refusals: Vec<(String, VltVendorRefusal)> = Vec::new(); - if let Some(patch) = prefetched { - // Bun preflight (see `BunVendorRefusal`): refuse BEFORE the engine - // and the vendor step, so the tree stays exactly as it was (no - // `.socket/` is created on a fresh project). The already-fetched - // patch is the only network traffic of a refused run. + // Download phase — records in memory, blobs never persisted, the nested + // apply structurally never runs (save_only): the vendor step IS the + // persistence. + let params = get_download_params( + args, /*save_only=*/ true, /*persist_blobs=*/ false, + ); + let prefetched_views: HashMap = prefetched + .map(|p| HashMap::from([(p.uuid.clone(), p.clone())])) + .unwrap_or_default(); + // Boxed: the futures embed the narrowing + fetch loop, and `run`'s poll + // frame must fit Windows' 1 MiB main-thread stack. + let download: DetachedDownload = if let Some(patch) = prefetched { + // The uuid path judges the project ONCE here (see + // `vendored_download_refusals`) and hands the refusals to the + // download phase: a refusal is raised BEFORE the engine and the + // vendor step, so the tree stays exactly as it was (no `.socket/` + // is created on a fresh project). The already-fetched patch is the + // only network traffic of a refused run. // // JSON: `status: "error"` with `error: {code: , // message: }` and the patch's `failed` event (same @@ -2312,24 +2318,19 @@ async fn run_get_vendored( // // Human: `Error (): ` on stderr — an error, so it is // exempt from `--silent` like every other `Error (…)` line here. - bun_refusal = bun_vendor_preflight(&args.common.project_root(), selected).await; - let ledger = load_state(&args.common.project_root()).await; - vlt_refusals = vlt_vendor_preflight_selected( - &args.common.cwd, + let vendor_state = load_state(&args.common.project_root()).await; + let refusals = Box::pin(vendored_download_refusals( + ¶ms, selected, - ledger.as_ref().map(|s| &s.entries), - ) + vendor_state.as_ref().map(|s| &s.entries), + None, + )) .await; - let refusal = bun_refusal - .as_ref() - .filter(|r| r.applies_to(&patch.purl)) - .map(|r| (&r.code, &r.detail)) - .or_else(|| vlt_refusal_for(&vlt_refusals, &patch.purl).map(|r| (&r.code, &r.detail))); - if let Some((code, detail)) = refusal { + if let Some(VendorRefusal { code, detail }) = refusals.get(&patch.purl) { // Same failure telemetry as the vendor-step Err arm below: this // run exits 1 without vendoring anything. socket_patch_core::telemetry::track_patch_vendor_failed( - &detail, + detail, args.common.dry_run, telemetry, ) @@ -2351,32 +2352,13 @@ async fn run_get_vendored( } return 1; } - } - - // Download phase — records in memory, blobs never persisted, the nested - // apply structurally never runs (save_only): the vendor step IS the - // persistence. Boxed: the future embeds the narrowing + fetch loop, and - // `run`'s poll frame must fit Windows' 1 MiB main-thread stack. - let params = get_download_params( - args, /*save_only=*/ true, /*persist_blobs=*/ false, - ); - let prefetched_views: HashMap = prefetched - .map(|p| HashMap::from([(p.uuid.clone(), p.clone())])) - .unwrap_or_default(); - let download: DetachedDownload = if prefetched.is_some() { - // The preflight above already read the lock: hand its outcome down. - let vendor_state = load_state(&args.common.project_root()).await; Box::pin(download_patch_records_preflighted( selected, ¶ms, api_client, prefetched_views, vendor_state, - VendorRefusals { - bun: bun_refusal.as_ref(), - vlt: &vlt_refusals, - }, - None, + &refusals, )) .await } else { @@ -4821,6 +4803,50 @@ mod tests { ); } + /// The download phase refuses what the vendor engine would before it + /// fetches a view: a v1 package-lock project refuses every npm patch + /// with the backend's code, and no view is requested. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_v1_package_lock_refuses_before_any_view() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{"name":"app","version":"1.0.0","dependencies":{"covgap-v1":"1.0.0"}}"#, + ) + .unwrap(); + std::fs::write( + tmp.path().join("package-lock.json"), + r#"{"name":"app","version":"1.0.0","lockfileVersion":1,"dependencies":{}}"#, + ) + .unwrap(); + let uuid = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let purl = "pkg:npm/covgap-v1@1.0.0"; + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; + + assert_eq!(code, 1, "json={json}"); + assert!(records.is_empty()); + assert_eq!(json["events"][0]["action"], "failed", "json={json}"); + assert_eq!( + json["events"][0]["errorCode"], "vendor_lockfile_version_unsupported", + "json={json}" + ); + let requests = server.received_requests().await.unwrap_or_default(); + assert!( + requests + .iter() + .all(|r| !r.url.path().contains("/patches/view/")), + "a refused patch's view is never fetched: {requests:?}" + ); + } + #[tokio::test] #[serial_test::serial] async fn download_patch_records_uninstalled_variant_base_warns_and_keeps_all() { diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 99a173774..966a6f80d 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod agent_download; pub mod apply; -pub(crate) mod bun_preflight; pub(crate) mod composer_hints; pub(crate) mod context; pub(crate) mod fetch_stage; @@ -23,7 +22,6 @@ pub mod vex; pub(crate) mod vex_consumed; pub(crate) mod vex_sources; pub(crate) mod vlt_heal; -pub(crate) mod vlt_preflight; use std::path::Path; 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 eb5bf2a40..7181cf010 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -22,6 +22,7 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::telemetry::{track_patch_vendor_failed, PendingTelemetry, TelemetryAuth}; use socket_patch_core::utils::purl_key::PurlKey; +use socket_patch_core::vendor::refusals::{vendor_refusals, RefusalScope, VendorRefusal}; use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -32,13 +33,11 @@ use crate::commands::agent_download::tag_mode; use crate::commands::agent_download::{ download_patch_records_reusing, DetachedDownload, DownloadParams, }; -use crate::commands::bun_preflight::bun_vendor_preflight_with_ledger; use crate::commands::lock_cli::lock_failure; use crate::commands::vendor::{ note_classic_migration_risk, symlinked_wiring_warnings, track_outcomes_for_vendor, }; use crate::commands::vendored_backend::{records_manifest, ApplyRequest, VendoredBackend}; -use crate::commands::vlt_preflight::{vlt_refusal_for, vlt_vendor_preflight_selected}; use crate::ecosystem_dispatch::NpmCrawlSnapshot; use crate::json_envelope::{ Command as EnvelopeCommand, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning, @@ -226,26 +225,21 @@ impl VendorPreview { /// Dry-run preview for `scan --mode vendored` (and `get … --mode vendored /// --dry-run`): classify each selected patch against the vendor ledger /// without writing anything or touching the network beyond discovery -/// (see [`PreviewVerdict`]): would-refuse for npm purls the wet run's Bun, -/// vlt or npm package-lock preflight -/// ([`crate::commands::bun_preflight::BunVendorRefusal`], -/// [`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), 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), plus the gem hosted→vendored takeover refusals -/// ([`crate::commands::vendor::gem_takeover_preview_refusals`]). The preview stays a ledger -/// classification otherwise (engine refusals outside the preflights are -/// 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; 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. +/// (see [`PreviewVerdict`]): would-refuse for every purl the wet run +/// refuses before any download +/// ([`socket_patch_core::vendor::refusals::vendor_refusals`]: the npm +/// flavor gates, the package-lock project refusal, a hosted pnpm pin's +/// takeover (#853) and the npm / cargo lock-text gates, #1976), for PyPI +/// purls the PyPI backend's wiring guards refuse ([`pypi_refusals`], +/// #891), and for the gem hosted→vendored takeover refusals +/// ([`crate::commands::vendor::gem_takeover_preview_refusals`]). The +/// preview stays a ledger classification otherwise (engine refusals outside +/// these are not predicted), and a would-refuse never flips the run's +/// status or exit code. The refusals are 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; 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). /// /// The one answer to "which selected patches would the wet run refuse?": /// the dry-run rows and the rollout planning pass @@ -275,20 +269,40 @@ async fn preview_vendor_with( origins: &[String], takeover_refusals: &HashMap, ) -> VendorPreview { - // The ledger load outcome reaches the preflight AS a result, so an + // The ledger load outcome reaches the refusals AS a result, so an // unreadable ledger previews as `vendor_state_unreadable` rather than // as an empty ledger. let state = load_state(cwd).await; - let refusal = - 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; + // Hosted pins matter only to the ecosystems whose refusals are scoped + // by them; discovery is skipped for any other selection. + let pins = if selected.iter().any(|p| { + ["pkg:npm/", "pkg:cargo/", "pkg:pypi/"] + .iter() + .any(|prefix| p.purl.starts_with(prefix)) + }) { + crate::commands::agent_download::hosted_pins(cwd, origins.to_vec()).await + } else { + Vec::new() + }; + let options = socket_patch_core::crawlers::CrawlerOptions { + cwd: cwd.to_path_buf(), + global: false, + global_prefix: None, + }; + let installed = crate::commands::vendor::installed_lookup(&options, None); + let refusals = vendor_refusals( + cwd, + &crate::commands::agent_download::selection_pairs(selected), + state.as_ref().map(|s| &s.entries), + &RefusalScope { + hosted_pins: &pins, + installed: &installed, + }, + ) + .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, npm_lock_refusal.is_some()).await; - let pypi_refusals = pypi_refusals(cwd, selected, origins, takeover_refusals).await; + let claimed = crate::commands::agent_download::claimed_purls(&pins); + let pypi_refusals = pypi_refusals(cwd, selected, origins, &claimed, takeover_refusals).await; let refuse = |code: &str, detail: &str| PreviewVerdict::WouldRefuse { code: code.to_string(), detail: detail.to_string(), @@ -299,24 +313,8 @@ async fn preview_vendor_with( let verdict = match lookup_entry(&state.entries, &p.purl) { // Refusal takes priority: a preserved ledger can name this // UUID even after rollback has removed its live wiring. - _ if refusal.as_ref().is_some_and(|r| r.applies_to(&p.purl)) => { - let r = refusal.as_ref().expect("checked by the guard"); - refuse(r.code, &r.detail) - } - _ if vlt_refusal_for(&vlt_refusals, &p.purl).is_some() => { - let r = vlt_refusal_for(&vlt_refusals, &p.purl).expect("checked by the guard"); - refuse(r.code, &r.detail) - } - _ if pnpm_refusals.contains_key(&p.purl) => { - let (code, detail) = &pnpm_refusals[&p.purl]; - refuse(code, 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"); - refuse(code, detail) - } - _ if lock_text_refusals.contains_key(&p.purl) => { - let (code, detail) = &lock_text_refusals[&p.purl]; + _ if refusals.contains_key(&p.purl) => { + let VendorRefusal { code, detail } = &refusals[&p.purl]; refuse(code, detail) } _ if takeover_refusals.contains_key(&p.purl) => { @@ -362,11 +360,13 @@ async fn preview_vendor_with( /// asked again, and neither is one with a live hosted pin: the wet run /// restores its upstream entry before the backend runs, so its guards /// would judge text this preview never stages (as for the takeovers of -/// every ecosystem but pnpm, see [`hosted_pnpm_refusals`]). +/// every ecosystem but pnpm, whose takeover refusal is one of +/// [`socket_patch_core::vendor::refusals::vendor_refusals`]). async fn pypi_refusals( cwd: &Path, selected: &[PatchSearchResult], origins: &[String], + claimed: &HashSet, takeover_refusals: &HashMap, ) -> HashMap { let pypi: Vec<&PatchSearchResult> = selected @@ -376,8 +376,6 @@ async fn pypi_refusals( if pypi.is_empty() { return HashMap::new(); } - let claimed = - crate::commands::agent_download::hosted_claimed_purls(cwd, origins.to_vec()).await; let candidates: Vec<(&str, &str)> = pypi .into_iter() .filter(|p| !claimed.contains(&PurlKey::new(&p.purl))) @@ -426,100 +424,6 @@ pub(crate) fn merge_vendor_envelope(env: &mut Envelope, venv: Envelope) { } } -/// 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 -} - -/// 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. 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: Vec<&PatchSearchResult> = selected - .iter() - .filter(|p| { - (p.purl.starts_with("pkg:npm/") && !npm_lock_refused) - || p.purl.starts_with("pkg:cargo/") - }) - .collect(); - if gated.is_empty() { - return HashMap::new(); - } - 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::agent_download::unclaimed_lock_text_refusals( - cwd, &gated, &claimed, state, options, None, - ) - .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`]) -/// and the ledger does not already hold at this uuid, refused by the pnpm -/// backend on the project's lock and manifest text -/// ([`socket_patch_core::vendor::pnpm_takeover_lock_text_refusals`]). The -/// wet takeover reaches the same refusal after its restore and rolls the -/// restore back, so the hosted pin stays. Discovery runs only when the -/// selection holds an npm purl and the project has a pnpm lock. -async fn hosted_pnpm_refusals( - cwd: &Path, - selected: &[PatchSearchResult], - origins: &[String], - state: &VendorState, -) -> HashMap { - let npm = |p: &&PatchSearchResult| p.purl.starts_with("pkg:npm/"); - if !selected.iter().any(|p| npm(&p)) - || tokio::fs::metadata(cwd.join("pnpm-lock.yaml")) - .await - .is_err() - { - 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(npm) - .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(); - } - socket_patch_core::vendor::pnpm_takeover_lock_text_refusals(cwd, &candidates).await -} - /// Everything the vendor step takes: the in-memory `records` to vendor /// (from [`download_patch_records_reusing`] or `get`'s download phase), the /// blob `seed` that phase fetched (so the stager fetches no view twice), diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 873e394fd..b40b86f86 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -20,6 +20,7 @@ //! CLI_CONTRACT.md "Ownership, state, and reversal". use clap::Args; +use futures_util::future::BoxFuture; use futures_util::StreamExt; use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::constants::SOCKET_DIR; @@ -36,6 +37,7 @@ use socket_patch_core::utils::group_commit::{CommittedFile, GroupCommit}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::utils::purl_key::PurlKey; use socket_patch_core::utils::socket_dir::remove_tree_and_prune; +use socket_patch_core::vendor::refusals::{RefusalScope, VendorRefusals}; use socket_patch_core::vendor::{ self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, lookup_entry_kv, save_state, save_state_shared, PackageSource, RevertOpts, RevertOutcome, VendorEntry, @@ -49,7 +51,6 @@ use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::apply::{representative_file, result_to_event, variant_matches_installed}; -use crate::commands::bun_preflight::bun_vendor_preflight_pairs; use crate::commands::lock_cli::acquire_or_emit; use crate::commands::vendor_orphans::{ orphan_label, orphan_removal_failed, orphan_removed_event, orphan_still_wired, @@ -61,7 +62,6 @@ use crate::commands::vendored_backend::{ use crate::commands::vex::{ generate_vex_from_manifest_path, generate_vex_without_manifest, ManifestlessVex, VexEmbedArgs, }; -use crate::commands::vlt_preflight::{vlt_refusal_for, vlt_vendor_preflight_pairs}; use crate::ecosystem_dispatch::{ find_packages_for_rollback_reusing, npm_paths_by_identity, npm_paths_by_identity_in, partition_purls, NpmCrawlSnapshot, @@ -2732,25 +2732,6 @@ async fn sweep_stale_artifact( ); } -pub(crate) async fn pristine_fetch_is_verifiable( - project_root: &Path, - inventory: &[lock_inventory::LockfileEntry], - purl: &str, - ledger_entry: Option<&VendorEntry>, -) -> bool { - let verifiable = - |e: &lock_inventory::LockfileEntry| e.integrity != lock_inventory::LockIntegrity::None; - if lock_inventory::lookup(inventory, purl).is_some_and(verifiable) { - return true; - } - match ledger_entry { - Some(le) => lock_inventory::recover_lock_entry(project_root, le) - .await - .is_ok_and(|e| verifiable(&e)), - None => false, - } -} - pub(crate) async fn installed_purls( options: &CrawlerOptions, purls: &[String], @@ -2779,37 +2760,13 @@ pub(crate) async fn installed_purls( installed } -pub(crate) async fn lock_refusals_reaching_backend( - cwd: &Path, - mut refused: HashMap, - ledger: &HashMap, - installed: F, -) -> HashMap -where - F: FnOnce(Vec) -> Fut, - Fut: std::future::Future>, -{ - if refused.is_empty() { - return refused; - } - let inventory = lock_inventory::inventory_project(cwd).await; - let mut unresolved: Vec = Vec::new(); - for purl in refused.keys() { - if !pristine_fetch_is_verifiable(cwd, &inventory, purl, lookup_entry(ledger, purl)).await { - unresolved.push(purl.clone()); - } - } - if unresolved.is_empty() { - return refused; - } - unresolved.sort(); - let installed = installed(unresolved.clone()).await; - for purl in unresolved { - if !installed.contains(&purl) { - refused.remove(&purl); - } - } - refused +/// [`installed_purls`] as the installed-copy lookup of a +/// [`RefusalScope`]: `prior` is scan's npm crawl, when the caller has it. +pub(crate) fn installed_lookup<'a>( + options: &'a CrawlerOptions, + prior: Option<&'a NpmCrawlSnapshot>, +) -> impl Fn(Vec) -> BoxFuture<'a, HashSet> + Send + Sync + 'a { + move |purls| Box::pin(async move { installed_purls(options, &purls, prior).await }) } enum StagedSource { @@ -2914,7 +2871,8 @@ enum CandidateVerdict<'a> { /// Several variants match a package with nothing installed to probe /// and none recorded: `vendor_variant_ambiguous`. Ambiguous, - /// A Bun, vlt or linked-vendor-dir refusal, with the engine's code and + /// A [`socket_patch_core::vendor::refusals::vendor_refusals`] or + /// linked-vendor-dir refusal, with the engine's code and /// detail. Raised before the takeover, so a live hosted pin stays. Refuse { code: &'static str, detail: String }, /// The candidate reaches its takeover and backend. `superseded`: its @@ -2936,8 +2894,10 @@ struct CandidateGates<'a> { /// Purls already failed this run (`vendor_ledger_entry_missing`); a /// sibling variant's group must not bring them back. refused: &'a HashSet, - bun_refusal: Option<&'a crate::commands::bun_preflight::BunVendorRefusal>, - vlt_refusals: &'a [(String, crate::commands::vlt_preflight::VltVendorRefusal)], + /// [`socket_patch_core::vendor::refusals::vendor_refusals`] over the + /// run's records, raised before the takeover so a live hosted pin + /// stays. + refusals: &'a VendorRefusals, /// Whether the lockfiles still pin the purl hosted. hosted_pinned: &'a dyn Fn(&str) -> bool, } @@ -3006,13 +2966,7 @@ impl<'a> CandidateGates<'a> { return CandidateVerdict::Ambiguous; } } - if let Some(refusal) = self.bun_refusal.filter(|r| r.applies_to(candidate)) { - return CandidateVerdict::Refuse { - code: refusal.code, - detail: refusal.detail.clone(), - }; - } - if let Some(refusal) = vlt_refusal_for(self.vlt_refusals, candidate) { + if let Some(refusal) = self.refusals.get(candidate) { return CandidateVerdict::Refuse { code: refusal.code, detail: refusal.detail.clone(), @@ -3358,25 +3312,6 @@ pub(crate) async fn vendor_records_reusing( let vendored_at = now_rfc3339(); - // Bun vendored preflight (see `crate::commands::bun_preflight`), run - // ONCE per run over the in-scope records and consulted per candidate - // BEFORE the hosted→vendored takeover: the takeover reverts a hosted - // purl's lockfile edits first, so a bun refusal the engine would raise - // afterwards (pre-v2 text `workspace:` lock, unsupported project) must - // be raised before it, or the purl ends up unpatched in both modes. - // A purl the ledger wires at the record's uuid is exempt. - let bun_pairs: Vec<(&str, &str)> = vendorable - .iter() - .filter_map(|p| records.get(p).map(|r| (p.as_str(), r.uuid.as_str()))) - .collect(); - let bun_refusal = bun_vendor_preflight_pairs(&common.cwd, &bun_pairs, Ok(&state.entries)).await; - // The vlt twin (see `crate::commands::vlt_preflight`): every refusal - // the vlt backend can decide from the lock, the package.json files, - // the ledger and the installed copy, consulted per candidate before - // the takeover below reverts a live hosted redirect. - let vlt_refusals = - vlt_vendor_preflight_pairs(&common.cwd, &bun_pairs, Ok(&state.entries)).await; - // Release-variant grouping (pypi `?artifact_id=`, gem `?platform=`): // the crawler emits base purls; match the manifest's qualified variants // against the installed distribution via the first-file probe. @@ -3409,15 +3344,37 @@ pub(crate) async fn vendor_records_reusing( .find(|pin| PurlKey::same(&pin.purl, purl)) }; - // 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 + // Every refusal the engine can decide before a download (see + // `vendor::refusals`), computed once per run over the in-scope records + // and consulted per candidate BEFORE the hosted→vendored takeover: the + // takeover reverts a hosted purl's lockfile edits first, so a refusal + // the backend would raise afterwards must be raised before it, or the + // purl ends up unpatched in both modes. + let refusal_pairs: Vec<(&str, &str)> = vendorable + .iter() + .filter_map(|p| records.get(p).map(|r| (p.as_str(), r.uuid.as_str()))) + .collect(); + let crawler_options = common.crawler_options(); + let installed = installed_lookup(&crawler_options, prior); + let refusals = vendor::refusals::vendor_refusals( + &common.cwd, + &refusal_pairs, + Ok(&state.entries), + &RefusalScope { + hosted_pins: &hosted_pins, + installed: &installed, + }, + ) + .await; + + // Yarn berry takeover preflight (see + // `socket_patch_core::vendor::yarn_berry_vendor_preflight`): the + // backend's project-level refusals (mixed line endings in yarn.lock or + // package.json, cacheKey, `.yarnrc.yml` compressionLevel), 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. + // hosted edits. The package-lock project refusal (#659) is one of + // `refusals`, so it is raised before the takeover too. let npm_takeover_refusal: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); let pipenv_version = tokio::sync::OnceCell::new(); @@ -3458,8 +3415,7 @@ pub(crate) async fn vendor_records_reusing( force, records, refused: &fetch_failed, - bun_refusal: bun_refusal.as_ref(), - vlt_refusals: &vlt_refusals, + refusals: &refusals, hosted_pinned: &hosted_pinned, }; // Candidates the loop refuses as `vendor_variant_ambiguous`, joined to @@ -3599,20 +3555,8 @@ pub(crate) async fn vendor_records_reusing( } if candidate.starts_with("pkg:npm/") { 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 - } - } + .get_or_init(|| { + socket_patch_core::vendor::yarn_berry_vendor_preflight(&common.cwd) }) .await .clone(); @@ -5384,23 +5328,42 @@ mod plan_gate_tests { )); } - /// Gates with no preflight refusals, no hosted pins and no `--force`. + /// Gates with no vendored refusals, no hosted pins and no `--force`. pub(super) fn gates<'a>( root: &'a Path, records: &'a HashMap, refused: &'a HashSet, ) -> CandidateGates<'a> { + static NONE: VendorRefusals = VendorRefusals::new(); CandidateGates { cwd: root, force: false, records, refused, - bun_refusal: None, - vlt_refusals: &[], + refusals: &NONE, hosted_pinned: &|_| false, } } + /// [`vendor::refusals::vendor_refusals`] over `pairs` with an empty + /// ledger and no hosted pins. + pub(super) async fn refusals_of(root: &Path, pairs: &[(&str, &str)]) -> VendorRefusals { + let empty = HashMap::new(); + let nothing_installed = |_: Vec| -> BoxFuture<'static, HashSet> { + Box::pin(async { HashSet::new() }) + }; + vendor::refusals::vendor_refusals( + root, + pairs, + Ok(&empty), + &RefusalScope { + hosted_pins: &[], + installed: ¬hing_installed, + }, + ) + .await + } + /// The download plan runs every record through its backend's own gate: /// a package the backend refuses before its first service call is /// never planned, wherever it sits in the loop order. Here the refused @@ -5537,8 +5500,51 @@ mod plan_gate_tests { /// the vlt preflight refuses it and so does the loop (#1909). The plan /// must leave it out: a grant can start a server-side build and counts /// against quota. + /// The plan judges every refusal the engine decides before a download, + /// not only the npm flavor gates: a v1 package-lock project's package + /// is never planned. + #[tokio::test] + async fn the_plan_leaves_out_a_package_a_v1_package_lock_refuses() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let purl = "pkg:npm/left-pad@1.3.0"; + std::fs::write( + root.join("package.json"), + r#"{"name":"app","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + std::fs::write( + root.join("package-lock.json"), + r#"{"name":"app","version":"1.0.0","lockfileVersion":1,"dependencies":{}}"#, + ) + .unwrap(); + let installed = root.join("node_modules/left-pad"); + std::fs::create_dir_all(&installed).unwrap(); + let all_packages = vec![(purl.to_string(), StagedSource::Installed(installed))]; + let records = HashMap::from([(purl.to_string(), record(UUID_A))]); + let refusals = refusals_of(root, &[(purl, UUID_A)]).await; + assert_eq!(refusals[purl].code, "vendor_lockfile_version_unsupported"); + let none = HashSet::new(); + let gates = CandidateGates { + refusals: &refusals, + ..gates(root, &records, &none) + }; + let plan = plan_service_downloads( + &gates, + &VendorState::default(), + &all_packages, + &HashMap::new(), + ( + &tokio::sync::OnceCell::new(), + &vendor::pypi::InstalledSiteListings::default(), + ), + ) + .await; + assert!(plan.is_empty(), "the refused package is never planned"); + } + #[tokio::test] - async fn the_plan_leaves_out_a_package_the_vlt_preflight_refuses() { + async fn the_plan_leaves_out_a_package_vlt_refuses() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let purl = "pkg:npm/left-pad@1.3.0"; @@ -5560,13 +5566,13 @@ mod plan_gate_tests { let records = HashMap::from([(purl.to_string(), record(UUID_A))]); let pairs = [(purl, UUID_A)]; let none = HashSet::new(); - let plan = |vlt_refusals: Vec<_>| { + let plan = |refusals: VendorRefusals| { let none = &none; let all_packages = &all_packages; let records = &records; async move { let gates = CandidateGates { - vlt_refusals: &vlt_refusals, + refusals: &refusals, ..gates(root, records, none) }; plan_service_downloads( @@ -5585,12 +5591,8 @@ mod plan_gate_tests { .collect::>() } }; - let empty = HashMap::new(); - std::fs::write(store.join("package.json"), r#"{"name":"left-pad"}"#).unwrap(); - let vlt = - crate::commands::vlt_preflight::vlt_vendor_preflight_pairs(root, &pairs, Ok(&empty)) - .await; + let vlt = refusals_of(root, &pairs).await; assert!(vlt.is_empty(), "{vlt:?}"); assert_eq!(plan(vlt).await, vec![UUID_A.to_string()]); @@ -5599,10 +5601,8 @@ mod plan_gate_tests { r#"{"name":"left-pad","bundleDependencies":["x"]}"#, ) .unwrap(); - let vlt = - crate::commands::vlt_preflight::vlt_vendor_preflight_pairs(root, &pairs, Ok(&empty)) - .await; - assert_eq!(vlt[0].1.code, "vendor_bundled_deps_unsupported"); + let vlt = refusals_of(root, &pairs).await; + assert_eq!(vlt[purl].code, "vendor_bundled_deps_unsupported"); assert!( plan(vlt).await.is_empty(), "the vlt-refused package is never planned" diff --git a/crates/socket-patch-cli/src/commands/vlt_preflight.rs b/crates/socket-patch-cli/src/commands/vlt_preflight.rs deleted file mode 100644 index 5e498fa5b..000000000 --- a/crates/socket-patch-cli/src/commands/vlt_preflight.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! The vlt vendored-mode preflight, the per-purl twin of -//! [`crate::commands::bun_preflight`]: every refusal the vlt backend can -//! decide from `vlt-lock.json`, the importer package.json files, the vendor -//! ledger and the installed store copy, evaluated read-only before any -//! `/patches/view/` fetch, any write and any hosted→vendored takeover. -//! -//! Callers: `vendor` ([`crate::commands::vendor::vendor_records`], before -//! the takeover reverts a live hosted redirect), `scan`/`get --mode -//! vendored` (the download phase and the dry-run preview's -//! `would_refuse`) and `get --mode vendored`. Agent mode never runs -//! it. After it passes, only network and build failures remain for the -//! engine. - -use std::path::Path; - -use socket_patch_core::api::types::PatchSearchResult; -use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::vendor::npm_flavor::{vlt_flavor_change_refusal, vlt_routes}; -use socket_patch_core::vendor::vlt_lock::vlt_vendor_preflight; - -use crate::commands::bun_preflight::LedgerLoad; - -/// A purl the vlt backend would refuse: the engine's code and detail. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct VltVendorRefusal { - pub(crate) code: &'static str, - pub(crate) detail: String, -} - -/// The refusal (if any) the preflight recorded for `purl`. -pub(crate) fn vlt_refusal_for<'a>( - refusals: &'a [(String, VltVendorRefusal)], - purl: &str, -) -> Option<&'a VltVendorRefusal> { - refusals - .iter() - .find_map(|(p, refusal)| (p == purl).then_some(refusal)) -} - -/// Run the preflight for `(purl, uuid)` pairs. Only npm purls of a project -/// the router sends to vlt are judged; every other pair passes. The order -/// matches the engine's: an unreadable ledger, the lock sniff, the flavor -/// guard, then the core target analysis, store copy and gitignore checks. -pub(crate) async fn vlt_vendor_preflight_pairs( - project_root: &Path, - pairs: &[(&str, &str)], - ledger: LedgerLoad<'_>, -) -> Vec<(String, VltVendorRefusal)> { - let npm: Vec<&(&str, &str)> = pairs - .iter() - .filter(|(purl, _)| Ecosystem::from_purl(purl) == Some(Ecosystem::Npm)) - .collect(); - if npm.is_empty() { - return Vec::new(); - } - let Some(route) = vlt_routes(project_root).await else { - return Vec::new(); - }; - let mut out = Vec::new(); - for (purl, uuid) in npm { - let (code, detail) = match (&route, ledger) { - (_, Err(e)) => ("vendor_state_unreadable", e.to_string()), - (Err((code, detail)), Ok(_)) => (*code, detail.clone()), - (Ok(()), Ok(entries)) => match vlt_flavor_change_refusal(entries, purl) { - Some(detail) => ("vendor_flavor_changed", detail), - None => match vlt_vendor_preflight(project_root, purl, uuid).await { - Ok(()) => continue, - Err(refusal) => refusal, - }, - }, - }; - out.push(((*purl).to_string(), VltVendorRefusal { code, detail })); - } - out -} - -/// [`vlt_vendor_preflight_pairs`] over search results (the `scan`/`get` -/// selection). -pub(crate) async fn vlt_vendor_preflight_selected( - project_root: &Path, - selected: &[PatchSearchResult], - ledger: LedgerLoad<'_>, -) -> Vec<(String, VltVendorRefusal)> { - let pairs: Vec<(&str, &str)> = selected - .iter() - .map(|s| (s.purl.as_str(), s.uuid.as_str())) - .collect(); - vlt_vendor_preflight_pairs(project_root, &pairs, ledger).await -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use socket_patch_core::vendor::state::VendorEntry; - - use super::*; - - const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; - const PURL: &str = "pkg:npm/left-pad@1.3.0"; - - fn lock(nodes: &[&str], edges: &[&str]) -> String { - let block = |entries: &[&str]| { - let lines: Vec = entries - .iter() - .enumerate() - .map(|(i, e)| format!(" {e}{}", if i + 1 < entries.len() { "," } else { "" })) - .collect(); - format!("{{\n{}\n }}", lines.join("\n")) - }; - format!( - "{{\n \"lockfileVersion\": 1,\n \"options\": {{}},\n \"nodes\": {},\n \"edges\": {}\n}}\n", - block(nodes), - block(edges) - ) - } - - fn direct_lock() -> String { - lock( - &[r#""~npm~left-pad@1.3.0": [0,"left-pad","sha512-REG=="]"#], - &[r#""file~_d left-pad": "prod 1.3.0 ~npm~left-pad@1.3.0""#], - ) - } - - fn project(lock: &str, pkg: &str) -> tempfile::TempDir { - let tmp = tempfile::tempdir().unwrap(); - std::fs::write(tmp.path().join("vlt-lock.json"), lock).unwrap(); - std::fs::write(tmp.path().join("package.json"), pkg).unwrap(); - tmp - } - - const PKG: &str = r#"{"dependencies":{"left-pad":"1.3.0"}}"#; - - fn entry(flavor: Option<&str>) -> VendorEntry { - serde_json::from_value(serde_json::json!({ - "ecosystem": "npm", - "basePurl": PURL, - "uuid": UUID, - "artifact": { "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") }, - "wiring": [], - "flavor": flavor, - })) - .unwrap() - } - - async fn run(root: &Path, ledger: LedgerLoad<'_>) -> Vec<(String, VltVendorRefusal)> { - vlt_vendor_preflight_pairs(root, &[(PURL, UUID), ("pkg:pypi/x@1.0.0", UUID)], ledger).await - } - - #[tokio::test] - async fn a_direct_dependency_passes_and_other_projects_are_never_judged() { - let empty = HashMap::new(); - let tmp = project(&direct_lock(), PKG); - assert!(run(tmp.path(), Ok(&empty)).await.is_empty()); - - let npm = tempfile::tempdir().unwrap(); - std::fs::write(npm.path().join("package-lock.json"), "{}").unwrap(); - assert!(run(npm.path(), Ok(&empty)).await.is_empty()); - - let pnp = project("not json", PKG); - std::fs::write(pnp.path().join(".pnp.cjs"), "").unwrap(); - assert!( - run(pnp.path(), Ok(&empty)).await.is_empty(), - "the PnP refusal is not vlt's" - ); - } - - #[tokio::test] - async fn every_lock_decidable_refusal_is_reported_per_npm_purl() { - let empty = HashMap::new(); - let cases = [ - ( - "\u{feff}{}".to_string(), - PKG, - "vendor_lockfile_version_unsupported", - ), - ( - direct_lock().replace("\"lockfileVersion\": 1", "\"lockfileVersion\": 2"), - PKG, - "vendor_lockfile_version_unsupported", - ), - ( - direct_lock().replace(" \"~npm", " \"~npm"), - PKG, - "vendor_lockfile_version_unsupported", - ), - ( - direct_lock(), - r#"{"dependencies":{"left-pad":"^1.3.0"}}"#, - "vendor_vlt_lock_out_of_sync", - ), - ( - direct_lock(), - r#"{"dependencies":{"left-pad":"1.3.0"},"devDependencies":{"left-pad":"1.3.0"}}"#, - "vendor_lock_entry_unsupported", - ), - ( - lock( - &[ - r#""~npm~has@1.0.0": [0,"has","sha512-H=="]"#, - r#""~npm~left-pad@1.3.0": [0,"left-pad","sha512-REG=="]"#, - ], - &[ - r#""file~_d has": "prod 1.0.0 ~npm~has@1.0.0""#, - r#""~npm~has@1.0.0 left-pad": "prod 1.3.0 ~npm~left-pad@1.3.0""#, - ], - ), - r#"{"dependencies":{"has":"1.0.0"}}"#, - "vendor_vlt_transitive_unsupported", - ), - ]; - for (lock, pkg, code) in cases { - let tmp = project(&lock, pkg); - let refusals = run(tmp.path(), Ok(&empty)).await; - assert_eq!(refusals.len(), 1, "{code}: {refusals:?}"); - let refusal = vlt_refusal_for(&refusals, PURL).unwrap(); - assert_eq!(refusal.code, code, "{}", refusal.detail); - assert!(vlt_refusal_for(&refusals, "pkg:pypi/x@1.0.0").is_none()); - } - } - - #[tokio::test] - async fn the_ledger_decides_before_the_lock() { - let tmp = project(&direct_lock(), PKG); - let err = std::io::Error::other("corrupt state.json: synthetic"); - let refusals = run(tmp.path(), Err(&err)).await; - assert_eq!(refusals[0].1.code, "vendor_state_unreadable"); - assert_eq!(refusals[0].1.detail, "corrupt state.json: synthetic"); - - let pnpm = HashMap::from([(PURL.to_string(), entry(Some("pnpm")))]); - let refusals = run(tmp.path(), Ok(&pnpm)).await; - assert_eq!(refusals[0].1.code, "vendor_flavor_changed"); - assert!(refusals[0].1.detail.contains("`pnpm`"), "{refusals:?}"); - - let vlt = HashMap::from([(PURL.to_string(), entry(Some("vlt")))]); - assert!(run(tmp.path(), Ok(&vlt)).await.is_empty()); - } - - #[tokio::test] - async fn the_installed_store_copy_is_checked() { - let tmp = project(&direct_lock(), PKG); - let store = tmp - .path() - .join("node_modules/.vlt/~npm~left-pad@1.3.0/node_modules/left-pad"); - std::fs::create_dir_all(&store).unwrap(); - let empty = HashMap::new(); - for (pkg, code) in [ - ( - r#"{"name":"left-pad","bundleDependencies":["x"]}"#, - "vendor_bundled_deps_unsupported", - ), - ( - r#"{"name":"left-pad","devDependencies":{},"devDependencies":{}}"#, - "vendor_lock_entry_unsupported", - ), - ] { - std::fs::write(store.join("package.json"), pkg).unwrap(); - let refusals = run(tmp.path(), Ok(&empty)).await; - assert_eq!(refusals[0].1.code, code, "{refusals:?}"); - } - } - - #[tokio::test] - async fn a_root_socket_ignore_refuses_before_any_write() { - let Some(git) = socket_patch_core::utils::process::resolve_tool("git") else { - eprintln!("git not found; skipping"); - return; - }; - let tmp = project(&direct_lock(), PKG); - let status = std::process::Command::new(git) - .args(["init", "-q"]) - .current_dir(tmp.path()) - .status() - .unwrap(); - assert!(status.success()); - let empty = HashMap::new(); - std::fs::write(tmp.path().join(".gitignore"), "dist/\nnode_modules\n").unwrap(); - assert!(run(tmp.path(), Ok(&empty)).await.is_empty()); - std::fs::write(tmp.path().join(".gitignore"), ".socket/\n").unwrap(); - let refusals = run(tmp.path(), Ok(&empty)).await; - assert_eq!(refusals[0].1.code, "vendor_artifact_gitignored"); - assert!( - refusals[0].1.detail.contains(".gitignore:1:.socket/"), - "{refusals:?}" - ); - assert!(!tmp.path().join(".socket").exists()); - } -} 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 index a8605eaea..8452ec691 100644 --- 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 @@ -349,6 +349,52 @@ async fn get_vendored_over_hosted_v1_package_lock_keeps_the_hosted_pin() { assert_still_hosted(root, "package-lock.json", &lock, &npmrc); } +/// A v1 lock with no hosted pin: `get --mode vendored` raises the +/// backend's refusal before the engine, with the uuid path's pre-record +/// envelope (`status: "error"`), so the run writes nothing at all. +#[tokio::test(flavor = "multi_thread")] +async fn get_uuid_vendored_over_unhosted_v1_lock_refuses_before_any_write() { + let server = MockServer::start().await; + mock_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_npm_project(root, "package-lock.json", 1); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + + let fixture = prebuilt_common::Server::view(patch_view()); + let api = server.uri(); + let args = [ + "get", + UUID, + "--mode", + "vendored", + "--json", + "--yes", + "--api-url", + &api, + "--api-token", + "fake", + "--org", + ORG, + "--cwd", + root.to_str().unwrap(), + "--vendor-url", + &fixture.uri, + ]; + let (code, env) = run_json(root, &api, &args); + assert_eq!(code, 1, "{env:#}"); + assert_eq!(env["status"], "error", "{env:#}"); + assert_eq!(env["error"]["code"], V1_CODE, "{env:#}"); + assert!( + !root.join(".socket").exists(), + "nothing may be written: {env:#}" + ); + assert_eq!( + std::fs::read_to_string(root.join("package-lock.json")).unwrap(), + lock + ); +} + /// #659: the `npm-shrinkwrap.json` v1 variant. #[tokio::test(flavor = "multi_thread")] async fn scan_vendored_over_hosted_v1_shrinkwrap_keeps_the_hosted_pin() { diff --git a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs index 26695e7a9..e2f7ea761 100644 --- a/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs +++ b/crates/socket-patch-cli/tests/manifest_path_ledger_root.rs @@ -398,7 +398,6 @@ const ENGINE_FILES: &[&str] = &[ "commands/vendor.rs", "commands/scan/vendor_flow.rs", "commands/scan/hosted.rs", - "commands/bun_preflight.rs", "commands/vlt_heal.rs", ]; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index dd732ca7f..3aaa2b11e 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -99,6 +99,7 @@ pub(crate) mod pypi_requirements; mod pypi_uv; mod pypi_wheel; pub mod redownload; +pub mod refusals; pub mod registry_fetch; pub(crate) mod reuse; pub(crate) mod revert; diff --git a/crates/socket-patch-core/src/vendor/refusals.rs b/crates/socket-patch-core/src/vendor/refusals.rs new file mode 100644 index 000000000..acd529e27 --- /dev/null +++ b/crates/socket-patch-core/src/vendor/refusals.rs @@ -0,0 +1,930 @@ +//! The vendored-mode refusals a run can decide before it downloads a patch +//! or writes anything: the one answer every vendored entry point reads. +//! `scan --mode vendored` (download phase, dry-run preview and rollout +//! plan), `get … --mode vendored` (both identifier paths) and the `vendor` +//! command's record loop (before the hosted→vendored takeover reverts a +//! live pin) all call [`vendor_refusals`], so a package the engine would +//! refuse is refused everywhere with the engine's own code and detail, and +//! never has its view fetched, its download granted or its hosted pin +//! stripped. +//! +//! The checks run in the engine's order, the first one that refuses a purl +//! winning: +//! +//! 1. the npm flavor gate the router picks: Bun's project refusal (with its +//! already-vendored exemption) or vlt's per-purl refusals, either one +//! naming an unreadable ledger as `vendor_state_unreadable`; +//! 2. the npm package-lock project refusal (a lock that is not v2/v3); +//! 3. the lock-text refusals a hosted pnpm pin's takeover meets (#853); +//! 4. the lock-text refusals of every other npm and cargo candidate, the +//! ones the backend raises on the lock and manifest text alone. +//! +//! Each check lives in its backend's module; this one only dispatches and +//! scopes them. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::Path; + +use futures_util::future::BoxFuture; + +use crate::crawlers::Ecosystem; +use crate::patch::redirect::upstream::HostedPin; +use crate::utils::purl_key::PurlKey; +use crate::vendor::state::{lookup_entry, VendorEntry}; +use crate::vendor::{bun_lock, lock_inventory, npm_flavor, npm_lock, vlt_lock}; + +/// The vendor ledger as the refusals consume it: the caller's own load +/// outcome, so an UNREADABLE ledger is a refusal the run can report +/// instead of a silently empty ledger. +pub type LedgerLoad<'a> = Result<&'a HashMap, &'a std::io::Error>; + +/// A purl the engine would refuse: its stable code and detail. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorRefusal { + pub code: &'static str, + pub detail: String, +} + +/// The refusals of one run, keyed by the candidate purl as given. +pub type VendorRefusals = BTreeMap; + +/// Which of `purls` have an installed copy on disk (the caller's crawl). +pub type InstalledLookup<'a> = + dyn Fn(Vec) -> BoxFuture<'a, HashSet> + Send + Sync + 'a; + +/// What the scoping of the lock-text checks needs from the caller. +pub struct RefusalScope<'a, 'b> { + /// The project's live hosted pins: vendoring one of their purls is a + /// hosted→vendored takeover, which restores the upstream lock entry + /// before the backend reads it. + pub hosted_pins: &'a [HostedPin], + /// Asked only for lock-text refused purls the lockfile does not + /// resolve to a verifiable registry source: such a purl reaches its + /// backend only when it is installed. + pub installed: &'a InstalledLookup<'b>, +} + +fn is_npm(purl: &str) -> bool { + Ecosystem::from_purl(purl) == Some(Ecosystem::Npm) +} + +fn is_cargo(purl: &str) -> bool { + Ecosystem::from_purl(purl) == Some(Ecosystem::Cargo) +} + +/// Every refusal the vendor engine can decide for `candidates` (`(purl, +/// uuid)` pairs) before any download, in the engine's order (see the +/// module docs). Read-only; a candidate absent from the result is left to +/// the engine. +pub async fn vendor_refusals( + project_root: &Path, + candidates: &[(&str, &str)], + ledger: LedgerLoad<'_>, + scope: &RefusalScope<'_, '_>, +) -> VendorRefusals { + let mut out = VendorRefusals::new(); + let npm: Vec<(&str, &str)> = candidates + .iter() + .filter(|(purl, _)| is_npm(purl)) + .copied() + .collect(); + if !npm.is_empty() { + npm_flavor_refusals(project_root, &npm, ledger, &mut out).await; + if let Some((code, detail)) = npm_lock::npm_lock_vendor_preflight(project_root).await { + for (purl, _) in &npm { + refuse(&mut out, purl, code, &detail); + } + } + } + let empty = HashMap::new(); + let entries = ledger.unwrap_or(&empty); + let claimed: HashSet = scope + .hosted_pins + .iter() + .map(|pin| PurlKey::new(&pin.purl)) + .collect(); + let open = |purl: &str| !out.contains_key(purl); + + let takeovers: Vec<(&str, &str)> = npm + .iter() + .filter(|(purl, uuid)| { + open(purl) + && claimed.contains(&PurlKey::new(purl)) + && lookup_entry(entries, purl).is_none_or(|e| e.uuid != *uuid) + }) + .copied() + .collect(); + let takeover_refusals = if takeovers.is_empty() + || tokio::fs::metadata(project_root.join("pnpm-lock.yaml")) + .await + .is_err() + { + HashMap::new() + } else { + super::pnpm_takeover_lock_text_refusals(project_root, &takeovers).await + }; + + let gated: Vec<(&str, &str)> = candidates + .iter() + .filter(|(purl, uuid)| { + (is_npm(purl) || is_cargo(purl)) + && open(purl) + && !claimed.contains(&PurlKey::new(purl)) + && !lookup_entry(entries, purl).is_some_and(|e| e.detached && e.uuid == *uuid) + }) + .copied() + .collect(); + let lock_text = if gated.is_empty() { + HashMap::new() + } else { + let refused = super::lock_text_refusals(project_root, &gated).await; + reaching_backend(project_root, refused, entries, scope.installed).await + }; + + for (purl, (code, detail)) in takeover_refusals.into_iter().chain(lock_text) { + refuse(&mut out, &purl, code, &detail); + } + out +} + +fn refuse(out: &mut VendorRefusals, purl: &str, code: &'static str, detail: &str) { + out.entry(purl.to_string()) + .or_insert_with(|| VendorRefusal { + code, + detail: detail.to_string(), + }); +} + +/// The npm flavor gate the router picks for this project. vlt wins the +/// router (DESIGN D2): a Bun lock beside `vlt-lock.json` is never the one +/// vendoring rewires, so a vlt-routed project is judged per purl by the vlt +/// backend's read-only checks, and any other project by Bun's +/// project-level refusal. +async fn npm_flavor_refusals( + project_root: &Path, + npm: &[(&str, &str)], + ledger: LedgerLoad<'_>, + out: &mut VendorRefusals, +) { + match npm_flavor::vlt_routes(project_root).await { + Some(route) => vlt_refusals(project_root, npm, ledger, route, out).await, + None => bun_refusals(project_root, npm, ledger, out).await, + } +} + +/// vlt's refusals, per npm purl, in the backend's order: an unreadable +/// ledger, the lock sniff, the flavor guard, then the target analysis, +/// installed store copy and gitignore checks. +async fn vlt_refusals( + project_root: &Path, + npm: &[(&str, &str)], + ledger: LedgerLoad<'_>, + route: Result<(), (&'static str, String)>, + out: &mut VendorRefusals, +) { + for (purl, uuid) in npm { + let (code, detail) = match (&route, ledger) { + (_, Err(e)) => ("vendor_state_unreadable", e.to_string()), + (Err((code, detail)), Ok(_)) => (*code, detail.clone()), + (Ok(()), Ok(entries)) => match npm_flavor::vlt_flavor_change_refusal(entries, purl) { + Some(detail) => ("vendor_flavor_changed", detail), + None => match vlt_lock::vlt_vendor_preflight(project_root, purl, uuid).await { + Ok(()) => continue, + Err(refusal) => refusal, + }, + }, + }; + refuse(out, purl, code, &detail); + } +} + +/// Bun's project-level refusal (a malformed binary `bun.lockb`, an +/// unreadable lock, an unsupported `lockfileVersion`, a pre-version-2 +/// `workspace:` lock) applied to every npm purl, less the exempt ones. +/// +/// A purl is exempt only from the workspace gate, and only when `bun.lock` +/// wires every instance of its `name@version` to one of our +/// `.socket/vendor/npm/` tuples, at any uuid +/// ([`bun_lock::wired_instances_all_ours`]): updating an already-local +/// tuple introduces no new relative path, so in-sync runs, superseding +/// patches and repairs stay supported. A matching ledger uuid alone is not +/// enough, since `rollback --preserve-state` keeps the entry after removing +/// its wiring. Every other code means `bun.lock` could not be read or +/// parsed, so nothing in it can be ours. +/// +/// An unreadable ledger exempts nothing (fail closed) and becomes the +/// refusal itself, `vendor_state_unreadable` with the io/parse detail, so +/// the one refusal the run emits names the real problem rather than send +/// the operator off to re-lock `bun.lock`. +async fn bun_refusals( + project_root: &Path, + npm: &[(&str, &str)], + ledger: LedgerLoad<'_>, + out: &mut VendorRefusals, +) { + let Err((code, detail)) = bun_lock::preflight_vendor(project_root).await else { + return; + }; + if let Err(e) = ledger { + for (purl, _) in npm { + refuse(out, purl, "vendor_state_unreadable", &e.to_string()); + } + return; + } + for (purl, _) in npm { + let exempt = code == "vendor_bun_workspace_unsupported" + && bun_lock::wired_instances_all_ours(project_root, purl) + .await + .unwrap_or(false); + if !exempt { + refuse(out, purl, code, &detail); + } + } +} + +/// Keep only the lock-text refusals of packages the vendor loop would hand +/// to their backend: one the lockfile (or the ledger's recorded original) +/// resolves to a verifiable registry source, or one installed on disk. A +/// package with neither, absent from the lock and not installed, never +/// reaches its backend: the loop reports it `skipped` / +/// `package_not_installed`, and so it still does. +async fn reaching_backend( + project_root: &Path, + mut refused: HashMap, + ledger: &HashMap, + installed: &InstalledLookup<'_>, +) -> HashMap { + if refused.is_empty() { + return refused; + } + let inventory = lock_inventory::inventory_project(project_root).await; + let mut unresolved: Vec = Vec::new(); + for purl in refused.keys() { + if !pristine_fetch_is_verifiable(project_root, &inventory, purl, lookup_entry(ledger, purl)) + .await + { + unresolved.push(purl.clone()); + } + } + if unresolved.is_empty() { + return refused; + } + unresolved.sort(); + let installed = installed(unresolved.clone()).await; + for purl in unresolved { + if !installed.contains(&purl) { + refused.remove(&purl); + } + } + refused +} + +/// Whether the backend can fetch `purl`'s pristine source and verify it: +/// the lockfile pins it with an integrity, or the ledger's recorded +/// original entry does. +pub async fn pristine_fetch_is_verifiable( + project_root: &Path, + inventory: &[lock_inventory::LockfileEntry], + purl: &str, + ledger_entry: Option<&VendorEntry>, +) -> bool { + let verifiable = + |e: &lock_inventory::LockfileEntry| e.integrity != lock_inventory::LockIntegrity::None; + if lock_inventory::lookup(inventory, purl).is_some_and(verifiable) { + return true; + } + match ledger_entry { + Some(le) => lock_inventory::recover_lock_entry(project_root, le) + .await + .is_ok_and(|e| verifiable(&e)), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::load_state; + + const UUID: &str = "22222222-2222-4222-8222-222222222222"; + const OTHER_UUID: &str = "33333333-3333-4333-8333-333333333333"; + + fn nothing_installed(_: Vec) -> BoxFuture<'static, HashSet> { + Box::pin(async { HashSet::new() }) + } + + async fn run_scoped( + root: &Path, + pairs: &[(&str, &str)], + ledger: LedgerLoad<'_>, + pins: &[HostedPin], + installed: &InstalledLookup<'_>, + ) -> VendorRefusals { + vendor_refusals( + root, + pairs, + ledger, + &RefusalScope { + hosted_pins: pins, + installed, + }, + ) + .await + } + + async fn run(root: &Path, pairs: &[(&str, &str)], ledger: LedgerLoad<'_>) -> VendorRefusals { + run_scoped(root, pairs, ledger, &[], ¬hing_installed).await + } + + /// The refusals over the ledger as it reads on disk now. + async fn run_on_disk(root: &Path, pairs: &[(&str, &str)]) -> VendorRefusals { + let state = load_state(root).await; + run(root, pairs, state.as_ref().map(|s| &s.entries)).await + } + + fn entry(json: serde_json::Value) -> VendorEntry { + serde_json::from_value(json).unwrap() + } + + mod bun { + use super::*; + + const PURL: &str = "pkg:npm/covgap-bun@1.0.0"; + const PYPI: &str = "pkg:pypi/only@1.0.0"; + + /// A real bun 1.3.14 lockfileVersion-1 workspace lock (matrix capture + /// grammar) resolving `covgap-bun@1.0.0` from the registry. + const BUN_V1_WORKSPACE_LOCK: &str = r#"{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "unit-fixture", + "dependencies": { + "consumer": "workspace:*", + }, + }, + "packages/consumer": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "covgap-bun": "1.0.0", + }, + }, + }, + "packages": { + "consumer": ["consumer@workspace:packages/consumer"], + + "covgap-bun": ["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="], + } +} +"#; + + fn seed_vendor_entry(root: &Path, uuid: &str) { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).unwrap(); + std::fs::write( + vendor.join("state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "entries": { PURL: { + "ecosystem": "npm", + "basePurl": PURL, + "uuid": uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz"), + }, + "wiring": [], + "flavor": "bun", + }} + })) + .unwrap(), + ) + .unwrap(); + } + + /// The lock as it reads once `covgap-bun` is vendored at `uuid`. + fn vendored_lock(uuid: &str) -> String { + BUN_V1_WORKSPACE_LOCK.replace( + r#"["covgap-bun@1.0.0", "", {}, "sha512-AAAA=="]"#, + &format!( + r#"["covgap-bun@.socket/vendor/npm/{uuid}/covgap-bun-1.0.0.tgz", {{}}, "sha512-OURS=="]"# + ), + ) + } + + /// Nothing selected is npm: the Bun lock is never judged. A ledger + /// alone never exempts; an unreadable ledger exempts nothing (fail + /// closed) AND is reported as the real problem + /// (`vendor_state_unreadable`), never as a Bun lock remedy. + #[tokio::test] + async fn scope_and_corrupt_ledger() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let pypi = [( + "pkg:pypi/only@1.0.0", + "11111111-1111-4111-8111-111111111111", + )]; + assert!(run_on_disk(tmp.path(), &pypi).await.is_empty()); + + let npm = [(PURL, UUID), (PYPI, UUID)]; + let refusals = run_on_disk(tmp.path(), &npm).await; + assert_eq!(refusals[PURL].code, "vendor_bun_lockb_invalid"); + assert!(!refusals.contains_key(PYPI)); + + // A ledger at this UUID cannot make a malformed binary lock vendorable. + seed_vendor_entry(tmp.path(), UUID); + let refusals = run_on_disk(tmp.path(), &npm).await; + assert_eq!(refusals[PURL].code, "vendor_bun_lockb_invalid"); + + // …but a corrupt ledger exempts nothing and names itself. + std::fs::write(tmp.path().join(".socket/vendor/state.json"), b"{ not json").unwrap(); + let refusals = run_on_disk(tmp.path(), &npm).await; + let refusal = &refusals[PURL]; + assert_eq!( + refusal.code, "vendor_state_unreadable", + "{}", + refusal.detail + ); + assert!(refusal.detail.contains("state.json"), "{}", refusal.detail); + let err = std::io::Error::other("corrupt state.json: synthetic"); + let refusals = run(tmp.path(), &npm, Err(&err)).await; + assert_eq!(refusals[PURL].code, "vendor_state_unreadable"); + assert_eq!(refusals[PURL].detail, "corrupt state.json: synthetic"); + } + + /// vlt wins the router: a Bun lock beside `vlt-lock.json`, readable + /// or not, is never judged by Bun's refusal. + #[tokio::test] + async fn a_vlt_lock_takes_the_project_out_of_bun_scope() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); + let npm = [(PURL, UUID)]; + assert_eq!( + run_on_disk(tmp.path(), &npm).await[PURL].code, + "vendor_bun_lockb_invalid" + ); + for vlt in ["{\n \"lockfileVersion\": 1\n}\n", "\u{feff}{}"] { + std::fs::write(tmp.path().join("vlt-lock.json"), vlt).unwrap(); + let refusals = run_on_disk(tmp.path(), &npm).await; + assert!( + refusals + .get(PURL) + .is_none_or(|r| !r.code.starts_with("vendor_bun_")), + "{vlt:?}: {refusals:?}" + ); + } + } + + /// The lock-derived exemption: on a pre-v2 workspace lock whose every + /// instance of the purl is already ours, a SUPERSEDING uuid (ledger at + /// the old uuid) and a WIPED ledger are both exempt — the engine + /// re-vendors / stays in sync — while a fresh registry instance is + /// refused whatever the ledger says about other purls. + #[tokio::test] + async fn lock_derived_exemption_matches_the_engine_gate() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); + let fresh = [(PURL, UUID)]; + let refusals = run_on_disk(tmp.path(), &fresh).await; + assert_eq!(refusals[PURL].code, "vendor_bun_workspace_unsupported"); + + // A preserved ledger does not make registry wiring exempt. + seed_vendor_entry(tmp.path(), UUID); + assert!(run_on_disk(tmp.path(), &fresh).await.contains_key(PURL)); + + // Live vendored tuples remain exempt. + std::fs::write(tmp.path().join("bun.lock"), vendored_lock(UUID)).unwrap(); + assert!( + run_on_disk(tmp.path(), &fresh).await.is_empty(), + "in-sync re-run is exempt" + ); + + // Superseding uuid: the ledger disagrees, the lock says ours → exempt. + assert!( + run_on_disk(tmp.path(), &[(PURL, OTHER_UUID)]) + .await + .is_empty(), + "a patch update on an already-vendored purl must not be refused" + ); + + // Wiped ledger: no entry at all, the lock alone exempts. + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + assert!( + run_on_disk(tmp.path(), &fresh).await.is_empty(), + "a lost ledger must not turn an in-sync project into a refusal" + ); + + // A different, still-registry purl in the same lock stays refused + // and a non-npm purl is never in scope. + let others = [ + (PURL, OTHER_UUID), + ("pkg:npm/other@2.0.0", OTHER_UUID), + ("pkg:pypi/x@1.0.0", OTHER_UUID), + ]; + let refusals = run_on_disk(tmp.path(), &others).await; + assert!(!refusals.contains_key(PURL)); + assert_eq!( + refusals["pkg:npm/other@2.0.0"].code, + "vendor_bun_workspace_unsupported" + ); + assert!(!refusals.contains_key("pkg:pypi/x@1.0.0")); + } + } + + mod vlt { + use super::*; + + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PURL: &str = "pkg:npm/left-pad@1.3.0"; + const PYPI: &str = "pkg:pypi/x@1.0.0"; + const PKG: &str = r#"{"dependencies":{"left-pad":"1.3.0"}}"#; + + fn lock(nodes: &[&str], edges: &[&str]) -> String { + let block = |entries: &[&str]| { + let lines: Vec = entries + .iter() + .enumerate() + .map(|(i, e)| { + format!(" {e}{}", if i + 1 < entries.len() { "," } else { "" }) + }) + .collect(); + format!("{{\n{}\n }}", lines.join("\n")) + }; + format!( + "{{\n \"lockfileVersion\": 1,\n \"options\": {{}},\n \"nodes\": {},\n \"edges\": {}\n}}\n", + block(nodes), + block(edges) + ) + } + + fn direct_lock() -> String { + lock( + &[r#""~npm~left-pad@1.3.0": [0,"left-pad","sha512-REG=="]"#], + &[r#""file~_d left-pad": "prod 1.3.0 ~npm~left-pad@1.3.0""#], + ) + } + + fn project(lock: &str, pkg: &str) -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("vlt-lock.json"), lock).unwrap(); + std::fs::write(tmp.path().join("package.json"), pkg).unwrap(); + tmp + } + + fn flavored(flavor: Option<&str>) -> VendorEntry { + entry(serde_json::json!({ + "ecosystem": "npm", + "basePurl": PURL, + "uuid": UUID, + "artifact": { "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") }, + "wiring": [], + "flavor": flavor, + })) + } + + async fn judge(root: &Path, ledger: LedgerLoad<'_>) -> VendorRefusals { + run(root, &[(PURL, UUID), (PYPI, UUID)], ledger).await + } + + #[tokio::test] + async fn a_direct_dependency_passes_and_other_projects_are_never_judged() { + let empty = HashMap::new(); + let tmp = project(&direct_lock(), PKG); + assert!(judge(tmp.path(), Ok(&empty)).await.is_empty()); + + let pnp = project("not json", PKG); + std::fs::write(pnp.path().join(".pnp.cjs"), "").unwrap(); + assert!( + judge(pnp.path(), Ok(&empty)).await.is_empty(), + "the PnP refusal is not vlt's" + ); + } + + #[tokio::test] + async fn every_lock_decidable_refusal_is_reported_per_npm_purl() { + let empty = HashMap::new(); + let cases = [ + ( + "\u{feff}{}".to_string(), + PKG, + "vendor_lockfile_version_unsupported", + ), + ( + direct_lock().replace("\"lockfileVersion\": 1", "\"lockfileVersion\": 2"), + PKG, + "vendor_lockfile_version_unsupported", + ), + ( + direct_lock().replace(" \"~npm", " \"~npm"), + PKG, + "vendor_lockfile_version_unsupported", + ), + ( + direct_lock(), + r#"{"dependencies":{"left-pad":"^1.3.0"}}"#, + "vendor_vlt_lock_out_of_sync", + ), + ( + direct_lock(), + r#"{"dependencies":{"left-pad":"1.3.0"},"devDependencies":{"left-pad":"1.3.0"}}"#, + "vendor_lock_entry_unsupported", + ), + ( + lock( + &[ + r#""~npm~has@1.0.0": [0,"has","sha512-H=="]"#, + r#""~npm~left-pad@1.3.0": [0,"left-pad","sha512-REG=="]"#, + ], + &[ + r#""file~_d has": "prod 1.0.0 ~npm~has@1.0.0""#, + r#""~npm~has@1.0.0 left-pad": "prod 1.3.0 ~npm~left-pad@1.3.0""#, + ], + ), + r#"{"dependencies":{"has":"1.0.0"}}"#, + "vendor_vlt_transitive_unsupported", + ), + ]; + for (lock, pkg, code) in cases { + let tmp = project(&lock, pkg); + let refusals = judge(tmp.path(), Ok(&empty)).await; + assert_eq!(refusals.len(), 1, "{code}: {refusals:?}"); + assert_eq!(refusals[PURL].code, code, "{}", refusals[PURL].detail); + } + } + + #[tokio::test] + async fn the_ledger_decides_before_the_lock() { + let tmp = project(&direct_lock(), PKG); + let err = std::io::Error::other("corrupt state.json: synthetic"); + let refusals = judge(tmp.path(), Err(&err)).await; + assert_eq!(refusals[PURL].code, "vendor_state_unreadable"); + assert_eq!(refusals[PURL].detail, "corrupt state.json: synthetic"); + + let pnpm = HashMap::from([(PURL.to_string(), flavored(Some("pnpm")))]); + let refusals = judge(tmp.path(), Ok(&pnpm)).await; + assert_eq!(refusals[PURL].code, "vendor_flavor_changed"); + assert!(refusals[PURL].detail.contains("`pnpm`"), "{refusals:?}"); + + let vlt = HashMap::from([(PURL.to_string(), flavored(Some("vlt")))]); + assert!(judge(tmp.path(), Ok(&vlt)).await.is_empty()); + } + + #[tokio::test] + async fn the_installed_store_copy_is_checked() { + let tmp = project(&direct_lock(), PKG); + let store = tmp + .path() + .join("node_modules/.vlt/~npm~left-pad@1.3.0/node_modules/left-pad"); + std::fs::create_dir_all(&store).unwrap(); + let empty = HashMap::new(); + for (pkg, code) in [ + ( + r#"{"name":"left-pad","bundleDependencies":["x"]}"#, + "vendor_bundled_deps_unsupported", + ), + ( + r#"{"name":"left-pad","devDependencies":{},"devDependencies":{}}"#, + "vendor_lock_entry_unsupported", + ), + ] { + std::fs::write(store.join("package.json"), pkg).unwrap(); + let refusals = judge(tmp.path(), Ok(&empty)).await; + assert_eq!(refusals[PURL].code, code, "{refusals:?}"); + } + } + + #[tokio::test] + async fn a_root_socket_ignore_refuses_before_any_write() { + let Some(git) = crate::utils::process::resolve_tool("git") else { + eprintln!("git not found; skipping"); + return; + }; + let tmp = project(&direct_lock(), PKG); + let status = std::process::Command::new(git) + .args(["init", "-q"]) + .current_dir(tmp.path()) + .status() + .unwrap(); + assert!(status.success()); + let empty = HashMap::new(); + std::fs::write(tmp.path().join(".gitignore"), "dist/\nnode_modules\n").unwrap(); + assert!(judge(tmp.path(), Ok(&empty)).await.is_empty()); + std::fs::write(tmp.path().join(".gitignore"), ".socket/\n").unwrap(); + let refusals = judge(tmp.path(), Ok(&empty)).await; + assert_eq!(refusals[PURL].code, "vendor_artifact_gitignored"); + assert!( + refusals[PURL].detail.contains(".gitignore:1:.socket/"), + "{refusals:?}" + ); + assert!(!tmp.path().join(".socket").exists()); + } + } + + mod package_lock { + use super::*; + + const LEFT_PAD: &str = "pkg:npm/left-pad@1.3.0"; + const IS_NUMBER: &str = "pkg:npm/is-number@7.0.0"; + const CRATE: &str = "pkg:cargo/serde@1.0.0"; + const URL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + const GIT: &str = "git+ssh://git@github.com/stevemao/left-pad.git#ff8e7ba"; + + fn write(path: &Path, text: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + /// A v3 package-lock project with a registry is-number and left-pad + /// installed from `left_pad` (a URL with an integrity, or a git + /// spec without one): the backend refuses left-pad on the lock text + /// alone (`vendor_lock_entry_not_rewritable`, #1976). + fn project(root: &Path, spec: &str, left_pad: serde_json::Value) { + let manifest = serde_json::json!({ + "name": "app", "version": "1.0.0", + "dependencies": { "is-number": "7.0.0", "left-pad": spec }, + }); + let lock = serde_json::json!({ + "name": "app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, + "packages": { + "": manifest, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-AAAA==" + }, + "node_modules/left-pad": left_pad, + }, + }); + write(&root.join("package.json"), &manifest.to_string()); + write(&root.join("package-lock.json"), &lock.to_string()); + } + + fn url_project(root: &Path) { + project( + root, + URL, + serde_json::json!({ "version": "1.3.0", "resolved": URL, "integrity": "sha512-orig==" }), + ); + } + + /// A v1 lock refuses every npm purl of the project, whatever the + /// entry, and leaves other ecosystems to their own checks. + #[tokio::test] + async fn a_v1_lock_refuses_every_npm_purl() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("package.json"), + r#"{"name":"app","version":"1.0.0","dependencies":{"is-number":"7.0.0"}}"#, + ); + write( + &tmp.path().join("package-lock.json"), + r#"{"name":"app","version":"1.0.0","lockfileVersion":1,"dependencies":{}}"#, + ); + let empty = HashMap::new(); + let refusals = run( + tmp.path(), + &[(LEFT_PAD, UUID), (IS_NUMBER, UUID), (CRATE, UUID)], + Ok(&empty), + ) + .await; + assert_eq!( + refusals[LEFT_PAD].code, + "vendor_lockfile_version_unsupported" + ); + assert_eq!( + refusals[IS_NUMBER].code, + "vendor_lockfile_version_unsupported" + ); + assert!(!refusals.contains_key(CRATE), "{refusals:?}"); + } + + #[tokio::test] + async fn a_lock_text_refusal_is_scoped_like_the_vendor_loop() { + let tmp = tempfile::tempdir().unwrap(); + url_project(tmp.path()); + let empty = HashMap::new(); + let pairs = [(LEFT_PAD, UUID), (IS_NUMBER, UUID)]; + let refusals = run(tmp.path(), &pairs, Ok(&empty)).await; + assert_eq!( + refusals[LEFT_PAD].code, "vendor_lock_entry_not_rewritable", + "{refusals:?}" + ); + assert!(!refusals.contains_key(IS_NUMBER), "{refusals:?}"); + + // A hosted pin makes it a takeover: the restore rewrites the text + // the gate reads, so the gate is left to the vendor loop. + let pin = HostedPin { + purl: LEFT_PAD.to_string(), + uuid: OTHER_UUID.to_string(), + files: vec!["package-lock.json".to_string()], + }; + let refusals = run_scoped( + tmp.path(), + &pairs, + Ok(&empty), + std::slice::from_ref(&pin), + ¬hing_installed, + ) + .await; + assert!(refusals.is_empty(), "{refusals:?}"); + + // A detached ledger entry at this uuid is the idempotency skip. + let ledger = HashMap::from([( + LEFT_PAD.to_string(), + entry(serde_json::json!({ + "ecosystem": "npm", + "basePurl": LEFT_PAD, + "uuid": UUID, + "artifact": { "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") }, + "wiring": [], + "detached": true, + })), + )]); + assert!(run(tmp.path(), &pairs, Ok(&ledger)).await.is_empty()); + } + + /// A refused package with no verifiable registry source reaches its + /// backend only when it is installed. + #[tokio::test] + async fn an_unverifiable_refusal_needs_an_installed_copy() { + let tmp = tempfile::tempdir().unwrap(); + project( + tmp.path(), + "github:stevemao/left-pad#v1.3.0", + serde_json::json!({ "version": "1.3.0", "resolved": GIT }), + ); + let empty = HashMap::new(); + let pairs = [(LEFT_PAD, UUID)]; + assert!(run(tmp.path(), &pairs, Ok(&empty)).await.is_empty()); + let left_pad_installed = |purls: Vec| -> BoxFuture<'static, HashSet> { + Box::pin(async move { purls.into_iter().filter(|p| p == LEFT_PAD).collect() }) + }; + let refusals = + run_scoped(tmp.path(), &pairs, Ok(&empty), &[], &left_pad_installed).await; + assert_eq!( + refusals[LEFT_PAD].code, "vendor_lock_entry_not_rewritable", + "{refusals:?}" + ); + } + } + + mod pnpm_takeover { + use super::*; + + const PURL: &str = "pkg:npm/left-pad@1.3.0"; + const PIN_UUID: &str = "11111111-2222-4333-8444-555555555555"; + /// A hosted pin: the entry's tarball is the patch server's. + const HOSTED_V9: &str = "lockfileVersion: '9.0'\n\n\ + importers:\n\n .:\n dependencies:\n left-pad:\n specifier: 1.3.0\n version: 1.3.0\n\n\ + packages:\n\n left-pad@1.3.0:\n resolution: {integrity: sha512-x==, tarball: https://patch.socket.dev/patch/npm/left-pad/1.3.0/a/11111111-2222-4333-8444-555555555555/left-pad-1.3.0.tgz}\n\n\ + snapshots:\n\n left-pad@1.3.0: {}\n"; + + /// A hosted pnpm pin's takeover meets the pnpm lock-text gates + /// (#853); a ledger entry already at the selected uuid is in sync. + #[tokio::test] + async fn a_hosted_pin_meets_the_pnpm_gates_before_its_takeover() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + r#"{"name":"c","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + std::fs::write( + tmp.path().join("pnpm-lock.yaml"), + HOSTED_V9.replace('\n', "\r\n"), + ) + .unwrap(); + let pin = HostedPin { + purl: PURL.to_string(), + uuid: PIN_UUID.to_string(), + files: vec!["pnpm-lock.yaml".to_string()], + }; + let pins = std::slice::from_ref(&pin); + let empty = HashMap::new(); + let pairs = [(PURL, UUID)]; + let refusals = + run_scoped(tmp.path(), &pairs, Ok(&empty), pins, ¬hing_installed).await; + assert_eq!( + refusals[PURL].code, "vendor_lockfile_crlf_unsupported", + "{refusals:?}" + ); + + let ledger = HashMap::from([( + PURL.to_string(), + entry(serde_json::json!({ + "ecosystem": "npm", + "basePurl": PURL, + "uuid": UUID, + "artifact": { "path": format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") }, + "wiring": [], + })), + )]); + assert!( + run_scoped(tmp.path(), &pairs, Ok(&ledger), pins, ¬hing_installed) + .await + .is_empty() + ); + } + } +}