diff --git a/.github/workflows/bun-compatibility.yml b/.github/workflows/bun-compatibility.yml index 0089fd78..bbae07f3 100644 --- a/.github/workflows/bun-compatibility.yml +++ b/.github/workflows/bun-compatibility.yml @@ -3,8 +3,10 @@ name: Bun patch compatibility # Native Bun installer matrix: builds the CLI once per OS, downloads each # pinned Bun release straight from its GitHub release (retried, SHA-256 # verified against the release's SHASUMS256.txt) and runs -# `scripts/backtest-bun.py` — hosted, vendored and vendored-detached mode -# against the public minimist free patch, verifying the INSTALLED bytes, +# `scripts/backtest-bun.py` — hosted and vendored mode (vendored is +# manifest-free: the ledger embeds the record, so the former +# vendored-detached leg collapsed into it) against the public minimist free +# patch, verifying the INSTALLED bytes, # lock stability, digest rejection and rollback on Linux, macOS and Windows. # No Socket API token is needed. See docs/testing/bun-compatibility.md. # @@ -74,7 +76,7 @@ on: required: false default: '' modes: - description: 'Space-separated modes from hosted / vendored / vendored-detached (empty = all three)' + description: 'Space-separated modes from hosted / vendored (empty = both)' required: false default: '' @@ -308,7 +310,7 @@ jobs: chmod +x native-cli/socket-patch* || true cli="native-cli/socket-patch" if [ "$RUNNER_OS" = "Windows" ]; then cli="native-cli/socket-patch.exe"; fi - modes="hosted vendored vendored-detached" + modes="hosted vendored" if [ -n "$MODES_OVERRIDE" ]; then modes="$MODES_OVERRIDE"; fi shapes_arg=() if [ -n "$SHAPES_OVERRIDE" ]; then shapes_arg=(--shapes $SHAPES_OVERRIDE); fi diff --git a/.gitignore b/.gitignore index f011beb4..f751c7dc 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,7 @@ pypi/socket-patch/README.md # Generated by scripts/study-crates.ts study-output/ -simplify-output/ \ No newline at end of file +simplify-output/ +# Python bytecode caches (scripts/backtest-*.py) +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index ded4b6c6..3615d14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ into the new version's section — see docs/releasing.md. ## [Unreleased] -> **Semver note:** this entry changes `rollback`'s default behavior and -> narrows the meaning of its existing `vendored: []` JSON key — both MAJOR +> **Semver note:** this entry changes `rollback`'s default behavior, narrows +> the meaning of its existing `vendored: []` JSON key, makes vendored mode +> manifest-free, and turns a plain non-TTY `scan` report-only — all MAJOR > per CLI_CONTRACT.md's semver policy — so it ships as the next major > release (v5.0). @@ -37,7 +38,7 @@ into the new version's section — see docs/releasing.md. GCs the now-unused blobs plus diff/package archives. No `--mode` needed: state is inferred from the manifest, the vendor ledger, and the redirect ledger, and rollback now runs manifest-less when a ledger holds work - (hosted-only and detached-vendored projects; the truly-empty project + (hosted-only and vendored projects; the truly-empty project keeps the "Manifest not found" exit 1, and a wired-but-ledgerless project errors naming `socket-patch repair`). Wet non-preserve runs confirm once ("Roll back N patch(es), remove them from the local manifest, and delete @@ -52,6 +53,82 @@ into the new version's section — see docs/releasing.md. always-present `warnings[]` (`{code, detail}`, now populated), `hosted` (`{reverted, failed, unsupported, editedFiles}`), `manifest` (`{removedEntries, preserved}`), `gc`, and `paths` keys. +- **Vendored mode is manifest-free.** `scan --mode vendored` and + `get --mode vendored` never write (or read) `.socket/manifest.json`: the + selected patch records are fetched into memory and every vendor-ledger entry + carries `detached: true` plus the embedded `record` as its verification + source, so a vendored project's footprint is `.socket/vendor/**` only. The + former `--detached` opt-in is now the only vendored posture — the flag is + hidden, accepted as a no-op for compatibility, and still a usage error + without vendored mode. JSON uses the detached download vocabulary for both + commands (`downloaded: N`, `detached: true`, `patches[].action` = + `downloaded` | `skipped` | `failed`). The vendor step vendors exactly what + discovery selected — the "whole manifest is vendored" re-vendor from a + committed manifest on an empty discovery is retired (`repair` verifies and + rebuilds committed vendored state) — and a legacy manifest record for a purl + a vendored run vendors is migrated into the ledger (dropped from the + manifest; an emptied manifest is left as `{"patches": {}}`). `list` now + reads the vendor ledger too, so a vendored-only project lists its patches + with a `Mode: vendored` label and exits 0 instead of `manifest_not_found`; + `scan --prune`'s lockfile-unused reconcile applies to every ledger entry + (the check is about the lockfile, not the manifest); and standalone `vendor` + with no manifest is a clean exit-0 no-op whose message names the missing + manifest (and the ledger entries `repair` verifies) instead of claiming + "No .socket folder found". +- **A plain `scan` without a TTY is report-only.** When stdin is not a TTY, + `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, + `--vendor`, `--redirect`, `--prune`) is given, human-mode `scan` prints the + discovery report and the "To apply a patch, run: …" hint, downloads + nothing, creates no `.socket/`, and exits 0 — it no longer auto-accepts the + apply prompt. Any intent flag, `--yes`, or a TTY keeps the previous + behavior; `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. + Human `scan --mode hosted` now prints the results table and update + detection like the other modes and confirms once ("Redirect N package(s) + to the hosted patch server?" — the same prompt as `get --mode hosted` — + default yes, skipped by `--yes`/`--json`/`--dry-run`; on a non-TTY stdin + without `--yes` it prints `Non-interactive mode detected, proceeding with + default.` and proceeds), fetches patch details with the agent arm's + progress counter and per-package warnings, and an empty hosted discovery + prints `No patches available for installed packages.` and exits 0 without + entering the redirect engine (was `Redirected 0 package(s)`); a discovery + whose every offer is paid-tier for an org without paid access stops the + same way with `No downloadable patches (paid subscription required).`. A + malformed redirect ledger on a human hosted run that stops before the + engine is reported as the read-only `Warning: the redirect ledger … is + malformed` advisory instead of nowhere. +- **`apply.lock` never outlives a command, and hosted mode takes it.** Lock + acquisition creates `.socket/` when missing; the lock file is unlinked + (while still held) and an otherwise-empty `.socket/` removed when the + command exits, dry runs included, so there is nothing to `.gitignore` and + `repair` no longer has a lock-cleanup step (a leftover from a crashed run is + reclaimed and removed by the next lock-taking command; a live holder is + still `lock_held`, exit 1). `scan`/`get --mode hosted` now acquire the lock + around their first wet write — never on `--dry-run` or when nothing would + be written, so previews create no `.socket/` — and report `lock_held` / + `lock_io` like the other lock holders (top-level `errorCode` on the hosted + JSON shape; a read-only project root or a file squatting on `.socket/` is + refused at the lock, before the redirect ledger is touched, and a + vendored→hosted takeover over a symlinked wiring file is refused with + `redirect_symlinked_file_unsupported` before any revert). A zero-grant wet + run — which holds no lock — no longer moves a malformed + `redirect-state.json` aside: like a dry run it reports the hard error and + leaves the file in place; only the lock holder quarantines. The lock guard + unlinks only the file it holds (a replacement planted by a non-cooperating + `rm` + `touch` is left for the next acquire), and a long `--lock-timeout` + wait behind a hot loop of short commands can no longer accumulate its + vanished-file retries into a spurious `lock_io`. Agent-mode `get` + and `scan --apply`/`--sync` hold one lock window across download → + manifest write → nested apply (the nested apply no longer re-acquires and + now inherits `--lock-timeout`/`--verbose`); `setup` takes the lock while + persisting `--exclude`; `scan --prune` acquires once for its vendored + reconcile and manifest prune, and the GC legs of `scan --prune` and + `vendor` honor `--lock-timeout` and report a lock I/O error instead of + silently skipping on it. +- **Retired:** the legacy `.socket/cargo-patches` redirect takeover in the + cargo vendor backend (never shipped in a tagged release — such + `[patch.crates-io]` entries now refuse as `user_authored_patch_entry`) and + the `pypi_pipenv_invalid_wheel` refusal code (the Pipenv backend takes the + resolved version instead of parsing the wheel filename). ### Added @@ -268,6 +345,150 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **Reversal leaves no `.socket/` residue.** `rollback`, `remove`, + `vendor --revert`, the hosted unwind and the GC sweeps now prune what they + empty: an emptied redirect or vendor ledger is deleted together with the + empty `.socket/vendor//` and `.socket/vendor/` directories (per-entry + vendored reverts prune their ecosystem husk; a `redirect-state.json.corrupt` + quarantine keeps its directory), emptied `blobs/`, `diffs/` and `packages/` + stores are removed, and `.socket/` itself goes with the lock when nothing is + left — so a fully unwound hosted or vendored project has no `.socket/` at + all. Deliberately kept: the zero-patch `.socket/manifest.json` + (`{"patches": {}}` + its `setup` block — `list`/`apply`/`vex` exit codes + depend on it) and the `setup`-owned `.socket/.gitignore`, + `gem-plugin-stamp` and `bundler-plugin/` (rollback never undoes setup). + `setup --remove` now also removes an emptied `.socket/`. +- **`scan --prune` says what it skipped and what it could not finish.** The + `gc` JSON sub-object gains `failedVendoredEntries` plus the additive + `skipped: {code, message}` (`lock_held` | `lock_io`) and + `warnings: [{code, detail}]` (`vendor_state_write_failed`, + `manifest_write_failed`, `cleanup_failed`) keys, with matching `GC: …` + human lines, so a pass that could not take the lock or could not rewrite a + ledger no longer reads as a clean all-zero sweep — and a lock I/O fault or + a failed rewrite is never mislabelled as lock contention. A legacy manifest + record migrated into the vendor ledger is reported as the + `vendor_manifest_record_migrated` / `vendor_manifest_migration_failed` run + warnings; a corrupt manifest no longer fails a vendored run (standalone + `vendor` still fails closed on it). +- **Vendored `get`/`scan` name the patch they replace.** A `downloaded` + record for a purl the vendor ledger holds at another uuid carries `oldUuid` + and the human `[fetch]` line reads `(replacing )`; + `get --mode vendored --dry-run` prints `[dry-run] Would download and vendor + N patch(es).` on both identifier paths; the `[note]` and + `Patch record saved to` lines are gone with the manifest. +- **Agent-mode `get` leaves nothing behind when it records nothing.** + `.socket/` and `.socket/blobs/` are created only when a record is + persisted (all-skipped and all-failed runs leave no `.socket/`), a + same-uuid `get ` re-run rewrites neither the manifest nor the blobs, + and a blob/diff fetch that lands nothing creates no `.socket/blobs/` or + `diffs/` — the `Cannot create blobs/archives directory` all-failed envelope + is gone; an uncreatable cache dir is a per-entry + `Failed to write blob/archive to disk`. +- **`ownership_not_restored` is a warning, not silence.** A file `apply` + patched (or `rollback` restored) whose ownership could not be put back to + the original uid/gid now surfaces as an `ownership_not_restored` run + warning (`warnings[]` plus `Warning (ownership_not_restored): …` on + stderr) instead of riding a successful result unseen; the mode is still + restored. +- **`remove` on ledger-only state.** A missing manifest beside a vendor or + redirect ledger that holds nothing for the identifier answers `not_found` + (exit 1) instead of `manifest_not_found`; a second `--skip-rollback` on + the ledger-only leftover of an earlier `--skip-rollback` is refused with + `vendor_state_retained` (was `not_found`); every matching vendor-ledger + entry — detached or not — is removable through the ledger with + `--preserve-state` and drift-keeps honored exactly as on the manifest + path; manifest entries are removed in sorted order, and the + `(not installed)` line prints only when something was not installed. + `rollback` prints `No patches found in manifest` only for an unscoped run + with no work in any leg. +- **`setup --exclude` persists after the prompt, under the lock.** The + exclusion list is written after discovery and confirmation (also on the + already-configured path when the flag is explicit) as a read-modify-write + under `apply.lock`; a held or unopenable lock, or a manifest that cannot + be read or written, is reported as `not persisting --exclude: …` instead + of being swallowed. `setup --check` reads the vendor ledger even without + a manifest and, on a corrupt one, warns `unreadable vendor state` and + reports a `vendor_ledger` error entry (verdict `error`, exit 1) — never + `configured`; `vex` discloses the same unreadable ledger before its + `manifest_not_found` / `no_patches` exit on a manifest-free project; `list` + degrades a corrupt vendor ledger to a `Warning: unreadable vendor ledger …` + line (muted by `--silent`) rather than an error; `patch_setup` telemetry + fires only for a successful, non-dry-run setup. +- **`repair`/`vendor` state hygiene.** `repair` resolves installed copies + through qualified ledger keys (gem `?platform=`, pypi `?artifact_id=`, + maven `?classifier=` no longer read as "not installed"), puts a crashed + rebuild's `.pre-rebuild` set-aside back when it is the only copy, + and reports an absent or empty blobs dir as `No blobs to clean up.`; every + artifact sweep (`repair`, `rollback`, `remove`, `scan --prune`) keeps going + past one unremovable file and reports the failures afterwards; `vendor`'s + dropped-record reconcile saves per purl and counts a failed save as + `vendor_state_write_failed`; `vendor_marker_write_failed` is the one + marker-failure warning for every backend (cargo/golang/pypi's + `marker_write_failed` retired), and a pypi vendor whose informational + marker cannot be written now succeeds with that warning instead of + sweeping the wheel; npm, yarn (classic and berry) and pnpm reverts honor + the drift-keep on an unwired entry like bun and legacy pnpm already did; + the hosted replay no longer credits a byte-identical hatch rewrite as an + edited file; a corrupt redirect ledger met by a hosted scan is reported + once, not twice. +- **Manifest inputs are validated before they become paths.** `apply` + refuses an `afterHash` that is not a 64-hex blob hash or a uuid that is not + a plain path segment and reads blobs through a symlink-refusing opener (a + poisoned manifest or a planted `blobs/` symlink can no longer read out of + tree); `rollback` deletes patch-added files in every pnpm store copy and + heals a patched twin of an already-original primary. +- **Human chrome.** The global-mode `Using at: ` banner moves to + stderr so piped stdout stays clean; the empty-crawl hint of `scan` and + `get` reads `Run your package manager's install first.` instead of a fixed + npm/yarn/pnpm/pip/cargo/go/mvn/composer list; `vendor --revert` and the no-manifest + no-ops of `vendor` and `apply` (and `apply --check`) build no API client, + so the `SOCKET_API_TOKEN` advisories no longer print on hooked + manifest-less runs, and `repair` prints its token notice once. +- **Telemetry and self-update robustness.** The telemetry client uses a 2 s + connect timeout (a blackholed endpoint no longer stalls every command for + the full request budget), and `--update` maps only a contention errno to + `update_in_progress` — other lock failures surface their real cause. +- **A normal `scan` never creates `.socket/`.** Report-only, `--dry-run`, + zero-discovery and no-op runs (hosted or otherwise) no longer scaffold the + directory or a lock file; a GC pass checks for a manifest before it locks. +- **`apply --silent` on an all-unmatched manifest prints its error line** — + errors are never muted by `--silent`; and the no-manifest early exits of + `apply` and `vendor` name the missing `.socket/manifest.json` instead of + "No .socket folder found" (the folder may legitimately hold setup files or + vendored state). +- **Hosted redirect hygiene.** Missing project files no longer skip silently: + `redirect_composer_no_lockfile`, `redirect_gem_no_gemfile` (neither manifest + nor lock present) and `redirect_maven_no_pom` (no `pom.xml`, no Gradle + build) warn once per run; a present-but-corrupt `packages.lock.json` warns + `redirect_nuget_lock_unparseable` before any config mutation; a `Cargo.lock` + with several same-name+version `[[package]]` blocks and no `source` + disambiguation warns `redirect_cargo_lock_pkg_ambiguous` and skips + transactionally; a registry override of the wrong kind now warns the arm's + missing-override code for nuget/gem/golang (previously a silent skip); the + ledger's `redirect_nuget_source` edit records `action: "added"` when + `nuget.config` was authored from scratch; hosted-revert lockfile restores are + atomic and mode-preserving (including `bun.lockb`), and a FIFO or symlink + squatting on a lockfile is refused instead of wedging the revert. +- **Vendor backend parity.** Gem reverts follow every other backend's + drift-keep rule (genuine drift keeps artifact + ledger entry; converged files + are silent; a missing `Gemfile`/`Gemfile.lock` warns `vendor_lockfile_missing` + and still removes the artifact); composer, maven and nuget reverts keep the + artifact + ledger entry (`kept_artifact`, the `vendor_revert_kept` skip) + while the live `composer.lock` / `pom.xml` / `nuget.config` still names the + drift-skipped entry's uuid dir — previously the dir was deleted under a + `` / `` that still routed at it — and remove it once + nothing references it; the golang service leg stages its download + and, when a re-download of a wired present copy fails, keeps the copy and + directive instead of tearing them down; poetry/pipenv/requirements refuse + symlinked targets (`pypi_{poetry,pipenv,requirements}_symlink_unsupported`) + and every pypi flavor refuses a project file that changed between plan and + write (`pypi_{poetry,pdm,pipenv,uv}_changed`) instead of clobbering it; + `pyproject.toml` edits made by `setup` preserve CRLF line endings; an + unreadable (EACCES / squatting directory or FIFO) redirect ledger is + reported as unreadable and left in place instead of being quarantined as + "malformed"; a blob-cleanup pass keeps sweeping after one unremovable file + and reports the first error afterwards; the ledgers skip byte-identical + rewrites. - **Bun refusal safety:** hosted compatibility is checked before removing an existing vendored patch, including during dry-run. Vendored preflight exemptions require live local lock tuples; a ledger retained by @@ -300,12 +521,12 @@ into the new version's section — see docs/releasing.md. and `repair` on such a lock keep working; a corrupt `.socket/vendor/state.json` met by that preflight is reported as `vendor_state_unreadable` rather than a Bun lock code. `scan --mode vendored`, - `get --mode vendored` (search and uuid paths) and `--detached` runs now + `get --mode vendored` (search and uuid paths) now preflight the Bun lock BEFORE any download: a malformed binary, unreadable, unsupported-version or pre-version-2 workspace lock marks the npm patches `failed` with the vendor refusal code and detail, fetches nothing and - records no patch — the `scan` / `get ` path still writes an unchanged - `.socket/manifest.json` and exits `partial_failure`, `get --mode + records no patch — the `scan` / `get ` path writes nothing under + `.socket/` and exits `partial_failure`, `get --mode vendored` exits 1 with `status: "error"` and writes nothing — where previously the record landed in the manifest and the vendor step failed afterwards (and a detached run over an alias install misreported @@ -330,8 +551,8 @@ into the new version's section — see docs/releasing.md. rewrite keeps CRLF on the rewritten `bun.lock` line. Real-Bun coverage now runs in CI: the hermetic hosted and vendored suites on Linux, macOS and Windows (Bun 1.4.2, plus 1.1.45 and 1.2.23 lock-era legs), and - the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted, - vendored and detached-vendored mode — on pull requests and `main` (rows + the production native matrix — 16 releases from 0.8.1 to 1.4.2 in hosted + and vendored mode — on pull requests and `main` (rows carry `cliRevision` and `cliBuildSha` provenance), with the corrected digest boundary (Bun verifies URL/local tarball sha512 from 1.3.10, not 1.3.14). Bun 1.1.39–1.3.9 also re-save a hosted URL or diff --git a/Cargo.lock b/Cargo.lock index e790bce2..bfe94398 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,7 @@ dependencies = [ "qbsdiff", "regex", "reqwest", + "same-file", "self-replace", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index bae07947..a94d6051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ tar = "=0.4.46" flate2 = "=1.1.9" zip = { version = "=8.6.0", default-features = false, features = ["deflate"] } fs2 = "=0.4.3" +same-file = "=1.0.6" libc = "=0.2.182" semver = "=1.0.27" self-replace = "=1.5.0" diff --git a/README.md b/README.md index cce2b42e..9c92d937 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,7 @@ automatically: ```bash socket-patch setup # e.g. adds a postinstall script for npm projects -echo '.socket/apply.lock' >> .gitignore # lock state, not part of the patch record -git add .gitignore .socket package.json # npm example — setup prints which files it changed +git add .socket package.json # npm example — setup prints which files it changed git commit -m "apply Socket security patches" ``` @@ -215,13 +214,17 @@ committed: | Path | Contents | |------|----------| -| `.socket/manifest.json` | The record of downloaded patches: PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | -| `.socket/blobs/` | Patched file contents, named by git-sha256 hash | -| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers (only in vendored/hosted modes) | - -> Mutating commands also leave a `.socket/apply.lock` file there between runs. It is -> lock state, not part of the patch record — add it to your `.gitignore` -> ([`repair`](#repair) deletes it). +| `.socket/manifest.json` | Agent mode: the record of downloaded patches — PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | +| `.socket/blobs/` | Agent mode: patched file contents, named by git-sha256 hash | +| `.socket/vendor/` | Vendored package artifacts and the vendor/redirect ledgers — the **only** state vendored and hosted modes write (the vendor ledger embeds the patch records; neither mode touches `manifest.json`) | + +> While a command runs it holds a transient advisory lock, `.socket/apply.lock`, and +> removes it when it finishes — the file never outlives the command, so there is nothing +> to `.gitignore`. A crashed run can leave one behind; the next command reclaims and +> removes it. Nothing in the table is written until there is something to record: a +> report-only `scan`, a `--dry-run`, or a run that changes nothing leaves no `.socket/` at +> all, and a full [`rollback`](#rollback) removes everything it created (only the +> zero-patch `manifest.json` and any [`setup`](#setup) files stay). ### Three patch modes @@ -232,7 +235,7 @@ The same patched bytes can reach your build three different ways. The modes diff | Mode | Where the patch lives | Install-time requirement | Trade-off | |------|----------------------|--------------------------|-----------| | **agent** — `scan --mode agent` (or [`apply`](#apply)) | `.socket/` manifest + blobs, committed; the CLI re-applies after each install | The `socket-patch` CLI must run (install hook via [`setup`](#setup), or an `apply` step in CI) | Small repo footprint (per-file blobs, not whole packages); no lockfile edits; the only mode that needs CI / install-hook changes | -| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/`; the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | +| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/` (with a ledger that embeds the patch records — no manifest); the lockfile is rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | | **hosted** — `scan --mode hosted` | No patched bytes in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits + patch records are ledgered in `.socket/vendor/redirect-state.json` (commit it — [`vex`](#vex) reads it, and [`rollback`](#rollback) replays its recorded pre-redirect originals to unwind the redirect, see [Undo things](#undo-things)) | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | Every mode pins the patched bytes: in agent mode the CLI verifies every file on each @@ -343,8 +346,7 @@ Go, Maven, NuGet, Deno) have no hook and are patched on demand instead. ```bash # Vendored: commit the patched packages themselves (airgap-friendly) socket-patch scan --json --mode vendored --yes -echo '.socket/apply.lock' >> .gitignore -git add .gitignore .socket package-lock.json # your lockfile may differ +git add .socket package-lock.json # your lockfile may differ # Hosted: smallest diff — patched deps resolve from patch.socket.dev socket-patch scan --json --mode hosted --yes @@ -402,11 +404,11 @@ and repair; pick by what you want back: | Command | What it does | |---------|--------------| -| [`rollback`](#rollback) | Restores the original file bytes but **keeps the manifest entry** — the next `apply` re-applies the patch | -| [`remove`](#remove) | Everything `rollback` does, **plus** it deletes the manifest entry and reverts any vendoring — **permanent**, the patch is fully gone in one command | +| [`rollback`](#rollback) | **Fully unpatches, in every mode**: restores the original file bytes, unwinds vendored and hosted lockfile wiring, removes the rolled-back entries from the manifest (a zero-patch `{"patches": {}}` husk stays) and garbage-collects their blobs — everything, or just the given targets; `--preserve-state` keeps the local patch state for a later re-apply | +| [`remove`](#remove) | The single-patch dual of `rollback`: everything `rollback ` does for one PURL/UUID (restore, unwind its vendoring or hosted redirect, drop the entry, GC), plus `--skip-rollback` to drop only the record — **permanent**, the patch is fully gone in one command | | [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts — works without a manifest | | [`scan --prune`](#scan) | **Reconciles, doesn't reverse**: drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files — installed patches stay | -| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, cleans up unused ones, and removes the leftover `apply.lock` file (housekeeping — mutating commands leave it behind after every run) | +| [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | And `setup --remove` reverts the install hooks that `setup` added. @@ -426,11 +428,11 @@ And `setup --remove` reverts the install hooks that `setup` added. | [`vex`](#vex) | Generate an OpenVEX attestation for the applied patches | | [`vendor`](#vendor) | Eject patched dependencies into committable `.socket/vendor/` | | [`setup`](#setup) | Wire install hooks so patches re-apply automatically | -| [`rollback`](#rollback) | Restore original files (keeps the manifest) | +| [`rollback`](#rollback) | Fully unpatch everything (or the given targets) in every mode and drop the rolled-back manifest entries (`--preserve-state` keeps them) | | [`get`](#get) | Fetch and apply a patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | -| [`list`](#list) | List all patches in the local manifest | +| [`list`](#list) | List recorded patches: manifest entries plus vendor-ledger and redirect-ledger records | | [`remove`](#remove) | Remove a patch: roll back files + delete the manifest entry | -| [`repair`](#repair) | Download missing blobs, clean up unused ones, tidy lock state (alias: `gc`) | +| [`repair`](#repair) | Download missing blobs, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | ### Global options @@ -466,7 +468,7 @@ settings, described in [Configuration sources](#configuration-sources) below. | `-s, --silent` | `SOCKET_SILENT` | Suppress non-error output. | | `--dry-run` | `SOCKET_DRY_RUN` | Preview the operation without making any mutations. | | `-y, --yes` | `SOCKET_YES` | Skip interactive confirmation prompts. | -| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for mutating commands (`apply`, `rollback`, `repair`, `remove`). | +| `--lock-timeout ` | `SOCKET_LOCK_TIMEOUT` | Seconds to wait for `.socket/apply.lock` before giving up. `0`/unset = a single non-blocking try; a positive value retries with backoff. Only meaningful for the commands that take the lock — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted). The lock file exists only while a command runs. | | `--debug` | `SOCKET_DEBUG` | Emit verbose debug logs to stderr. | | `--no-telemetry` | `SOCKET_TELEMETRY_DISABLED` | Disable anonymous usage telemetry. | @@ -516,12 +518,15 @@ it finds. `scan` is the entry point for all three [patch modes](#three-patch-mod - `--mode agent` downloads and applies the selected patches in place; - `--mode vendored` discovers, downloads, and builds + wires the committable `.socket/vendor/` artifacts in one pass (re-vendoring automatically when a newer patch - is selected); + is selected); it is manifest-free — the vendor ledger embeds the patch records and + nothing else is written under `.socket/`; - `--mode hosted` rewrites lockfiles / registry configs so only the patched dependencies resolve to Socket-hosted packages. -Without a mode, interactive `scan` prompts before applying, and `scan --json` is -read-only (discovery plus an `updates[]` array; no mutation). +Without a mode, interactive `scan` prompts before applying (in a TTY — when stdin is not a +TTY and neither `--yes` nor a mode/`--prune` flag is given, it is report-only: it prints what +it found plus the "To apply a patch, run: …" hint, writes nothing, and exits 0), and +`scan --json` is read-only (discovery plus an `updates[]` array; no mutation). `scan --mode agent --prune` is the single command bots need for full auto-update: it discovers patches, applies them, and garbage-collects orphan blob files plus manifest @@ -536,8 +541,8 @@ socket-patch scan [options] | Flag | Env var | Description | |------|---------|-------------| | `--mode ` | — | Selects one of the three [patch modes](#three-patch-modes), summarized above. Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. | -| `--prune` | — | Garbage-collect after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are exempt from the crawl-based prune (an absent installed copy is their normal state), but a vendored entry whose dependency has left the lockfile is reverted and its manifest entry dropped. Orthogonal to `--mode` — combines with any mode. | -| `--detached` | — | With `--mode vendored`: skip all `.socket/manifest.json` writes — the vendor ledger embeds the patch records instead. For projects that want the vendored patches *only* in the lockfile + `.socket/vendor/`. Detached patches are invisible to `apply`/`rollback`/`repair`; undo them with `remove ` or `vendor --revert`. | +| `--prune` | — | Garbage-collect after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. Off by default. [Vendored](#vendor) packages are exempt from the crawl-based prune (an absent installed copy is their normal state), but a vendored entry whose dependency has left the lockfile is reverted (and any manifest entry it still had dropped). Orthogonal to `--mode` — combines with any mode. | +| `--detached` | — | Hidden compatibility no-op. Vendored mode is manifest-free by default: the vendor ledger (`.socket/vendor/state.json`) embeds the patch records and `.socket/manifest.json` is never written, so this former opt-in changes nothing. Still an error without `--mode vendored`. | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `100`). | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | | `--vex ` | `SOCKET_VEX` | On a successful scan, also write an OpenVEX 0.2.0 document to this path. See [Inline VEX generation](#inline-vex-on-apply--scan--vendor). | @@ -583,9 +588,6 @@ socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json # integrity-verified against the lockfile before vendoring. socket-patch scan --json --mode vendored --yes -# Same, but keep the manifest out of it entirely -socket-patch scan --json --mode vendored --detached --yes - # Preview a vendored run (would_vendor / would_revendor / already_vendored) socket-patch scan --json --mode vendored --yes --dry-run @@ -686,9 +688,10 @@ socket-patch vex --no-verify --output socket.vex.json [vendored mode](#three-patch-modes) (`scan --mode vendored` runs discovery + this engine in one pass). Instead of patching installed packages in place (machine-local state), `vendor` ejects each patched package into `.socket/vendor///…` and -rewires your lockfile so the project consumes the vendored copy. Commit `.socket/` — the -vendored artifacts plus the manifest that [`vex`](#vex), [`list`](#list), and -[`repair`](#repair) read — along with the lockfile edits, and **every fresh checkout +rewires your lockfile so the project consumes the vendored copy. Commit `.socket/vendor/` — +the vendored artifacts plus the ledger whose embedded patch records [`vex`](#vex), +[`list`](#list), and [`repair`](#repair) read (vendored mode writes nothing else under +`.socket/`) — along with the lockfile edits, and **every fresh checkout builds with the patched dependency**: no `socket-patch` binary, no Socket API access, no install hook required on the consuming machine. @@ -722,8 +725,10 @@ it: `updates[]` as the signal to re-run `scan --mode vendored`. - [`vex`](#vex) attests vendored patches by verifying the **committed artifact** (marked `(vendored)` in the impact statement) — no `setup` install hook needed. -- Re-running `vendor` is idempotent; patches dropped from the manifest are auto-reverted - on the next run. +- Re-running `vendor` is idempotent. Standalone `vendor` (no flags) is driven by + `.socket/manifest.json` — patches dropped from that manifest are auto-reverted on the + next run — so on a project vendored by `scan --mode vendored` (no manifest) it is a + clean no-op; use [`repair`](#repair) to verify or rebuild the committed artifacts there. **Examples:** ```bash @@ -733,8 +738,7 @@ socket-patch vendor # Preview without writing anything socket-patch vendor --dry-run -# Then make it stick: commit .socket/ (vendor artifacts + manifest) and the lockfile -# (gitignore .socket/apply.lock — see "How Socket Patch works") +# Then make it stick: commit .socket/ (vendor artifacts + ledger) and the lockfile git add .socket package-lock.json && git commit -m "vendor Socket patches" # Undo everything (restores the original lockfile byte-for-byte) @@ -869,25 +873,33 @@ socket-patch setup --json -y ### `rollback` -Roll back patches to restore the original files. If no identifier is given, all patches -are rolled back. The manifest entries are kept, so a later `apply` re-applies the patches -— use [`remove`](#remove) to delete a patch permanently. +Roll back patches to restore the system to unpatched. If no target is given, everything +is rolled back, across all three modes: in-place file restores (agent), vendored unwire + +artifact deletion + ledger-entry drop, and hosted lockfile-redirect unwind + record drop. +The rolled-back entries are then removed from `.socket/manifest.json` (a zero-patch +`{"patches": {}}` husk stays) and their blobs are garbage-collected — a later `apply` has +nothing to re-apply. Pass `--preserve-state` to keep the local patch state (manifest +entries, vendored artifacts + ledger entries) for a later re-apply; use +[`remove`](#remove) for a single patch. -Packages managed by [`vendor`](#vendor) are excluded — their patch lives in the committed -artifact, not the installed tree — and are listed in the JSON output's `vendored` array -(use `remove` or `vendor --revert` to undo them). +A wet run confirms once (auto-accepted under `--yes`/`--json`/non-TTY). Vendor-owned purls +the run did NOT act on (today: a corrupt vendor ledger) are listed in the JSON output's +`vendored` array; acted-on entries ride `vendoredReverted` / `vendoredPreserved` / +`vendoredKept`. **Usage:** ```bash -socket-patch rollback [identifier] [options] +socket-patch rollback [targets]... [options] ``` **Arguments:** -- `identifier` — package PURL or patch UUID to roll back. Omit to roll back all patches. +- `targets` — zero or more package PURLs, patch UUIDs or path globs (unioned). Omit to roll + back everything. **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| +| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Unpatch the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — for a later re-apply, and skip GC. Hosted redirects have no preservable state and are unwound either way. | | `--one-off` | `SOCKET_ONE_OFF` | Reserved: rollback by fetching original (`beforeHash`) files from the API, no manifest required. **Not yet implemented** — the command currently errors up front. | **Examples:** @@ -967,7 +979,9 @@ socket-patch get CVE-2024-12345 --json -y ### `list` -List all patches in the local manifest. +List all patches recorded locally: the manifest's entries plus the vendor ledger's +(labeled `Mode: vendored`) and the hosted redirect ledger's records, so it works on +manifest-less vendored or hosted projects too. **Usage:** ```bash @@ -1010,8 +1024,9 @@ Package: pkg:npm/flatted@3.3.1 Remove a patch from the manifest (rolls back files first by default). If the package is [vendored](#vendor), `remove` also **reverts the vendoring** — the lockfile is restored byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone -in one command. Detached-vendored patches (from `scan --mode vendored --detached`) are -removable by PURL or UUID too, even though they have no manifest entry. +in one command. Patches vendored by `scan --mode vendored` have no manifest entry and are +removable by PURL or UUID all the same (reverting the vendoring *is* the removal, so +`--skip-rollback` is refused for them). **Usage:** ```bash @@ -1024,7 +1039,7 @@ socket-patch remove [options] **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| -| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for vendored packages this also leaves the vendor wiring + artifact in place). | +| `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Only update the manifest, do not restore original files (for a vendored package that still has a manifest entry this also leaves the vendor wiring + artifact in place; refused for manifest-less vendored patches, where the revert *is* the removal). | **Examples:** ```bash @@ -1043,7 +1058,8 @@ socket-patch remove "pkg:npm/lodash@4.17.20" --json ### `repair` -Download missing blobs, clean up unused blobs, and reset the advisory lock state. +Download missing blobs, rebuild missing or corrupt vendored artifacts, and clean up unused +blobs. Alias: `gc` @@ -1053,12 +1069,10 @@ free space. It also rebuilds missing or corrupt vendored artifacts. For the comb workflow (discover + apply + GC in one pass), use `scan --json --mode agent --prune --yes` instead. -As its final step, `repair` removes the leftover `.socket/apply.lock` file that mutating -commands retain between runs (skipped under `--dry-run`). A leftover file from a crashed -run never blocks anything — the OS releases a dead process's lock automatically — so this -is pure housekeeping. If another `socket-patch` process is actively running, `repair` -refuses up front with `lock_held` (exit 1); it never steals a live lock — wait for the -other process to finish, or budget a wait with `--lock-timeout`. +Like every other mutating command, `repair` takes the `.socket/apply.lock` advisory lock +while it runs and removes it when it finishes. If another `socket-patch` process is +actively running, `repair` refuses up front with `lock_held` (exit 1); it never steals a +live lock — wait for the other process to finish, or budget a wait with `--lock-timeout`. **Usage:** ```bash @@ -1098,9 +1112,8 @@ place — without bumping the package version. patched file's hash on disk so the attestation only covers patches that are actually applied. [Vendored](#vendor) patches are verified against the **committed artifact** instead of the installed tree (their impact statement carries a `(vendored)` marker), - and need no `setup` install hook to be attested. Detached-vendored patches - (`scan --mode vendored --detached`) - attest from the vendor ledger's embedded records, and + and need no `setup` install hook to be attested. Patches vendored by + `scan --mode vendored` attest from the vendor ledger's embedded records, and [hosted-mode](#three-patch-modes) patches attest from the redirect ledger (`.socket/vendor/redirect-state.json`, marker `(redirected)` — hash-verified against the installed tree post-install), so `vex` works even with no manifest file at all. @@ -1155,8 +1168,8 @@ trivy image --vex socket.vex.json ``` Apply patches first (in any mode) — `vex` errors with `no_patches` when there is nothing -to attest (an empty manifest, no detached-vendored patches, and no hosted redirect -records). +to attest (an empty or missing manifest, no vendored ledger entries, and no hosted +redirect records). ### Inline VEX on `apply` / `scan` / `vendor` @@ -1170,7 +1183,7 @@ socket-patch apply --vex socket.vex.json # Discover, apply, prune, and attest — the full auto-update-bot pass socket-patch scan --json --mode agent --prune --yes --vex socket.vex.json -# Vendor and attest — works manifest-less with --detached too +# Vendor and attest — manifest-less by construction socket-patch scan --json --mode vendored --yes --vex socket.vex.json ``` @@ -1223,7 +1236,10 @@ socket-patch apply --json | jq '.status' ``` When stdin is not a TTY (e.g. in CI pipelines), interactive prompts auto-proceed instead -of blocking. Progress indicators and ANSI colors are automatically suppressed when output +of blocking — with one deliberate exception: a plain `scan` (no `--mode`/`--apply`/`--sync`/ +`--vendor`/`--prune` and no `--yes`) is report-only there. It prints what it found and the +"To apply a patch, run: …" hint, writes nothing, and exits 0; add `--yes` or a mode flag +to mutate. Progress indicators and ANSI colors are automatically suppressed when output is piped. The exact JSON shapes, exit codes, and stability guarantees are specified in diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b4420f0e..16d99fd0 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -15,11 +15,13 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored/hosted lockfile wiring, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `get` | `download` | Fetch + apply patch; requires positional `identifier` | -| `list` | — | Print patches in the local manifest, plus the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | +| `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | -| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, clean up unused ones, and delete the leftover `<.socket>/apply.lock` as a final housekeeping step (skipped under `--dry-run`; refuses with `lock_held` when a live process holds the lock) | +| `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | -**Removed in v4.0:** the `unlock` subcommand (fold: `repair` now cleans up the lock file; a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command). +**Removed in v4.0:** the `unlock` subcommand (a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command; `repair` briefly owned lock-file cleanup in v4.x, and since v5.0 every lock-taking command removes its own lock file on exit). + +**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. A leftover file from a crashed (SIGKILLed) run is reclaimed in place and removed by the next lock-taking command. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, `setup` while it persists `--exclude` (v5.0), agent-mode `get` and `scan --apply`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it around its first wet write (the takeover pre-reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). @@ -49,9 +51,9 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--json` | `-j` | `SOCKET_JSON` | `false` | bool | Machine-readable output | | `--verbose` | `-v` | `SOCKET_VERBOSE` | `false` | bool | Extra detail | | `--silent` | `-s` | `SOCKET_SILENT` | `false` | bool | Errors only | -| `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations | +| `--dry-run` | — | `SOCKET_DRY_RUN` | `false` | bool | Preview, no mutations (a dry run may still take the transient `apply.lock`, removed again on exit — see "Lock lifecycle"; hosted and vendored previews never leave a `.socket/`) | | `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts | -| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the mutating subcommands | +| `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, `setup` (while persisting `--exclude`), and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | | `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | | `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | @@ -76,7 +78,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--mode ` | — | The documented selector for the three patch-application modes. Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | | `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling; this alias is scheduled for removal in v4): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | | `scan` | `--apply` / `--prune` / `--sync` | — | Mode selectors (sync = apply + prune); `--apply` == `--mode agent` | -| `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`); `--detached` additionally skips all manifest writes — the vendor ledger embeds the patch records (requires vendored mode in either spelling) | +| `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | | `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off`; `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default `agent` = today's save+apply flow, unchanged. `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | @@ -96,13 +98,13 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. -`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. +`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning — a hosted-wired project's report-only scan used to be byte-identical to a never-touched project's. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`. Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under the apply lock — lock contention skips it without failing the scan): vendored entries whose patch is gone from the manifest are reverted, vendored entries whose dependency is no longer in the lockfile graph are reverted AND their manifest entries dropped (detached entries are exempt from both — they are manifest- and lockfile-invisible by design; a missing or undeterminable lockfile keeps the entry, fail-safe), and orphan `.socket/vendor//` dirs with no ledger entry are swept. The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (legacy manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — entries carrying an embedded `record` (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entr(y|ies): …` for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -114,11 +116,11 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download (manifest written, as `--apply`) → vendor every patched dependency via the same engine as the `vendor` command (under the same lock). The whole manifest is vendored, so a package vendored at an older patch uuid is **re-vendored automatically** (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs are `already_vendored` skips. With `--prune`, GC runs **before** the vendor step so stale manifest entries don't fail vendoring with `package_not_installed`. JSON output gains a `download` sub-object (the download phase; no `applied` field — nothing is applied in place) and a `vendor` sub-object (a full vendor Envelope). The download phase writes only `.socket/manifest.json`; patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". +`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest record(s) moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". -`scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply and repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them), `vendor --revert`, or — as of v5.0 — `rollback`, whose vendored leg reverts detached ledger entries alongside manifest-tracked ones (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. +**Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches embedded-record entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. +`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N package(s) to the hosted patch server?`, default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 package(s)`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding with default.` to stderr and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 package(s)`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). @@ -128,17 +130,17 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **Mode ledgers (contract surfaces).** Each committable mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write these files, so path + schema are part of the contract: -* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, optional `detached` records. -* `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here); `records` maps PURL → the full manifest `PatchRecord` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). +* `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and — for every entry written by `scan`/`get --mode vendored` — `detached: true` plus the embedded patch `record` (standalone `vendor` fed by an agent-mode manifest records neither). +* `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here; v5.0: a byte-identical re-save is skipped, which still satisfies the rule); `records` maps PURL → the full manifest `PatchRecord` so a post-install `vex` can attest redirected patches with no manifest entry. The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). -**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). Semantics: +**get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (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, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (scan hosted has no prompt; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture (writes ONLY `.socket/manifest.json`; blobs held in memory; the nested apply never runs), then scan's vendor step runs — apply lock, **whole-manifest scope including `reconcile_dropped`**: every manifest record is verified/re-vendored and records whose patches left the manifest may have their vendored state reverted, exactly like `scan --mode vendored` (a stderr `[note]` names the count of other affected records; this blast radius is deliberate parity, stated loudly). JSON: get's envelope (with `applied` dropped — structurally zero under save-only) gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (the pre-failure reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`. 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, `scan --mode vendored`, and `--detached` runs: 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 `{action:"failed", 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 records ride `patches[]` / `download.patches[]`, the download phase still writes `.socket/manifest.json` (unchanged — an empty `{"patches": {}}` on a fresh project; a record seeded for another purl survives, re-serialized), the vendor step still runs (no event for the refused purl — unless `.socket/manifest.json` already held its record, in which case the vendor step's own preflight, shared with `vendor`, reports it `failed` with the same code and leaves any hosted wiring untouched), exit `partial_failure`/1. **`--detached`**: the same `download.patches[]` records with `download.downloaded: 0`, and no manifest at all (previously the view was fetched first and, for an alias install, the engine misreported `package_not_installed`). **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the record save and the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) 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, `--detached` 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 the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, detached parity, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_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 `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (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, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). +* **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`; blobs held in memory; 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: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`; `--dry-run` prints `[dry-run] Would download and vendor N patch(es).` 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 `{action:"failed", 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 records ride `patches[]` / `download.patches[]` with `downloaded: 0`, 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; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) 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 the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_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` records (`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 the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. 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 `not_installed` — consumers dispatch on the per-record `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)`-prefixed strings 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 patches:` listing deliberately shows ALL found patches (pre-narrowing, main's behavior) with the `[skip]` lines following; machine output (the prompt count, the JSON envelope) uses the kept set. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines. -* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached`, no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). +* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `repair --dry-run` also skips the final lock-file deletion. `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -154,7 +156,7 @@ Contract details: * **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, empty/missing manifest, all patches unverified, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). * **Built from the post-run manifest**, verified against on-disk state (unless `--vex-no-verify`). Generated for real applies, `--dry-run`, and read-only `scan` alike. * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. -* `apply`'s no-manifest early exit (the "No .socket folder found" success no-op) does **not** trigger VEX generation — there is nothing to attest. +* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold setup files or vendored state) does **not** trigger VEX generation — there is nothing to attest. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. * **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. @@ -206,14 +208,31 @@ in particular, are behavior changes that gate a version bump when implemented). in-scope ecosystems are *actually in a correctly patched state* — install hooks present **and** on-disk patch consistency verified (the `apply --check` invariant: every manifest file's hash matches `afterHash`). *(Implemented — `run_check` appends a `patch` entry per installed-but-drifted PURL via - `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift.)* + `append_patch_consistency_entries`; uninstalled packages and zero-file records are not drift. + v5.0: vendored patches are consulted from the vendor ledger's embedded `record`s and verified + against the committed artifact — a manifest-less vendored project is checked the same way.)* 5. **In-repo and committable.** `setup` writes only inside the working tree: `package.json`, - `pyproject.toml`/`requirements.txt`, the `Gemfile` + generated `.socket/bundler-plugin/`. Every - artifact is git-committable. It never writes outside + `pyproject.toml`/`requirements.txt`, `composer.json` (the `post-install-cmd`/`post-update-cmd` + hooks), the `Gemfile` + the generated `.socket/bundler-plugin/{plugins.rb,socket-patch.gemspec}` + and `.socket/.gitignore` (one line ignoring the machine-local stamp), and `.socket/manifest.json` + only when `--exclude` persists an exclusion (property 9). Every artifact is git-committable. + `setup --check` writes nothing, and an already-configured `setup` writes nothing unless + `--exclude` is passed explicitly. The `--exclude` persistence (v5.0) runs AFTER discovery and + the confirm prompt, as a read-modify-write under `<.socket>/apply.lock` (`setup` joins the + `--lock-timeout` contenders): a held or unopenable lock, or a manifest that cannot be read or + written, is reported as a `not persisting --exclude: — ` warning — never exit 1 — + and a byte-identical exclude list neither locks nor rewrites. `--check` (property 4) reads the + vendor ledger even without a manifest; a ledger it cannot read or parse is surfaced as a + `Warning: unreadable vendor state (…)` line (muted by `--silent`) plus a `vendor_ledger` `files[]` + entry with `status: error` — verdict `error`, exit 1 — never as a `configured` verdict. It never writes outside `--cwd` — no `$HOME`, no global `site-packages` (the Python `.pth` wheel is installed later by the - user's package manager, not by `setup`; the gem patch stamp is written under `Bundler.bundle_path` - by the plugin at `bundle install` time, not by `setup`). *(Implemented.)* + user's package manager, not by `setup`; the gem patch stamp is written by the plugin at + `bundle install` time, not by `setup`, at `.socket/gem-plugin-stamp` — machine-local, hence the + `.gitignore` line; the legacy stamp under `Bundler.bundle_path` is deleted by the plugin). These + files are **setup-owned residue**: `rollback`/`remove` never undo `setup`, so `.socket/.gitignore`, + `.socket/bundler-plugin/` and `gem-plugin-stamp` survive a full reversal (see the residue rule + under the rollback contract). *(Implemented — `crates/socket-patch-core/src/setup/gem/mod.rs`.)* 6. **Clone-portable.** Because all setup state is committed files, a fresh checkout on another host — CI, a deploy, a teammate's machine — inherits the setup state unchanged; `setup --check` passes on @@ -237,8 +256,11 @@ in particular, are behavior changes that gate a version bump when implemented). 8. **Graceful, exact remove.** `setup --remove` (optionally per-ecosystem via `--ecosystems`) restores the repo to its exact pre-setup state: manifests byte-for-byte, sibling scripts/dependencies preserved, keys that became empty dropped. Afterward `setup --check` reports needs-configuration - again. *(Implemented for the manifest edits — npm `package.json` and Python deps round-trip - byte-for-byte.)* + again. For gem projects it also removes the plugin dir, the stamp and its `.gitignore` line, and + (v5.0) prunes an emptied `.socket/` (non-recursive `remove_dir` — a `.socket/` still holding a + manifest, blobs, vendored state or a user-authored `.gitignore` is kept), so a project that never + ran `apply` is back to its pre-setup tree. *(Implemented for the manifest edits — npm + `package.json` and Python deps round-trip byte-for-byte.)* 9. **Nested workspaces, with exclude.** Setup applies to every subproject below the repo root: npm / yarn / pnpm / bun workspace members are all discovered and configured (pnpm is root-package-only by @@ -255,9 +277,9 @@ in particular, are behavior changes that gate a version bump when implemented). ### Per-ecosystem setup support -`setup` installs an automatic-repatch hook for the three ecosystems with a usable post-install / -startup hook (npm, pypi, gem) — plus **composer** when the binary is built with the opt-in `composer` -feature. The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but +`setup` installs an automatic-repatch hook for the four ecosystems with a usable post-install / +startup hook (npm, pypi, gem, composer — every ecosystem is built in unconditionally; there are no +ecosystem feature gates). The remaining ecosystems are **apply-only**: `socket-patch apply` patches them on demand, but there is no hook for `setup` to install, so `setup` is a `no_files` no-op for them. These are exactly the ecosystems for which property 7's **manual** declaration is intended (so their hand-applied patches still show up in VEX). @@ -359,7 +381,9 @@ wins, pinned by `find_by_purls_prefers_root_copy_over_nested_duplicate`). `setup` predates the v3.0 unified envelope and emits its own three shapes. They are stable as of v3.0; consumers may rely on these keys. All three share a `files[*]` entry shape; `kind` is one of -`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`, `gem_plugin_registration` (the last is +`package_json`, `pth`, `gemfile`, `gem_plugin`, `composer`, `patch` (`--check` property 4: a manifest +or ledger patch not applied on disk, `needs_configuration`), `vendor_ledger` (`--check`: a +`.socket/vendor/state.json` that cannot be read or parsed, `error`), `gem_plugin_registration` (the last is `setup --remove`-only: clearing bundler's machine-local `.bundle/plugin` registration of the wired plugin — emitted only when a registration existed; `status: error` carries the `bundler plugin uninstall socket-patch` remedy when it could not be cleared safely). @@ -455,6 +479,8 @@ per service outcome: | 401 / 403 grant / 5xx / network error | local build + `vendor_prebuilt_unavailable` | refuse | | `--offline` | local build | refuse (`vendor_service_offline_conflict`) | +**golang service leg staging (v5.0)**: the module zip is downloaded, extracted and `h1:`-verified in a `.socket-stage` sibling and swapped into place only afterwards; a failed re-download of a WIRED, present copy keeps the copy and its `replace` directive (previously both were torn down), while a missing copy still drops the dangling directive. + Coverage today: **npm** (all lock flavors), **pypi** (wheel — sdist falls back / refuses), **cargo** (download + extract the `.crate`), **golang** (download + extract the module zip, verify the `h1:` dirhash, wire the `replace`), **composer** (download + extract the dist zip), **gem** (download + @@ -498,8 +524,10 @@ or temporary patch files. Pre-existing `.socket/` artifacts (from a prior `apply are read in place; already-vendored purls re-stage patch content from the committed artifact itself (uuid-matched against the ledger, every harvested blob self-verified by its afterHash — so in-sync re-runs and fresh clones of vendored projects need no network); anything still missing is fetched -into memory via the patch-view endpoint. A vendored project's `.socket/` holds only -`manifest.json` (omitted in detached mode) and `vendor/`. +into memory via the patch-view endpoint. A vendored project's `.socket/` holds only `vendor/` +(v5.0 — vendored runs never write `manifest.json`; one exists only when standalone `vendor` was fed +by an agent-mode manifest, or as the `{"patches": {}}` husk left after a legacy record migrated +into the ledger). **Vendored artifact repair (v3.5)**: `repair` health-checks every ledger entry — per-file afterHashes inside the artifact plus, for file-shaped artifacts (`.tgz`/`.whl`), the whole file @@ -514,7 +542,8 @@ artifact is re-verified against the recorded fingerprint before the run counts i event; a mismatch removes the artifact and fails with `vendor_artifact_rebuild_failed`). Lockfile references to `.socket/vendor///...` with NO ledger coverage (the ledger was deleted wholesale) are RECONSTRUCTED: the uuid comes from the path (the recovery rule above), the -record from the manifest — or the patch API, yielding a *detached* entry with the record embedded +record from the manifest — or the patch API, yielding an entry with the record embedded (the same +`detached: true` + `record` shape every `scan`/`get --mode vendored` entry has) — and a fresh ledger entry is persisted with the rebuilt artifact's fingerprint. When nothing is installed and the ledger is gone, npm-family reconstruction has one more rung: the REWIRED lockfile still records the integrity of the packed vendored tarball, so the pristine copy is @@ -624,10 +653,11 @@ worse, lets a warm cache silently serve unpatched bytes): `source`/`checksum`, requirement lines, uv specifiers). Those are not recoverable offline, so `--revert` never guesses at unrecorded fragments: a missing ledger is an empty ledger (clean no-op plus the orphan-dir sweep), and entries whose recorded fragments no longer match are left - alone with warnings. Entries written by `scan --vendor --detached` additionally carry - `detached: true` and `record` (an embedded copy of the patch record — same committed-file trust - class as the manifest; artifact verification still re-hashes against its afterHashes and the - uuid-in-path cross-checks). + alone with warnings. Every entry written by `scan`/`get --mode vendored` (v5.0: the only + vendored posture) carries `detached: true` and `record` (an embedded copy of the patch record — + same committed-file trust class as the manifest; artifact verification still re-hashes against + its afterHashes and the uuid-in-path cross-checks); only standalone `vendor` fed by an agent-mode + manifest records neither. * **Re-vendor carries originals forward**: re-vendoring under a newer patch uuid rewrites the previous run's own wiring (`original: None` from the backend — it must never record a dangling `.socket/vendor/` pointer as pre-vendor state); the engine merges the TRUE pre-vendor originals @@ -635,11 +665,25 @@ worse, lets a warm cache silently serve unpatched bytes): still restores the registry fragments byte-for-byte. The old uuid's now-orphaned artifact dir is removed (`vendor_stale_artifact_removed`) unless another entry still references it. * `vendor --revert` restores the originals (fragments that no longer match — a user re-resolved — - are left alone with a `vendor_lock_entry_drifted` warning), removes the artifacts, prunes the - ledger, and sweeps orphan uuid dirs. It works without a manifest. + are left alone with a `vendor_lock_entry_drifted` warning; the drift-kept artifact and entry stay, + every backend alike — gem included as of v5.0, where a MISSING `Gemfile`/`Gemfile.lock` instead + warns `vendor_lockfile_missing` and still removes the artifact; composer / maven / nuget, whose + whole-file wiring cannot tell a converged fragment from a drifted one, keep the artifact exactly + while the live `composer.lock` / `pom.xml` / `nuget.config` still names its + `.socket/vendor//` dir — a file that no longer references it is warned about and the + artifact removed), removes the artifacts, prunes the + ledger, sweeps orphan uuid dirs, and (v5.0) prunes the now-empty `.socket/vendor//` and + `.socket/vendor/` levels — `.socket/` itself is removed by the lock guard when nothing else is + left. It works without a manifest: with no manifest and no ledger it is a clean exit-0 no-op. * Re-running `vendor` is idempotent (byte-stable lockfiles, deterministic artifacts → - `already_vendored` skips). Patches dropped from the manifest are auto-reverted at the start of - the next `vendor` run (`vendor_reconciled` events). + `already_vendored` skips). Manifest-tracked entries whose patches were dropped from the manifest + are auto-reverted at the start of the next `vendor` run (`vendor_reconciled` events); entries with + an embedded `record` have no manifest record and are exempt. Standalone `vendor` (no flags) is fed + by `.socket/manifest.json` only: with no manifest it is a clean exit-0 no-op whose human line names + the missing manifest — `No manifest found, nothing to vendor.`, or, when the vendor ledger holds + entries, `No manifest to vendor from; N vendored entr(y is|ies are) tracked in the ledger — + `socket-patch repair` verifies (it|them).` — and it never re-vendors from the ledger. This no-op + and `--revert` build no API client (v5.0), so no token advisory prints there. * **remove reverts vendoring**: `remove ` on a vendored patch restores the recorded lockfile fragments, deletes the artifact, and drops the ledger entry (envelope events `removed`/`vendor_reverted`, which do NOT bump `summary.removed` — that count stays "manifest @@ -650,9 +694,9 @@ worse, lets a warm cache silently serve unpatched bytes): artifact, the ledger entry (byte-identical — its already-reverted wiring records replay as silent no-ops on a later revert, per the liveness contract, and a re-vendor re-wires from the live lock probe), AND the manifest entry (`skipped`/`vendor_state_preserved`; `summary.removed` - stays 0), and skips all GC — equivalent to `rollback --preserve-state`. Detached entries - are removable by purl/uuid through the same command even though they have no manifest record - (`--skip-rollback` is refused there: reverting IS the removal). **Drift-keep fix (v5.0, + stays 0), and skips all GC — equivalent to `rollback --preserve-state`. Ledger entries with + no manifest record (every `scan`/`get --mode vendored` entry) are removable by purl/uuid through + the same command (`--skip-rollback` is refused there: reverting IS the removal). **Drift-keep fix (v5.0, bugfix)**: when the revert drift-keeps (`kept_artifact` — the lock changed under us and the backend left wiring + artifact alone), the manifest entry for that purl is now ALSO kept (`skipped`/`vendor_revert_kept`) — previously `remove` dropped it, stranding a live ledger @@ -665,7 +709,7 @@ worse, lets a warm cache silently serve unpatched bytes): redirect ledger unwinds those redirects too — per-purl for the supported ecosystems (cargo + npm-family), via the whole-ledger reverse replay when the identifier covers EVERY record (the same eligibility rule as `rollback`). A hosted-only match works with no manifest at all - (mirroring the detached-vendored escape). Unsupported-ecosystem hosted targets fail closed + (mirroring the manifest-less vendored escape). Unsupported-ecosystem hosted targets fail closed BEFORE the manifest mutation with top-level `hosted_revert_unsupported` (exit 1; remedy: unscoped `socket-patch rollback`, or re-run `scan --mode hosted`); a failed unwind or ledger persist is `hosted_revert_failed` (exit 1, manifest not modified). Successful unwinds ride the @@ -701,9 +745,12 @@ worse, lets a warm cache silently serve unpatched bytes): (`warnings[]` + stderr) that a `vendor` run must refresh the artifact — while `get … --mode vendored` (v3.6) re-vendors at the new uuid in the same run instead of warning (the vendor step immediately resolves the drift the warning describes). -* **Old-binary skew caveat**: a pre-detached `socket-patch` binary running `vendor` against a - checkout with detached entries cannot see the `detached` flag and will reconcile-revert them. - The ledger schema itself stays parseable both ways (additive optional fields). +* **Old-binary skew caveat**: EVERY `scan`/`get --mode vendored` entry is now detached-shaped, so a + `socket-patch` binary that predates the `detached` flag (pre-4.0) running `vendor` against such a + checkout cannot see the flag and will reconcile-revert every vendored entry; a 4.x binary honors + the flag but drives its own re-vendor from the manifest and finds nothing to do. Pin the CLI + version in CI when mixing generations. The ledger schema itself stays parseable both ways + (additive optional fields). ### Caveats (documented behavior, not bugs) @@ -740,20 +787,20 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / detached-vendored projects). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning, naming the quarantine remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. -2. **Agent leg** — the existing in-place restore machinery, unchanged: multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. -3. **Vendored leg** — each in-scope ledger entry (detached included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted, ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. +1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` project is manifest-less). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning; v5.0 distinguishes a ledger that cannot be READ — EACCES, a directory or FIFO squatting on the path — which is reported as such and left in place with a fix-the-permissions remedy, from MALFORMED JSON, which is quarantined to `redirect-state.json.corrupt` with the restore remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. +2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. +3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. 4. **Hosted leg** — see "Hosted unwind coverage" below. 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — `cleanup_unused_blobs` + diff/package-archive sweeps against the post-removal manifest, with beforeHash blobs pinned (synthetic afterHash-slot records) for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). -**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply: `[Roll back N patch(es) and remove them from the local manifest][, and delete M vendored artifact(s) (K detached — their embedded patch records are the only local copy)][, and unwind H hosted redirect(s)]?` — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply: `[Roll back N patch(es) and remove them from the local manifest][, and delete M vendored artifact(s) and their ledger records][, and unwind H hosted redirect(s)]?` (clauses joined by `, and `, first letter capitalized; a hosted ledger with leftover edits but no records gets `replay K leftover hosted redirect edit(s)` instead of the unwind clause) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). ### `--preserve-state` (opt-out, both `rollback` and `remove`) Restore the system but keep the local patch state for a later re-apply: manifest entries kept, vendored artifacts + ledger entries kept byte-identical (only the lockfile wiring is reverted; the already-reverted wiring records replay as silent no-ops on a later revert, and a re-vendor re-wires from the live lock), and all blob/archive GC skipped. **Hosted redirects have no preservable local state**: their ledger records describe live wiring only, so a preserve run still unwinds them and drops the records either way — surfaced as the `hosted_state_not_preservable` warning (re-run `scan --mode hosted` to re-wire). Caveat (documented): preserved vendored entries may be reclaimed by an explicit later `scan --prune` (user-invoked GC); `vendor` re-runs re-wire them. -**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. +**Replay fail-closed carve-outs (v5.0)**: the gem SECTION-MOVE record (`redirect_gemfile_lock_gem_source`) refuses in the replay — the writer records only the bare remote URLs, not the moved spec block, so a URL swap cannot invert the move (remedy: `scan --mode hosted` normalize). A socket-owned go.mod `replace` folded into a `replace ( … )` BLOCK and later refreshed also refuses (the ledger records the single-line spelling). Both keep their records + edits for a retry. **Ledger persistence rule**: rollback and remove persist the mutated redirect ledger whenever it changed — INCLUDING on partial-failure exits — so lockfile writes that already flushed are never stranded against a stale on-disk ledger. **Lock discipline**: all three state stores are LOADED under the apply lock (only cheap existence probes run before it), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties a ledger deletes the file — `redirect-state.json` and/or `vendor/state.json` — and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. ### Hosted unwind coverage @@ -769,7 +816,7 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_pnpm_trust_scaffold_modified`, plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_pnpm_trust_scaffold_modified`, `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. Previously this listed every vendor-owned skip | | `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) | | `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept | @@ -863,7 +910,7 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_SILENT` | `--silent` / `-s` | `false` | — | | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | | `SOCKET_YES` | `--yes` / `-y` | `false` | — | -| `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock`; unset/`0` = single non-blocking try. | +| `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock` on the lock-taking subcommands (incl. hosted/vendored `scan`/`get`); unset/`0` = single non-blocking try. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | | `SOCKET_FORCE` | `apply --force` / `-f`, `--update --force` | `false` | Local to `apply` and `--update`. | @@ -1036,8 +1083,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). v5.0: rollback no longer yields — its vendored leg reverts these entries by default, and its `vendored: []` array is reserved-empty (a corrupt vendor ledger surfaces via the `vendor_state_unreadable` warning + exit 1 — the skip cannot name purls, since naming them needs the ledger). Scan `--apply --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | | `vendor_reverted` | `removed` | remove: vendoring reverted (lock fragments restored, artifact + ledger entry gone) as part of removing the patch. | | `vendor_revert_failed` | top-level error | remove: the vendor revert failed; the manifest was NOT modified. | -| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a detached-only patch. | -| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the detached-only refusal above. | +| `vendor_state_retained` | `skipped` | remove `--skip-rollback`: vendor wiring + artifact deliberately left in place (the next `vendor` run reconciles the dropped entry). Also the top-level error code when `--skip-rollback` targets a vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch, which used to answer `not_found`). | +| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | | `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is the `vendoredPreserved: []` envelope array. | | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | | `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile redirect was unwound as part of removing the patch (`verified` on dry-run). Bypasses `summary.removed` like `vendor_reverted`. | @@ -1047,7 +1094,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted redirects were unwound and their ledger records dropped anyway — hosted has no preservable local state; re-run `scan --mode hosted` to re-wire. (`remove --preserve-state` prints the same note on stderr.) | | `out_of_scope_copies_restored` | rollback `warnings[]` | path-scoped rollback (v5.0): a selected patch had installed copies outside the given patterns; ALL copies were restored (patches are per-package). Informational — never flips the exit. | | `path_scope_excluded_supplements` | scan `warnings[]` | path-scoped scan (v5.0): lockfile-only / vendor-ledger supplement packages have no installed path and were excluded from the scoped scan; the detail carries the count. | -| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `--detached` runs, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | +| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | | `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | | `vendor_stale_artifact_removed` | `removed` | vendor / scan `--vendor`: re-vendor under a newer patch uuid removed the previous uuid's orphaned artifact dir. | @@ -1087,7 +1134,9 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | | `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | | `pypi_pipenv_installer_unsupported` | `failed` | vendor (pipenv): the installed Pipenv is older than 2018 and cannot consume vendored wheel references — upgrade Pipenv or use hosted mode. | -| `pypi_pipenv_version_mismatch` / `pypi_pipenv_invalid_wheel` | `failed` | vendor (pipenv): a category pins a different version than the patch (or the wheel filename carries no version) — refused before any write. | +| `pypi_pipenv_version_mismatch` | `failed` | vendor (pipenv): a category pins a different version than the patch — refused before any write. (`pypi_pipenv_invalid_wheel` retired in v5.0: the backend takes the orchestrator's resolved version instead of parsing the wheel filename.) | +| `pypi_poetry_symlink_unsupported` / `pypi_pipenv_symlink_unsupported` / `pypi_requirements_symlink_unsupported` | `failed` | vendor (pypi, v5.0): a target file (`pyproject.toml` / `poetry.lock`, `Pipfile` / `Pipfile.lock`, or any planned `requirements*.txt`) is a symlink — refused before any write on wire AND on revert (the revert keeps the artifact, `kept_artifact`); the twins of the existing pdm/uv symlink refusals. | +| `pypi_poetry_changed` / `pypi_pdm_changed` / `pypi_pipenv_changed` / `pypi_uv_changed` | `failed` | vendor (pypi, v5.0): the lock / project file changed between the read that planned the edit and the first write — refused before any write (worded like `pypi_lock_changed`: " changed during vendoring; re-run"). | | `pypi_pipenv_stale_install` | `skipped` (warning) | vendor (pipenv): the vendored twin of `redirect_pypi_stale_install` — the project's venv still holds the upstream release Pipenv will not reinstall over; the detail names the `pipenv run pip uninstall -y && pipenv sync` remedy. | | `pypi_pipenv_installer_unknown` | `skipped` (warning) | vendor (pipenv): no `pipenv` answered on PATH; the vendored references assume Pipenv 2018 or later (7–11 cannot consume them — use hosted mode there); `SOCKET_PIPENV_MAJOR` pins the release. | | `vendor_lock_entry_relocked` | revert `warnings[]` | vendor `--revert` / rollback (pipenv): a relock regenerated the wired entry to a registry reference, or removed it; the record is retired (artifact removed, ledger entry dropped) instead of drift-kept. | @@ -1108,7 +1157,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). Both stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NEITHER store has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead). v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` also proceeds manifest-less when the identifier matches a detached vendored entry or a hosted redirect-ledger record. | +| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead). v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | | `manifest_invalid` | list, remove | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove | I/O error reading manifest. | | `apply_failed` | apply | apply pipeline error before any patch ran. | @@ -1121,7 +1170,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified |--------------|---| | `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | -| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, both additive and absent on manifest entries) | +| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | @@ -1151,7 +1200,7 @@ One command is **intentionally not** plain-envelope and will stay that way (not Per-patch records emitted in `patches[]` (and in `scan --apply`'s `apply.patches[*]`) carry the same metadata regardless of which command -produced them — both flow through `download_and_apply_patches` in +produced them — both flow through `download_and_apply_patches_with` in `src/commands/get.rs`. The shape is stable as of v3.0; consumers can rely on these keys. @@ -1198,6 +1247,15 @@ installed-version narrowing; see "get --mode and installed narrowing"), the same calm-skip vocabulary as scan's pre-download partitions. Absent on the classic "already in manifest" skip. +Vendored mode (v5.0) uses the detached download vocabulary instead: +`get --mode vendored`'s `patches[]` and `scan --mode vendored`'s +`download.patches[]` carry `action: "downloaded" | "skipped" | "failed"` +(no `added`/`updated` — the vendor ledger, not the manifest, tracks patch +generations; a `downloaded` record whose purl the ledger already holds at +another uuid carries the additive `oldUuid`, and its human `[fetch]` line +reads ` (replacing )`) beside the same metadata keys, and +the enclosing object carries `downloaded: N` and `detached: true`. + Additive: a `failed` record may ALSO carry `errorCode` beside `error` — today exactly the vendored-mode Bun preflight refusals (`vendor_bun_lockb_invalid`, `vendor_lockfile_missing`, @@ -1349,7 +1407,7 @@ socket-patch apply --json | jq ' Exit `0` when `status` is `success`, `noManifest`, or `notFound`-with-zero-failed. Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) or `error`. -`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. +`apply` with no manifest at all is a clean exit-0 no-op (`status: "noManifest"`), and an **empty** manifest (zero patches) is a plain `success` exit 0 — this is load-bearing for the install hooks, which run `apply` on every install. A fully rolled-back agent project therefore keeps `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block): the v5.0 residue rule never deletes a zero-patch manifest, precisely so these hook exits (and `list`'s 0-vs-1 below) never flip. Pinned by `tests/in_process_edge_cases.rs` and `tests/cli_dry_run_paths_e2e.rs`. **One carve-out**: a yarn-berry Plug'n'Play layout (`.pnp.*` loader at `--cwd`) refuses with the loud `yarn_pnp_unsupported` error (exit 1) even when no manifest exists — `scan` cannot discover PnP packages (they live inside `.yarn/cache/*.zip`, no `node_modules/`) and therefore never writes a manifest, so without the carve-out the documented refusal was unreachable and a PnP project's only signal was the calm noManifest exit. Pinned by `tests/e2e_safety_yarn_pnp.rs`. ## Exit codes @@ -1359,7 +1417,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | | `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `scan PATHS` combined with `--mode hosted`/`--mode vendored` (same enforcement point), `remove --preserve-state --skip-rollback` (the no-op quadrant; flag- or env-sourced alike), an unparseable path glob on `scan`/`rollback`, `repair --offline --download-only`. `vex` also exits `2` on hard errors before document generation (see its tri-state table below). **Carve-out**: `get`'s self-enforced conflicts have always exited `1` via its error envelope (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`) and the v3.6 `--mode hosted\|vendored --save-only` conflict deliberately follows that get-internal precedent — changing the existing ones to `2` would be a MAJOR exit-code change | -`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing. Every mutating subcommand returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: @@ -1437,7 +1495,7 @@ launcher-gem legs are gated on the GitHub release — with its binaries and Every item in this document is locked in by at least one of: - **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. -- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_with_uuid_fallback`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, `format_severity`, `color`, and the JSON serializers. +- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, `format_severity`, `color`, and the JSON serializers. - **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 3953225b..c2fbdbf1 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -17,10 +17,12 @@ use std::path::{Path, PathBuf}; use clap::Args; -use socket_patch_core::api::client::ApiClientEnvOverrides; +use socket_patch_core::api::client::{ + resolve_ambient_credentials, ApiClient, ApiClientEnvOverrides, +}; use socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH; use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::vendor::VendorSource; +use socket_patch_core::vendor::{VendorServiceConfig, VendorSource}; /// clap value-parser for each `--ecosystems` / `SOCKET_ECOSYSTEMS` token. /// @@ -258,8 +260,10 @@ pub struct GlobalArgs { /// positive value retries with a 100 ms backoff until the lock /// frees or the budget elapses. Only meaningful for the lock- /// contending subcommands (`apply`, `rollback`, `repair`, `remove`, - /// `vendor`, and the vendored modes of `scan`/`get`); other - /// commands accept it silently. + /// `vendor`, `setup --exclude`'s manifest write, and the hosted / + /// vendored modes of `scan`/`get`); other commands accept it + /// silently. Every holder removes the lock file on exit, so a + /// leftover from a crashed run never contends. #[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] pub lock_timeout: Option, @@ -308,6 +312,43 @@ impl GlobalArgs { } } + /// The project root whose `.socket/` state stores — manifest, vendor + /// ledger, redirect ledger — belong together: the RESOLVED manifest's + /// directory, stepping out of a standard `.socket/` layout when the + /// manifest lives in one. For the default `/.socket/manifest.json` + /// this is exactly `cwd`; for a `--manifest-path` into another project + /// it is that project's root (its `.socket` parent's parent); for a + /// bare file like `--manifest-path /tmp/x/abs.json` it is the file's + /// own directory. Every command that reads more than one store must + /// derive them from THIS root, so `--manifest-path` can never + /// interleave two projects' state (CLI_CONTRACT.md: both stores always + /// come from the SAME project). + pub(crate) fn project_root(&self) -> PathBuf { + let manifest_path = self.resolved_manifest_path(); + match manifest_path.parent() { + Some(dir) + if dir.file_name() + == Some(std::ffi::OsStr::new( + socket_patch_core::constants::SOCKET_DIR, + )) => + { + dir.parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| self.cwd.clone()) + } + Some(dir) => dir.to_path_buf(), + None => self.cwd.clone(), + } + } + + /// The directory the manifest lives in — where `apply.lock`, `blobs/`, + /// `diffs/` and `packages/` sit (`/.socket` by default). The one + /// derivation every lock acquire and artifact probe uses; see + /// [`socket_dir_of`] for callers holding a raw manifest path. + pub(crate) fn socket_dir(&self) -> PathBuf { + socket_dir_of(&self.resolved_manifest_path(), &self.cwd) + } + /// Build [`ApiClientEnvOverrides`] from the CLI flags. /// /// Every field is forwarded as `Some(_)` only when set and non-empty. @@ -324,6 +365,57 @@ impl GlobalArgs { proxy_url: self.proxy_url.clone().filter(|s| !s.is_empty()), } } + + /// The `(api_token, org_slug)` telemetry is attributed with, resolved + /// through the API client's own credential chain (flag → the + /// `SOCKET_NO_API_TOKEN` veto → env → `socket login` config) WITHOUT + /// building a client. For the purely local commands (`list`, `setup`, + /// `vex`): a client would add the org-slug auto-resolve round-trip and + /// the "No SOCKET_API_TOKEN set" advisory to a command that needs + /// neither, while anything less than the full chain reported a + /// `socket login`-only caller's events anonymously to the public proxy + /// — off the on-prem host every other command reports to. + pub(crate) fn telemetry_credentials(&self) -> (Option, Option) { + let overrides = self.api_client_overrides(); + resolve_ambient_credentials(overrides.api_token, overrides.org_slug) + } + + /// The vendoring-service config every vendor entry point (`vendor`, + /// `scan`/`get --mode vendored`) builds from the same flags — + /// `--vendor-source` / `--vendor-url` / `--patch-server-url` / + /// `--offline` — so they commit byte-identical artifacts and lock + /// integrity for the same patch. `client` is the run-level API client + /// (moved in; the service reuses it for the package-reference request) + /// and `use_public_proxy` its proxy-fallback state. `vendor_source` was + /// validated by clap, so the parse cannot fail; the `auto` default is + /// the defensive fallback. A pure assembler (no async, no network). + pub(crate) fn vendor_service_config( + &self, + client: Option, + use_public_proxy: bool, + ) -> VendorServiceConfig { + VendorServiceConfig { + source: VendorSource::parse(&self.vendor_source).unwrap_or_default(), + client, + use_public_proxy, + vendor_url: self.vendor_url.clone(), + patch_server_url: self.patch_server_url.clone(), + offline: self.offline, + } + } +} + +/// The `.socket/`-role directory for `manifest_path`: its parent, falling +/// back to `cwd` for a bare relative file name — never `"."`, which is +/// wrong under a non-default `--cwd`. [`GlobalArgs::resolved_manifest_path`] +/// always joins a relative path onto `cwd`, so the fallback is reachable +/// only for callers handed an unresolved path. +pub(crate) fn socket_dir_of(manifest_path: &Path, cwd: &Path) -> PathBuf { + manifest_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.to_path_buf()) } /// Apply CLI-flag toggles for env-driven knobs by mirroring them into env @@ -530,9 +622,10 @@ mod tests { } /// Clear the extra env the core telemetry gate reads beyond the - /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` and the - /// legacy `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests - /// below can't pass or fail vacuously. Restores afterwards. + /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` — the + /// kill-switch socket-cli's vitest suite relies on — and the legacy + /// `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests below + /// can't pass or fail vacuously. Restores afterwards. fn with_clean_telemetry_env(f: impl FnOnce()) { with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f); } @@ -737,6 +830,76 @@ mod tests { }); } + // ---- vendor_service_config ------------------------------------------ + // Moved from scan's vendored flow: the config every vendor entry point + // builds must be the same assembler, so `scan --mode vendored` and a + // plain `vendor` commit byte-identical artifacts for the same patch. + + fn common_with_source(source: &str) -> GlobalArgs { + GlobalArgs { + vendor_source: source.to_string(), + ..Default::default() + } + } + + /// Regression: scan's vendored flow must build its service config FROM + /// `--vendor-source`, not hardcode build-only (the pre-fix `service = + /// None`). Under the default (`auto`), the config must permit the + /// vendoring service exactly as the `vendor` command's default does — + /// otherwise `scan --mode vendored` silently builds locally while a + /// plain `vendor` service-downloads, and the two commit different bytes / + /// lock integrity for the same patch (lock churn / merge conflicts). + #[test] + fn vendor_service_config_default_source_permits_service() { + let cfg = common_with_source("auto").vendor_service_config(None, false); + assert_eq!(cfg.source, VendorSource::Auto); + assert!( + cfg.source.may_use_service(), + "the default must be able to use the service (matching `vendor`)" + ); + assert!(!cfg.source.requires_service()); + assert!(cfg.client.is_none()); + assert!(!cfg.use_public_proxy); + } + + /// `--vendor-source service` reaches the fail-closed service path and + /// `--vendor-source build` never contacts the service. + #[test] + fn vendor_service_config_honors_service_and_build_sources() { + let cfg = common_with_source("service").vendor_service_config(None, true); + assert_eq!(cfg.source, VendorSource::Service); + assert!(cfg.source.requires_service()); + assert!( + cfg.use_public_proxy, + "the proxy-fallback state threads through" + ); + + let cfg = common_with_source("build").vendor_service_config(None, false); + assert_eq!(cfg.source, VendorSource::Build); + assert!(!cfg.source.may_use_service()); + } + + /// The service overrides (`--vendor-url` / `--patch-server-url` / + /// `--offline`) thread through unchanged, so every entry point targets + /// the same hosts. + #[test] + fn vendor_service_config_threads_overrides_through() { + let common = GlobalArgs { + vendor_source: "service".to_string(), + vendor_url: Some("https://vendor.example".to_string()), + patch_server_url: Some("https://patch.example".to_string()), + offline: true, + ..Default::default() + }; + let cfg = common.vendor_service_config(None, false); + assert_eq!(cfg.vendor_url.as_deref(), Some("https://vendor.example")); + assert_eq!( + cfg.patch_server_url.as_deref(), + Some("https://patch.example") + ); + assert!(cfg.offline); + } + /// The new URL knobs flow through to the parsed args from CLI and env. #[test] #[serial_test::serial] @@ -941,6 +1104,37 @@ mod tests { assert!(o.org_slug.is_none()); } + /// Telemetry attribution runs the client's credential chain over the + /// same overrides: explicit values — the flag, or the env var clap folds + /// into the same field — are used verbatim, and empty means "unset" + /// (`Some("")` would build a malformed `/v0/orgs//telemetry` URL and an + /// empty `Bearer ` header). The ambient layers below the flags are + /// pinned in core (`resolve_ambient_credentials_*`) and end-to-end by + /// `tests/cli_config_fallback.rs::list_telemetry_follows_socket_cli_login`. + #[test] + fn telemetry_credentials_prefer_explicit_values_and_treat_empty_as_unset() { + let explicit = GlobalArgs { + api_token: Some("sktsec_flag_api".to_string()), + org: Some("flag-org".to_string()), + ..GlobalArgs::default() + }; + assert_eq!( + explicit.telemetry_credentials(), + ( + Some("sktsec_flag_api".to_string()), + Some("flag-org".to_string()) + ) + ); + let empty = GlobalArgs { + api_token: Some(String::new()), + org: Some(String::new()), + ..GlobalArgs::default() + }; + let (api_token, org_slug) = empty.telemetry_credentials(); + assert_ne!(api_token.as_deref(), Some("")); + assert_ne!(org_slug.as_deref(), Some("")); + } + /// Empty strings for url/token/org are filtered out, not forwarded as /// `Some("")` — otherwise an empty CLI value would mask env-var fallback. #[test] @@ -1003,6 +1197,68 @@ mod tests { ); } + /// The default layout: `/.socket/manifest.json` → the project + /// root is `cwd` and the socket dir is `/.socket`. + #[test] + fn project_root_and_socket_dir_for_default_layout() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + ..GlobalArgs::default() + }; + assert_eq!(args.project_root(), PathBuf::from("/work/project")); + assert_eq!( + args.socket_dir(), + PathBuf::from("/work/project").join(".socket") + ); + } + + /// `--manifest-path` into ANOTHER project's `.socket/`: every store + /// (manifest, vendor ledger, redirect ledger, lock) resolves against + /// that project — its `.socket` parent's parent — never the cwd. + #[test] + fn project_root_steps_out_of_a_foreign_socket_dir() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + manifest_path: "../other/.socket/manifest.json".to_string(), + ..GlobalArgs::default() + }; + let other = PathBuf::from("/work/project").join("../other"); + assert_eq!(args.project_root(), other); + assert_eq!(args.socket_dir(), other.join(".socket")); + } + + /// A bare manifest file outside any `.socket/` layout: the file's own + /// directory plays both roles. + #[test] + fn project_root_of_a_bare_manifest_file_is_its_directory() { + let args = GlobalArgs { + cwd: PathBuf::from("/work/project"), + manifest_path: "custom/mp.json".to_string(), + ..GlobalArgs::default() + }; + let custom = PathBuf::from("/work/project").join("custom"); + assert_eq!(args.project_root(), custom); + assert_eq!(args.socket_dir(), custom); + } + + /// `socket_dir_of` on a raw relative file name falls back to `cwd`, + /// never to `"."` (wrong under a non-default `--cwd`); a resolved path + /// yields its parent. + #[test] + fn socket_dir_of_bare_relative_name_falls_back_to_cwd() { + assert_eq!( + socket_dir_of(Path::new("manifest.json"), Path::new("/work/project")), + PathBuf::from("/work/project"), + ); + let resolved = PathBuf::from("/work/project") + .join(".socket") + .join("manifest.json"); + assert_eq!( + socket_dir_of(&resolved, Path::new("/elsewhere")), + PathBuf::from("/work/project").join(".socket"), + ); + } + /// `parse_supported_ecosystem` accepts every supported ecosystem name /// and returns it verbatim. #[test] diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 4e07cdea..bedeef21 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1,5 +1,6 @@ use clap::Args; -use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::api::blob_fetcher::get_missing_blobs; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; use socket_patch_core::crawlers::{ detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager, RubyCrawler, @@ -9,6 +10,7 @@ use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRec use socket_patch_core::patch::apply::{ apply_package_patch, verify_file_patch, ApplyResult, MismatchPolicy, PatchSources, VerifyStatus, }; +use socket_patch_core::patch::apply_lock::LockGuard; use socket_patch_core::patch::redirect::golang_local::{ apply_go_redirect, reconcile_go_redirects, verify_go_redirect_state, }; @@ -72,6 +74,7 @@ async fn ensure_blobs_for_mismatches( all_packages: &HashMap>, vendored_purls: &HashSet, staged: &mut StagedSources, + client: &ApiClient, ) { if args.common.strict && !args.force { return; // strict fails on mismatch — nothing to fetch @@ -116,9 +119,8 @@ async fn ensure_blobs_for_mismatches( } return; }; - let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; let _ = socket_patch_core::api::blob_fetcher::fetch_blobs_by_hash( - &needed, blobs_path, &client, None, + &needed, blobs_path, client, None, ) .await; } @@ -150,6 +152,11 @@ async fn ensure_blobs_for_mismatches( /// by contrast, mirrors the apply loop's representative check against the /// FIRST copy (release-variant ecosystems install one directory per /// `package@version`). +/// +/// Only a mismatched file whose afterHash blob is NOT staged can queue a +/// fetch, so the probe first decides that with metadata probes alone and +/// hashes only the files that can still matter: the common fully-cached +/// run hashes nothing here (the apply loop re-verifies everything anyway). async fn mismatch_blob_gaps( manifest: &PatchManifest, all_packages: &HashMap>, @@ -158,6 +165,18 @@ async fn mismatch_blob_gaps( force: bool, ) -> HashSet { let mut needed: HashSet = HashSet::new(); + let missing = get_missing_blobs(manifest, blobs_path).await; + if missing.is_empty() { + return needed; + } + // A record can queue a fetch only through a content-modifying file + // (non-empty beforeHash) whose afterHash blob is missing. + let can_queue = |record: &PatchRecord| { + record + .files + .values() + .any(|f| !f.before_hash.is_empty() && missing.contains(&f.after_hash)) + }; for (purl, pkg_paths) in all_packages { let Some(first_path) = pkg_paths.first() else { continue; @@ -177,6 +196,9 @@ async fn mismatch_blob_gaps( { continue; } + if !records.iter().any(|(_, record)| can_queue(record)) { + continue; + } let gated = variant_eco && !force && (records.len() > 1 @@ -184,6 +206,9 @@ async fn mismatch_blob_gaps( .first() .is_some_and(|(key, _)| key.as_str() != stripped)); for (_, record) in records { + if !can_queue(record) { + continue; + } if gated { if let Some((file_name, file_info)) = representative_file(&record.files) { let status = verify_file_patch(first_path, file_name, file_info) @@ -195,16 +220,12 @@ async fn mismatch_blob_gaps( } } for (file_name, info) in &record.files { - if info.before_hash.is_empty() { + if info.before_hash.is_empty() || !missing.contains(&info.after_hash) { continue; } for pkg_path in pkg_paths { let verify = verify_file_patch(pkg_path, file_name, info).await; - if verify.status == VerifyStatus::HashMismatch - && tokio::fs::metadata(blobs_path.join(&info.after_hash)) - .await - .is_err() - { + if verify.status == VerifyStatus::HashMismatch { needed.insert(info.after_hash.clone()); break; // the fetch is per-hash; one drifted copy queues it } @@ -273,17 +294,23 @@ pub(crate) fn is_local_go(purl: &str, common: &GlobalArgs) -> bool { && Ecosystem::from_purl(purl) == Some(Ecosystem::Golang) } -/// Whether local-go redirects are in scope (local mode + golang not filtered out -/// by `--ecosystems`). Gates reconcile / `--check`. -fn go_in_local_scope(common: &GlobalArgs) -> bool { +/// Whether this run can touch `eco`'s LOCAL install tree at all: local mode +/// (a `--global` / `--global-prefix` run crawls a different tree, so the +/// checkout says nothing about what it will patch) with the ecosystem not +/// filtered out by `--ecosystems`. The filter check is the exact `cli_name` +/// match `partition_purls` applies — clap admits no alias or case variant — +/// so a scope decided here can never diverge from the crawl scope. Gates +/// the local-go reconcile / `--check` (golang) and the yarn-PnP refusal +/// (npm: the refusal is about THIS run's packages living inside +/// `.yarn/cache/*.zip`, so a run that never crawls the checkout's +/// `node_modules` must not be refused by its layout). +fn eco_in_local_scope(common: &GlobalArgs, eco: Ecosystem) -> bool { if common.global || common.global_prefix.is_some() { return false; } match &common.ecosystems { None => true, - Some(list) => list - .iter() - .any(|e| e.eq_ignore_ascii_case("golang") || e.eq_ignore_ascii_case("go")), + Some(list) => list.iter().any(|e| e == eco.cli_name()), } } @@ -329,7 +356,7 @@ async fn try_local_go_apply( /// After the apply loop: prune local-go redirects whose patches were dropped /// from the manifest. No-op unless local go is in scope. async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet) { - if !go_in_local_scope(common) { + if !eco_in_local_scope(common, Ecosystem::Golang) { return; } let desired: HashSet = target_manifest_purls @@ -387,7 +414,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { { use socket_patch_core::patch::redirect::golang_local::Drift as GoDrift; - if go_in_local_scope(&args.common) { + if eco_in_local_scope(&args.common, Ecosystem::Golang) { // Vendored modules are excluded: their replace directives point at // `.socket/vendor/golang/` (the verify engine skips Vendor-owned // entries) and their state is audited by `vendor`, not `--check`. @@ -605,38 +632,14 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent PatchEvent::new(PatchAction::Applied, purl).with_files(files) } -/// Whether this run can touch `--cwd`'s npm `node_modules` at all: local -/// mode (a `--global` / `--global-prefix` run crawls a different tree, so -/// the checkout's layout says nothing about what it will patch) with npm -/// not filtered out by `--ecosystems`. Gates the yarn-PnP refusal — the -/// refusal is about THIS run's packages living inside `.yarn/cache/*.zip`, -/// so a run that never crawls the checkout's `node_modules` must not be -/// refused by its layout. The filter check is the exact `cli_name` match -/// `partition_purls` applies, so the refusal scope can never diverge from -/// the crawl scope. -fn npm_in_local_scope(common: &GlobalArgs) -> bool { - if common.global || common.global_prefix.is_some() { - return false; - } - match &common.ecosystems { - None => true, - Some(list) => list.iter().any(|e| e == Ecosystem::Npm.cli_name()), - } -} - /// True when the manifest records at least one npm patch — the only kind a /// PnP layout can block (a polyglot repo's pypi/gem/go patches live outside -/// `node_modules` and apply fine). An unreadable or vanished manifest -/// returns false so the ordinary manifest error paths surface instead of a -/// misdirected layout refusal. -async fn manifest_targets_npm(manifest_path: &Path) -> bool { - match read_manifest(manifest_path).await { - Ok(Some(m)) => m - .patches - .keys() - .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Npm)), - _ => false, - } +/// `node_modules` and apply fine). +fn manifest_targets_npm(manifest: &PatchManifest) -> bool { + manifest + .patches + .keys() + .any(|p| Ecosystem::from_purl(p) == Some(Ecosystem::Npm)) } /// Print the yarn-PnP refusal (JSON envelope or human stderr) and return @@ -668,14 +671,12 @@ fn refuse_yarn_pnp(args: &ApplyArgs) -> i32 { pub async fn run(args: ApplyArgs) -> i32 { apply_env_toggles(&args.common); - let (telemetry_client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = args.common.resolved_manifest_path(); - // Check if manifest exists - exit successfully if no .socket folder is set up + // No manifest → nothing to apply: a clean exit-0 no-op (load-bearing + // for the install hooks, which run `apply --silent` on every install). + // Nothing below this gate is touched — no API client (its config read, + // stderr advisory and org-slug round-trip), no lock, no `.socket/`. if tokio::fs::metadata(&manifest_path).await.is_err() { // A yarn-PnP layout refuses loudly even with no manifest: scan // cannot discover PnP packages (they live inside .yarn/cache zips), @@ -686,7 +687,7 @@ pub async fn run(args: ApplyArgs) -> i32 { // Scoped to runs that would actually crawl this checkout's // node_modules: a --global/--global-prefix run or an --ecosystems // filter excluding npm never touches it. - if npm_in_local_scope(&args.common) + if eco_in_local_scope(&args.common, Ecosystem::Npm) && matches!( detect_npm_pkg_manager(&args.common.cwd), NpmPkgManager::YarnBerryPnP @@ -700,7 +701,10 @@ pub async fn run(args: ApplyArgs) -> i32 { env.dry_run = args.common.dry_run; println!("{}", env.to_pretty_json()); } else if !args.common.silent { - println!("No .socket folder found, skipping patch application."); + // Names the manifest, not the folder: hosted- and vendored-mode + // projects have a `.socket/` (their ledgers live under + // `.socket/vendor/`) and still nothing for `apply` to do. + println!("No patch manifest found; nothing to apply."); } return 0; } @@ -713,12 +717,17 @@ pub async fn run(args: ApplyArgs) -> i32 { return run_check(&args, &manifest_path).await; } + // The run's ONE API client — built past both read-only exits above (a + // hook on a manifest-less project or a CI `--check` never pays its + // config read, stderr advisory or org-slug round-trip) and BEFORE the + // lock, so none of that lengthens the lock hold. It serves the staging + // fetch, the mismatch blob top-up and telemetry. + let (client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; + // Serialize against concurrent socket-patch runs targeting the same - // `.socket/` directory. The guard releases on function return; see - // `socket_patch_core::patch::apply_lock`. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let _lock = match acquire_or_emit( - socket_dir, + // `.socket/` directory; see `socket_patch_core::patch::apply_lock`. + let lock = match acquire_or_emit( + &args.common.socket_dir(), Command::Apply, args.common.json, args.common.dry_run, @@ -728,6 +737,45 @@ pub async fn run(args: ApplyArgs) -> i32 { Err(code) => return code, }; + run_locked(args, manifest_path, &client, lock).await +} + +/// The locked half of `apply`: everything from the manifest read on — the +/// package-manager layout gate, the apply loop, embedded VEX, output and +/// telemetry — over a `lock` the caller already holds and the caller's +/// `client`. [`run`] takes the lock itself; agent-mode `get` and +/// `scan --apply/--sync` call this straight after their manifest write, so +/// download → manifest write → apply is ONE lock window (a same-process +/// re-acquire would contend) and the nested apply never builds a second +/// client. `lock` is released explicitly once every mutation is done +/// (output and a possibly slow telemetry POST must not keep a sibling +/// waiting), otherwise on return. +pub(crate) async fn run_locked( + args: ApplyArgs, + manifest_path: PathBuf, + client: &ApiClient, + lock: LockGuard, +) -> i32 { + let api_token = client.api_token().cloned(); + let org_slug = client.org_slug().cloned(); + + // ONE parse of the manifest for the whole run — the PnP gate and the + // apply loop (embedded VEX re-reads it by design, after the writes). + // Apply never modifies it, so a read under the lock is final. `Ok(None)` + // (vanished since the existence probe above) and a read/parse error take + // the same exit as every other apply failure. + let manifest = match read_manifest(&manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + lock.release(); + return report_apply_failure(&args, "Invalid manifest", &api_token, &org_slug).await; + } + Err(e) => { + lock.release(); + return report_apply_failure(&args, &e.to_string(), &api_token, &org_slug).await; + } + }; + // Package-manager layout detection. yarn-berry PnP keeps packages // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` — // the npm crawler can't reach them and rewriting zips is a @@ -735,12 +783,13 @@ pub async fn run(args: ApplyArgs) -> i32 { // `yarn patch` — but only when an npm patch is actually in scope: // a polyglot repo's pypi/gem/go patches apply fine under PnP, and a // global-tree or non-npm `--ecosystems` run never crawls this - // checkout's node_modules at all. pnpm gets an informational event; - // the CoW guard in `apply_file_patch` does the substantive safety - // work. + // checkout's node_modules at all. pnpm gets an informational note; + // the substantive safety is core's rename-over write + // (`utils::fs::atomic_write_bytes` never touches the store's shared + // inode). match detect_npm_pkg_manager(&args.common.cwd) { NpmPkgManager::YarnBerryPnP => { - if npm_in_local_scope(&args.common) && manifest_targets_npm(&manifest_path).await { + if eco_in_local_scope(&args.common, Ecosystem::Npm) && manifest_targets_npm(&manifest) { return refuse_yarn_pnp(&args); } } @@ -750,9 +799,9 @@ pub async fn run(args: ApplyArgs) -> i32 { "Note: pnpm layout detected. Copy-on-write will keep the global store untouched." ); } - // Non-fatal — CoW handles the safety. JSON consumers see - // the layout-detected info in the apply envelope's - // existing events (no separate event added here yet). + // Non-fatal — the rename-over write handles the safety. JSON + // consumers see the layout-detected info in the apply + // envelope's existing events (no separate event added here yet). } NpmPkgManager::Bun => { if !args.common.json && !args.common.silent { @@ -761,7 +810,7 @@ pub async fn run(args: ApplyArgs) -> i32 { ); } // Same shape as pnpm: bun hard-links from its global - // install cache by default. The CoW guard handles the + // install cache by default. The rename-over write handles the // safety; this is informational only. } // Exhaustive on purpose (no `_`): a new package-manager layout must @@ -770,7 +819,7 @@ pub async fn run(args: ApplyArgs) -> i32 { NpmPkgManager::Npm | NpmPkgManager::YarnClassic | NpmPkgManager::Unknown => {} } - match apply_patches_inner(&args, &manifest_path).await { + match apply_patches_inner(&args, manifest, client).await { Ok(ApplyOutcome { success, results, @@ -783,6 +832,21 @@ pub async fn run(args: ApplyArgs) -> i32 { .filter(|r| r.success && !r.files_patched.is_empty()) .count(); + // Applied-with-advisory results: the bytes ARE patched, but a + // post-write ownership restore was not permitted (core carries + // it as `error` on a SUCCESSFUL result, where the event mapper + // rightly ignores it). It rides the run-warning channel so it + // is never silent. + let mut run_warnings = run_warnings; + run_warnings.extend(results.iter().filter_map(|r| { + let note = r.error.as_deref().filter(|_| r.success)?; + note.contains(socket_patch_core::patch::apply::OWNERSHIP_NOT_RESTORED_MARKER) + .then(|| RunWarning { + code: "ownership_not_restored".to_string(), + detail: format!("{}: {note}", normalize_purl(&r.package_key)), + }) + })); + // Run-level advisories + best-effort fallback-home skips on the // human path: one gated stderr line each. `--silent` is // errors-only, and under `--json` the envelope copies below are @@ -815,6 +879,9 @@ pub async fn run(args: ApplyArgs) -> i32 { None }; let vex_failed = matches!(vex_result, Some(Err(_))); + // Every mutation — the patches and the VEX attestation — is + // done: release the lock before output and telemetry. + lock.release(); if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -1036,28 +1103,41 @@ pub async fn run(args: ApplyArgs) -> i32 { } } Err(e) => { - track_patch_apply_failed( - &e, - args.common.dry_run, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - if args.common.json { - let mut env = Envelope::new(Command::Apply); - env.dry_run = args.common.dry_run; - env.mark_error(EnvelopeError::new("apply_failed", e.clone())); - println!("{}", env.to_pretty_json()); - } else { - // Errors print even under --silent ("errors only", never - // "nothing"): exit 1 with no message would be undiagnosable. - eprintln!("Error: {e}"); - } - 1 + lock.release(); + report_apply_failure(&args, &e, &api_token, &org_slug).await } } } +/// The one apply-failure exit: `apply_failed` telemetry, then the error +/// envelope (`--json`) or an `Error:` line that prints even under +/// `--silent` ("errors only", never "nothing" — exit 1 with no message +/// would be undiagnosable), exit 1. Shared by the manifest read in `run` +/// and `apply_patches_inner`'s `Err` arm. +async fn report_apply_failure( + args: &ApplyArgs, + error: &str, + api_token: &Option, + org_slug: &Option, +) -> i32 { + track_patch_apply_failed( + error, + args.common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + if args.common.json { + let mut env = Envelope::new(Command::Apply); + env.dry_run = args.common.dry_run; + env.mark_error(EnvelopeError::new("apply_failed", error.to_string())); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error: {error}"); + } + 1 +} + /// Synthesize one vendor-owned `Skipped`/`vendored` result per in-scope /// vendored purl, BEFORE the crawl-driven matching (and its empty-crawl /// early returns): a vendored package must surface as vendored — never as @@ -1166,18 +1246,12 @@ impl FallbackHomeSkip { async fn apply_patches_inner( args: &ApplyArgs, - manifest_path: &Path, + mut manifest: PatchManifest, + client: &ApiClient, ) -> Result { - let manifest = read_manifest(manifest_path) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Invalid manifest".to_string())?; - // Resolve patch sources (read `.socket/` directly, or stage an overlay // tempdir + download the gap). Shared with `vendor` via fetch_stage. - let socket_dir = manifest_path - .parent() - .expect("manifest path names a file, so it has a parent"); + let socket_dir = args.common.socket_dir(); // Partition manifest PURLs by ecosystem up front. The source probes, // the offline guard, and the download planner in `fetch_stage` must only // consider patches this run can actually apply — the `--ecosystems` @@ -1192,15 +1266,17 @@ async fn apply_patches_inner( .flat_map(|purls| purls.iter().cloned()) .collect(); - // In-scope view of the manifest for source probing and fetching. The - // apply loop keeps using the full `manifest` for per-PURL lookups — - // those are already scoped by `partitioned`. - let mut scoped_manifest = manifest.clone(); - scoped_manifest + // Narrow the manifest to the `--ecosystems` scope IN PLACE: every later + // lookup key comes from `partitioned` / `all_packages`, which are + // already in scope, so nothing downstream needs the full map (and the + // source probes, the offline guard and the download planner must only + // ever see in-scope patches). + manifest .patches .retain(|purl, _| target_manifest_purls.contains(purl)); - let mut staged = match stage_patch_sources(&args.common, &scoped_manifest, socket_dir).await? { + let mut staged = match stage_patch_sources(&args.common, &manifest, &socket_dir, client).await? + { StageOutcome::Ready(s) => s, StageOutcome::Unavailable => { return Ok(ApplyOutcome { @@ -1213,6 +1289,35 @@ async fn apply_patches_inner( } }; + // Local go: prune `replace`-redirects whose patches were dropped from the + // manifest (orphans). Done here — before the crawl + the "no packages + // found" early returns — so orphans are reconciled even when the manifest + // now lists zero in-scope go patches (the all-removed case). No-op unless + // local go is in scope. + reconcile_local_go(&args.common, &target_manifest_purls).await; + + if partitioned.is_empty() { + // Nothing in scope: the manifest lists no patches (or every patch was + // filtered out by `--ecosystems`). There is genuinely no work to do, + // so this is a clean no-op SUCCESS — not a failure. Returning `false` + // here used to exit 1 / `partialFailure`, which broke the npm + // `postinstall` hook (it runs `apply` on every install, including + // fresh projects whose manifest has no matching patches yet). Decided + // BEFORE the ledger read, gem discovery and the crawl — none of which + // can add work to an empty scope — but AFTER the staging above, which + // is where `--download-mode` is validated at runtime. + if !args.common.silent && !args.common.json { + println!("No patches to apply."); + } + return Ok(ApplyOutcome { + success: true, + results: Vec::new(), + unmatched: Vec::new(), + run_warnings: Vec::new(), + fallback_skips: Vec::new(), + }); + } + // Vendor ownership wins for EVERY ecosystem: a purl recorded in // `.socket/vendor/state.json` is managed by the explicit `vendor` // action — apply must not re-patch its installed tree (or repoint a @@ -1226,13 +1331,6 @@ async fn apply_patches_inner( let (mut results, mut matched_manifest_purls, vendored_bases) = synthesize_vendor_owned_results(&target_manifest_purls, &vendored_purls); - // Local go: prune `replace`-redirects whose patches were dropped from the - // manifest (orphans). Done here — before the crawl + the "no packages - // found" early returns — so orphans are reconciled even when the manifest - // now lists zero in-scope go patches (the all-removed case). No-op unless - // local go is in scope. - reconcile_local_go(&args.common, &target_manifest_purls).await; - let crawler_options = CrawlerOptions { cwd: args.common.cwd.clone(), global: args.common.global, @@ -1290,25 +1388,6 @@ async fn apply_patches_inner( ) .await; - if all_packages.is_empty() && partitioned.is_empty() { - // Nothing in scope: the manifest lists no patches (or every patch was - // filtered out by `--ecosystems`). There is genuinely no work to do, - // so this is a clean no-op SUCCESS — not a failure. Returning `false` - // here used to exit 1 / `partialFailure`, which broke the npm - // `postinstall` hook (it runs `apply` on every install, including - // fresh projects whose manifest has no matching patches yet). - if !args.common.silent && !args.common.json { - println!("No patches to apply."); - } - return Ok(ApplyOutcome { - success: true, - results: Vec::new(), - unmatched: Vec::new(), - run_warnings, - fallback_skips, - }); - } - if all_packages.is_empty() { // Vendored purls are already accounted for (synthesized Skipped/ // vendored results above); only the remainder is genuinely @@ -1319,7 +1398,11 @@ async fn apply_patches_inner( &matched_manifest_purls, &vendored_bases, ); - if !unmatched.is_empty() && !args.common.silent && !args.common.json { + // This diagnostic flips the exit code, so it prints even under + // --silent ("errors only", never nothing — the hooked `apply + // --silent` used to exit 1 mutely here); `--json` mutes stderr and + // the envelope's `package_not_installed` events are the channel. + if !unmatched.is_empty() && !args.common.json { eprintln!("Warning: No packages found that match available patches"); eprintln!( " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.", @@ -1339,7 +1422,15 @@ async fn apply_patches_inner( } // Apply patches - ensure_blobs_for_mismatches(args, &manifest, &all_packages, &vendored_purls, &mut staged).await; + ensure_blobs_for_mismatches( + args, + &manifest, + &all_packages, + &vendored_purls, + &mut staged, + client, + ) + .await; let sources = staged.as_patch_sources(); let policy = mismatch_policy(args.force, args.common.strict); let mut has_errors = false; diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs index cba818aa..ccee0f70 100644 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -1,7 +1,7 @@ //! The Bun vendored-mode preflight shared by EVERY path that feeds the -//! vendor engine: `scan --mode vendored` (the manifest-tracked AND the -//! `--detached` download phases), `get … --mode vendored` (search and uuid -//! paths), their `--dry-run` previews, and the `vendor` command's engine +//! vendor engine: `scan --mode vendored` (its in-memory download phase; +//! the hidden `--detached` flag is a no-op), `get … --mode vendored` +//! (search and uuid paths), their `--dry-run` previews, and the `vendor` command's engine //! loop itself ([`crate::commands::vendor::vendor_records`], where it runs //! BEFORE the hosted→vendored takeover reverts anything). //! @@ -78,26 +78,7 @@ pub(crate) async fn bun_vendor_preflight( cwd: &Path, selected: &[PatchSearchResult], ) -> Option { - let pairs = selection_pairs(selected); - if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { - return None; - } - let (code, detail) = socket_patch_core::vendor::bun_lock::preflight_vendor(cwd) - .await - .err()?; - // Loaded only once the project is known to refuse: an accepted project - // never touches the ledger here (the vendor step owns it). - let ledger = load_state(cwd).await; - Some( - refusal_with_exemptions( - cwd, - code, - detail, - &pairs, - ledger.as_ref().map(|s| &s.entries), - ) - .await, - ) + preflight_pairs(cwd, &selection_pairs(selected), None).await } /// [`bun_vendor_preflight`] for callers that already loaded the ledger (the @@ -108,7 +89,7 @@ pub(crate) async fn bun_vendor_preflight_with_ledger( selected: &[PatchSearchResult], ledger: LedgerLoad<'_>, ) -> Option { - bun_vendor_preflight_pairs(cwd, &selection_pairs(selected), ledger).await + preflight_pairs(cwd, &selection_pairs(selected), Some(ledger)).await } /// The preflight over bare `(purl, uuid)` pairs — the `vendor` command's @@ -119,6 +100,18 @@ pub(crate) async fn bun_vendor_preflight_pairs( cwd: &Path, pairs: &[(&str, &str)], ledger: LedgerLoad<'_>, +) -> Option { + preflight_pairs(cwd, pairs, Some(ledger)).await +} + +/// The one preflight every entry point above funnels into. `ledger` is the +/// caller's own load when it has one; `None` loads the ledger here — and +/// only once the project is known to refuse, so an accepted project never +/// touches `state.json` (the vendor step owns it). +async fn preflight_pairs( + cwd: &Path, + pairs: &[(&str, &str)], + ledger: Option>, ) -> Option { if !pairs.iter().any(|(purl, _)| purl.starts_with("pkg:npm/")) { return None; @@ -126,6 +119,14 @@ pub(crate) async fn bun_vendor_preflight_pairs( 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) + } + }; Some(refusal_with_exemptions(cwd, code, detail, pairs, ledger).await) } diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 2c1aa004..f8375611 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -14,13 +14,14 @@ use socket_patch_core::api::blob_fetcher::{ fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives, get_missing_blobs, DownloadMode, }; -use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::patch::apply::PatchSources; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::patch::apply::{is_valid_blob_hash, PatchSources}; use tempfile::TempDir; -use super::get::{base64_decode, is_valid_blob_hash}; +use super::get::base64_decode; use crate::args::GlobalArgs; +use crate::commands::bun_preflight::LedgerLoad; /// Resolved artifact locations for the patch pipeline. Holds the overlay /// `TempDir` alive — sources become invalid when this is dropped. @@ -72,12 +73,24 @@ pub(crate) enum StageOutcome { Unavailable, } +/// The disk stager's remedy: `repair` fills the persistent `.socket/` +/// cache `apply` reads from. +const APPLY_OFFLINE_REMEDY: &str = "Run \"socket-patch repair\" to download missing artifacts."; + +/// The memory stager's remedy. Vendored content is fetched into memory and +/// never lands under `.socket/`; sending a vendored project to `repair` +/// instead would populate `.socket/blobs/` — exactly the residue vendored +/// mode promises not to leave (and from inside `repair --offline` the hint +/// was self-referential). +const VENDOR_OFFLINE_REMEDY: &str = "Re-run without --offline to fetch the missing patch \ + content (kept in memory; nothing is written under .socket/)."; + /// Shared offline diagnostic: patches with no usable local source while -/// `--offline` is set (first five PURLs, then the `repair` hint). +/// `--offline` is set (first five PURLs, then the caller's `remedy` line). /// Prints even under `--silent` (errors only, NEVER nothing — an exit-1 /// run with zero output is undiagnosable); `--json` mutes stderr and the /// caller's envelope is the machine channel instead. -fn report_offline_missing(common: &GlobalArgs, purls: &[&str]) { +fn report_offline_missing(common: &GlobalArgs, purls: &[&str], remedy: &str) { if common.json { return; } @@ -91,7 +104,7 @@ fn report_offline_missing(common: &GlobalArgs, purls: &[&str]) { if purls.len() > 5 { eprintln!(" ... and {} more", purls.len() - 5); } - eprintln!("Run \"socket-patch repair\" to download missing artifacts."); + eprintln!("{remedy}"); } /// The manifest PURLs with no usable local source. A patch is "locally @@ -156,13 +169,16 @@ async fn overlay_dir(src: &Path, dst: &Path) { /// Resolve patch sources for `manifest`: read straight from `.socket/` when /// everything needed is cached (or `--offline`), else stage an overlay -/// tempdir and fetch the gap. `Err` is a hard setup failure (bad -/// `--download-mode`, tempdir creation); `Ok(Unavailable)` is the soft -/// "cannot proceed" path with diagnostics already printed. +/// tempdir and fetch the gap through `client` (the run's one API client — +/// building another here repeated its advisory and org-slug resolution). +/// `Err` is a hard setup failure (bad `--download-mode`, tempdir creation); +/// `Ok(Unavailable)` is the soft "cannot proceed" path with diagnostics +/// already printed. pub(crate) async fn stage_patch_sources( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, + client: &ApiClient, ) -> Result { let quiet = common.silent || common.json; let socket_blobs_path = socket_dir.join("blobs"); @@ -191,7 +207,7 @@ pub(crate) async fn stage_patch_sources( // verification on its own; we still surface the no-source // diagnosis so the user runs `repair` before retrying. if !no_source_purls.is_empty() { - report_offline_missing(common, &no_source_purls); + report_offline_missing(common, &no_source_purls, APPLY_OFFLINE_REMEDY); return Ok(StageOutcome::Unavailable); } } @@ -247,10 +263,8 @@ pub(crate) async fn stage_patch_sources( ); } - let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; let sources = staged.as_patch_sources(); - let fetch_result = - fetch_missing_sources(manifest, &sources, download_mode, &client, None).await; + let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, client, None).await; if !quiet { println!("{}", format_fetch_result(&fetch_result)); @@ -269,7 +283,7 @@ pub(crate) async fn stage_patch_sources( still_missing_blobs.len() ); } - let blob_result = fetch_missing_blobs(manifest, &staged.blobs, &client, None).await; + let blob_result = fetch_missing_blobs(manifest, &staged.blobs, client, None).await; if !quiet { println!("{}", format_fetch_result(&blob_result)); } @@ -351,11 +365,25 @@ pub(crate) enum MemStageOutcome { /// disk stager there is no hard-failure mode (no download-mode parse, no /// tempdir), so this returns the outcome directly — every failure is the /// soft `Unavailable`. +/// +/// `ledger` is the caller's single `load_state` outcome (the harvest reads +/// the committed artifacts it names; an unreadable ledger harvests +/// nothing). `seed` pre-populates the in-memory blob set — the vendored +/// download phase already holds every fetched view's `blobContent`, so a +/// fresh `scan`/`get --mode vendored` never fetches a view a second time +/// here; manifest-driven callers pass an empty map. `client` is the run's +/// one API client (every CLI caller has one — building another here +/// repeated its token advisory and org-slug round-trip, under the apply +/// lock in `vendor`'s case); `None` builds one on demand, only once a fetch +/// is actually needed (the unit tests' offline arms never get that far). pub(crate) async fn stage_vendor_sources_in_memory( common: &GlobalArgs, manifest: &PatchManifest, socket_dir: &Path, project_root: &Path, + ledger: LedgerLoad<'_>, + seed: HashMap>, + client: Option<&ApiClient>, ) -> MemStageOutcome { let quiet = common.silent || common.json; let blobs = socket_dir.join("blobs"); @@ -364,52 +392,57 @@ pub(crate) async fn stage_vendor_sources_in_memory( let missing_blobs = get_missing_blobs(manifest, &blobs).await; let missing_package_archives = get_missing_archives(manifest, &packages).await; + let mut mem = seed; // A diff archive alone is NOT a sufficient source here, unlike the disk // stager: vendoring runs the auto-force policy, where a beforeHash // mismatch (already-applied tree, patch built against different bytes) // is overwritten with the FULL after-blob — which a diff cannot // produce. On-disk diffs still serve Strategy 2 for clean files; the - // after-blob content must additionally exist (disk, harvest, or fetch). + // after-blob content must additionally exist (disk, seed/harvest, or + // fetch). + let covered = |record: &PatchRecord, mem: &HashMap>| { + record + .files + .values() + .all(|f| !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash)) + || !missing_package_archives.contains(&record.uuid) + }; let mut to_fetch: Vec<(&str, &str)> = manifest .patches .iter() - .filter_map(|(purl, record)| { - let all_blobs_present = record - .files - .values() - .all(|f| !missing_blobs.contains(&f.after_hash)); - let pkg_present = !missing_package_archives.contains(&record.uuid); - if all_blobs_present || pkg_present { - None - } else { - Some((purl.as_str(), record.uuid.as_str())) - } - }) + .filter(|(_, record)| !covered(record, &mem)) + .map(|(purl, record)| (purl.as_str(), record.uuid.as_str())) .collect(); - let mut mem = HashMap::new(); if !to_fetch.is_empty() { // The committed vendor artifact IS the patched content: harvest its // afterHash blobs into memory so in-sync re-runs and fresh clones of // already-vendored projects stage with no network and no disk blobs. - mem = socket_patch_core::vendor::harvest_artifact_blobs(project_root, &manifest.patches) - .await; - if !mem.is_empty() { - to_fetch.retain(|(purl, _)| { - manifest.patches.get(*purl).is_none_or(|record| { - !record.files.values().all(|f| { - !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash) - }) - }) - }); + // Harvested bytes are hash-verified, so they win over a same-hash + // seed entry. + if let Ok(entries) = ledger { + mem.extend( + socket_patch_core::vendor::harvest_artifact_blobs_from( + project_root, + entries, + &manifest.patches, + ) + .await, + ); } + to_fetch.retain(|(purl, _)| { + manifest + .patches + .get(*purl) + .is_none_or(|record| !covered(record, &mem)) + }); } if !to_fetch.is_empty() { if common.offline { let purls: Vec<&str> = to_fetch.iter().map(|(purl, _)| *purl).collect(); - report_offline_missing(common, &purls); + report_offline_missing(common, &purls, VENDOR_OFFLINE_REMEDY); return MemStageOutcome::Unavailable; } @@ -420,10 +453,19 @@ pub(crate) async fn stage_vendor_sources_in_memory( ); } - let (client, _) = get_api_client_with_overrides(common.api_client_overrides()).await; + let built; + let client = match client { + Some(client) => client, + None => { + built = get_api_client_with_overrides(common.api_client_overrides()) + .await + .0; + &built + } + }; let mut failed: Vec<&str> = Vec::new(); for (purl, uuid) in &to_fetch { - match client.fetch_patch(common.org.as_deref(), uuid).await { + match client.fetch_patch(uuid).await { Ok(Some(patch)) => { let mut complete = true; for (file, info) in &patch.files { @@ -529,6 +571,25 @@ mod tests { } } + /// A network-free client for the offline arms (never used: they return + /// before any fetch), built directly so no ambient token or socket-cli + /// config can leak into a unit test. + fn offline_client() -> ApiClient { + ApiClient::new(socket_patch_core::api::client::ApiClientOptions { + api_url: "http://127.0.0.1:1".to_string(), + api_token: None, + use_public_proxy: false, + org_slug: None, + }) + } + + /// The client `dead_endpoint_args` describes (see there). + async fn dead_endpoint_client(args: &GlobalArgs) -> ApiClient { + get_api_client_with_overrides(args.api_client_overrides()) + .await + .0 + } + /// Everything cached → read `.socket/` in place: no overlay tempdir, and /// the returned paths are the persistent cache dirs themselves. #[tokio::test] @@ -538,9 +599,14 @@ mod tests { std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); std::fs::write(socket_dir.join("blobs").join(HASH), b"patched").unwrap(); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); let StageOutcome::Ready(staged) = outcome else { panic!("fully-cached staging must be Ready"); }; @@ -555,9 +621,14 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); assert!( matches!(outcome, StageOutcome::Unavailable), "offline + no local source must be Unavailable" @@ -581,9 +652,14 @@ mod tests { ) .unwrap(); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); assert!( matches!(outcome, StageOutcome::Ready(_)), "a present diff archive is a usable source for the disk stager" @@ -612,6 +688,9 @@ mod tests { &manifest_with_one_patch(), &socket_dir, &project_root, + Ok(&HashMap::new()), + HashMap::new(), + None, ) .await; assert!( @@ -620,6 +699,105 @@ mod tests { ); } + /// The download phase's blob seed IS a source: with every after-hash + /// seeded, an offline run with no disk blobs, no archives and no + /// committed artifact is Ready and stages the seeded bytes (no fetch, + /// no harvest needed) — the vendored flows never fetch a view twice. + #[tokio::test] + async fn mem_stage_seeded_blobs_are_ready_offline_without_any_disk_source() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + let seed: HashMap> = [(HASH.to_string(), b"seeded".to_vec())].into(); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &project_root, + Ok(&HashMap::new()), + seed, + None, + ) + .await; + let MemStageOutcome::Ready(staged) = outcome else { + panic!("a fully seeded stage must be Ready"); + }; + assert_eq!( + staged.mem.get(HASH).map(Vec::as_slice), + Some(&b"seeded"[..]), + "the seeded bytes are the staged content" + ); + assert!( + !socket_dir.exists(), + "in-memory staging must not create .socket/" + ); + } + + /// A seed covering only SOME hashes still leaves the rest to the + /// ladder: offline with nothing else, the record is Unavailable (the + /// seed is merged, never treated as complete coverage). + #[tokio::test] + async fn mem_stage_partial_seed_still_needs_the_missing_hash() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + let mut manifest = manifest_with_one_patch(); + manifest + .patches + .get_mut("pkg:npm/left-pad@1.3.0") + .unwrap() + .files + .insert( + "other.js".to_string(), + PatchFileInfo { + before_hash: "d".repeat(64), + after_hash: "e".repeat(64), + }, + ); + let seed: HashMap> = [(HASH.to_string(), b"seeded".to_vec())].into(); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest, + &socket_dir, + &project_root, + Ok(&HashMap::new()), + seed, + None, + ) + .await; + assert!( + matches!(outcome, MemStageOutcome::Unavailable), + "one seeded hash out of two is not coverage" + ); + } + + /// An unreadable ledger (`Err`) harvests nothing — and is not an + /// error here: the caller reports the corrupt ledger itself. + #[tokio::test] + async fn mem_stage_unreadable_ledger_skips_the_harvest() { + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + let project_root = tmp.path().join("proj"); + std::fs::create_dir_all(&project_root).unwrap(); + let err = std::io::Error::other("corrupt state.json"); + + let outcome = stage_vendor_sources_in_memory( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &project_root, + Err(&err), + HashMap::new(), + None, + ) + .await; + assert!(matches!(outcome, MemStageOutcome::Unavailable)); + } + /// GlobalArgs wired to a guaranteed-unreachable API endpoint: explicit /// token + org overrides keep client construction network-free, and the /// URL points at a port that was just bound and released, so every fetch @@ -656,10 +834,12 @@ mod tests { ) .unwrap(); + let args = dead_endpoint_args(); let outcome = stage_patch_sources( - &dead_endpoint_args(), + &args, &manifest_with_one_patch(), &socket_dir, + &dead_endpoint_client(&args).await, ) .await .expect("no hard failure"); @@ -687,9 +867,14 @@ mod tests { download_mode: "file".to_string(), ..dead_endpoint_args() }; - let outcome = stage_patch_sources(&args, &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &args, + &manifest_with_one_patch(), + &socket_dir, + &dead_endpoint_client(&args).await, + ) + .await + .expect("no hard failure"); assert!( matches!(outcome, StageOutcome::Ready(_)), "a local diff archive covers the patch even when the blob download fails" @@ -703,10 +888,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); + let args = dead_endpoint_args(); let outcome = stage_patch_sources( - &dead_endpoint_args(), + &args, &manifest_with_one_patch(), &socket_dir, + &dead_endpoint_client(&args).await, ) .await .expect("no hard failure"); @@ -726,7 +913,13 @@ mod tests { silent: true, ..GlobalArgs::default() }; - let Err(err) = stage_patch_sources(&args, &manifest_with_one_patch(), tmp.path()).await + let Err(err) = stage_patch_sources( + &args, + &manifest_with_one_patch(), + tmp.path(), + &offline_client(), + ) + .await else { panic!("an unparseable download mode is a hard failure"); }; @@ -747,9 +940,14 @@ mod tests { std::fs::create_dir_all(socket_dir.join("blobs")).unwrap(); std::fs::write(socket_dir.join("blobs").join(HASH), b"cached").unwrap(); - let outcome = stage_patch_sources(&offline_args(), &manifest_with_one_patch(), &socket_dir) - .await - .expect("no hard failure"); + let outcome = stage_patch_sources( + &offline_args(), + &manifest_with_one_patch(), + &socket_dir, + &offline_client(), + ) + .await + .expect("no hard failure"); let StageOutcome::Ready(mut staged) = outcome else { panic!("fully-cached staging must be Ready"); }; diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 219675e0..17c7945b 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1,7 +1,7 @@ use clap::Args; use regex::Regex; use socket_patch_core::api::client::{ - build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, + build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, ApiClient, }; use socket_patch_core::api::ranking::{cmp_search_results, severity_order}; use socket_patch_core::api::types::{ @@ -13,21 +13,28 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; -use socket_patch_core::patch::apply::select_installed_variants; +use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; +use socket_patch_core::patch::apply_lock::{self, LockError, LockGuard}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; -use socket_patch_core::utils::purl::{is_purl, normalize_purl, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::{ + canonical_purl, is_purl, normalize_purl, strip_purl_qualifiers, +}; +use socket_patch_core::vendor::{load_state, lookup_entry, VendorEntry, VendorState}; use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; +use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::bun_preflight::{ bun_vendor_preflight, bun_vendor_preflight_with_ledger, BunVendorRefusal, }; +use crate::commands::lock_cli::lock_failure; use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_packages_for_rollback, partition_purls, }; -use crate::output::{confirm, select_one, SelectError}; +use crate::output::{confirm, print_json, select_one, SelectError}; /// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as /// the telemetry `ecosystem` field. Returns an empty string when the @@ -40,7 +47,7 @@ fn ecosystem_from_purl(purl: &str) -> String { .to_string() } -/// Per-patch outcome reported in the JSON output of `download_and_apply_patches`. +/// Per-patch outcome reported in the JSON output of `download_and_apply_patches_with`. /// `Updated` carries the previous UUID so a bot can diff a manifest update against /// what was there before — see CLI_CONTRACT.md for the stable vocabulary. #[derive(Debug, PartialEq, Eq, Clone)] @@ -70,7 +77,7 @@ fn run_outcome(patches_failed: bool, apply_failed: bool) -> (&'static str, i32) } } -/// Classify what `download_and_apply_patches` will do to a given PURL based on +/// Classify what `download_and_apply_patches_with` will do to a given PURL based on /// the manifest state *before* any insert. Pure / no I/O so it's unit-testable. pub(crate) fn decide_patch_action( manifest: &PatchManifest, @@ -179,14 +186,6 @@ fn merge_metadata(record: &mut serde_json::Value, meta: serde_json::Value) { } } -/// Print a `serde_json::Value` as pretty JSON to stdout. -fn print_json(v: &serde_json::Value) { - println!( - "{}", - serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") - ); -} - /// Truncate `s` to at most `limit` displayed characters, appending an /// ellipsis when it was longer (so the result is never wider than /// `limit`). Operates on `char` boundaries, NOT bytes: a byte-index slice @@ -252,24 +251,45 @@ fn report_error(json: bool, message: impl std::fmt::Display) { } } -/// A blob hash must be a SHA-256 hex string — the same shape `fetch_blob` -/// enforces before splicing a hash into a URL. Enforced here because the -/// hash comes from an untrusted API response and is used as a filesystem -/// path component: anything else (`../../x`, an absolute path) would -/// escape the blobs directory via `Path::join`. -pub(crate) fn is_valid_blob_hash(hash: &str) -> bool { - hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) +/// Report a failed apply-lock acquire in get's legacy error shape — the +/// `{status: "error", error: ""}` envelope every other hard error +/// here uses, plus the stable `errorCode` (`lock_held` / `lock_io`) the +/// other lock sites emit — and return the envelope for the caller's +/// early-return guard. The message/code mapping is +/// [`crate::commands::lock_cli::lock_failure`]'s, so the waited clause and +/// the I/O rendering cannot drift from `apply`'s. +fn report_lock_failure(json: bool, err: &LockError, timeout: Duration) -> serde_json::Value { + let (code, message) = lock_failure(err, timeout); + let envelope = serde_json::json!({ + "status": "error", + "errorCode": code, + "error": message, + }); + if json { + print_json(&envelope); + } else { + eprintln!("Error: {message}"); + } + envelope } -/// Decode a base64 string and write it to `blobs_dir/hash`. Returns a -/// formatted error string referencing `file_path` and `label` on failure. +/// Decode a base64 string and write it to `blobs_dir/hash`. Returns whether +/// the blob file was NEWLY created (`false`: a blob with this hash already +/// existed — content-addressed, so it is the same bytes — and was +/// overwritten in place), or a formatted error string referencing +/// `file_path` and `label` on failure. +/// +/// `blobs_dir` is created here, lazily — only once a blob is actually +/// about to be persisted — so a run that records nothing (every fetch +/// failed, every patch skipped, undecodable content) leaves no empty +/// `.socket/blobs/` behind. async fn write_blob_entry( blobs_dir: &Path, b64: &str, hash: &str, file_path: &str, label: &str, -) -> Result<(), String> { +) -> Result { if !is_valid_blob_hash(hash) { return Err(format!( "Refusing to write {label} for {file_path}: invalid blob hash {hash:?} (expected 64 hex chars)" @@ -277,19 +297,35 @@ async fn write_blob_entry( } let decoded = base64_decode(b64).map_err(|e| format!("Failed to decode {label} for {file_path}: {e}"))?; - tokio::fs::write(blobs_dir.join(hash), &decoded) + tokio::fs::create_dir_all(blobs_dir) .await - .map_err(|e| format!("Failed to write {label} for {file_path}: {e}")) + .map_err(|e| format!("Failed to create blobs directory: {e}"))?; + let target = blobs_dir.join(hash); + // Probed BEFORE the (overwriting) write: a blob that already existed — + // a live record's revert data, or a sibling patch's shared after-blob + // written earlier this run — is never this call's to remove on unwind. + let existed = tokio::fs::try_exists(&target).await.unwrap_or(false); + tokio::fs::write(&target, &decoded) + .await + .map_err(|e| format!("Failed to write {label} for {file_path}: {e}"))?; + Ok(!existed) } /// Write every after/before blob for `patch` into `blobs_dir`, reporting -/// per-file failures on stderr unless `quiet` is set. Returns `Err(())` -/// on the first failure; callers handle the bookkeeping that follows. +/// per-file failures on stderr unless `quiet` is set. Returns the hashes +/// this call NEWLY created (the caller unwinds them if it then fails to +/// record the patch), or `Err(())` on the first failure — after removing +/// the blobs this same call had already created and pruning an emptied +/// `blobs/` (`is_empty_dir` semantics: a pre-existing blob is never touched), +/// so a patch that fails half-way leaves no orphan `.socket/blobs/` +/// with no record pointing at it; callers handle the bookkeeping that +/// follows. async fn write_all_patch_blobs( blobs_dir: &Path, patch: &PatchResponse, quiet: bool, -) -> Result<(), ()> { +) -> Result, ()> { + let mut created: Vec = Vec::new(); for (file_path, file_info) in &patch.files { for (blob, hash, label) in [ (&file_info.blob_content, &file_info.after_hash, "blob"), @@ -300,16 +336,36 @@ async fn write_all_patch_blobs( ), ] { if let (Some(blob), Some(hash)) = (blob, hash) { - if let Err(e) = write_blob_entry(blobs_dir, blob, hash, file_path, label).await { - if !quiet { - eprintln!(" [error] {e}"); + match write_blob_entry(blobs_dir, blob, hash, file_path, label).await { + Ok(true) => created.push(hash.clone()), + Ok(false) => {} + Err(e) => { + if !quiet { + eprintln!(" [error] {e}"); + } + unwind_new_blobs(blobs_dir, &created).await; + return Err(()); } - return Err(()); } } } } - Ok(()) + Ok(created) +} + +/// Remove the blobs a failed run NEWLY created (`write_all_patch_blobs`'s +/// return value — never a pre-existing blob, which some record may still +/// reference), then prune an emptied `blobs/` up to but excluding `.socket/`, +/// so an all-failed run on a fresh project leaves no `.socket/` behind +/// (contract: `.socket/blobs/` exists only when a record is persisted). +/// Best-effort; the caller's error is what gets reported. +async fn unwind_new_blobs(blobs_dir: &Path, hashes: &[String]) { + for hash in hashes { + let _ = tokio::fs::remove_file(blobs_dir.join(hash)).await; + } + if let Some(stop_dir) = blobs_dir.parent() { + socket_patch_core::utils::socket_dir::prune_empty_dirs(blobs_dir, stop_dir).await; + } } /// Convert the API-shaped vulnerability map on `PatchResponse` into the @@ -485,11 +541,12 @@ pub struct GetArgs { /// `agent` (default; record in `.socket/manifest.json` + blobs and /// apply in place), `hosted` (rewrite lockfiles so the patched deps /// resolve to Socket's hosted patch server; no manifest, no blobs — - /// state lives in the redirect ledger), or `vendored` (record in the - /// manifest, then commit patched artifacts under `.socket/vendor/` and - /// rewire the lockfile). Hosted/vendored runs produce the same on-disk - /// result as `scan --mode hosted|vendored` selecting the same patch. - /// No env binding, matching `scan --mode`. + /// state lives in the redirect ledger), or `vendored` (commit patched + /// artifacts under `.socket/vendor/` and rewire the lockfile; no + /// manifest, no blobs — the vendor ledger carries the records). + /// Hosted/vendored runs produce the same on-disk result as + /// `scan --mode hosted|vendored` selecting the same patch. No env + /// binding, matching `scan --mode`. #[arg(long = "mode", value_enum)] pub mode: Option, } @@ -515,18 +572,22 @@ impl fmt::Display for IdentifierType { } } -fn detect_identifier_type(identifier: &str) -> Option { - let uuid_re = Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") - .expect("hardcoded UUID regex must compile"); - let cve_re = Regex::new(r"(?i)^CVE-\d{4}-\d+$").expect("hardcoded CVE regex must compile"); - let ghsa_re = Regex::new(r"(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$") - .expect("hardcoded GHSA regex must compile"); +/// Case-insensitive advisory-id shapes, compiled once. The UUID shape is +/// [`crate::looks_like_uuid`] (the same 8-4-4-4-12 hex check the argv +/// rewrite uses), so the two detectors cannot drift. +static CVE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^CVE-\d{4}-\d+$").expect("hardcoded CVE regex must compile")); +static GHSA_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$") + .expect("hardcoded GHSA regex must compile") +}); - if uuid_re.is_match(identifier) { +fn detect_identifier_type(identifier: &str) -> Option { + if crate::looks_like_uuid(identifier) { Some(IdentifierType::Uuid) - } else if cve_re.is_match(identifier) { + } else if CVE_RE.is_match(identifier) { Some(IdentifierType::Cve) - } else if ghsa_re.is_match(identifier) { + } else if GHSA_RE.is_match(identifier) { Some(IdentifierType::Ghsa) } else if is_purl(identifier) { Some(IdentifierType::Purl) @@ -655,16 +716,12 @@ pub(crate) fn select_patches( }) }) .collect(); - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "selection_required", - "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), - "purl": purl, - "options": options_json, - })) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&serde_json::json!({ + "status": "selection_required", + "error": format!("Multiple patches available for {purl}. Re-run with the chosen UUID as the identifier (`socket-patch get `) to select one."), + "purl": purl, + "options": options_json, + })); return Err(1); } Err(SelectError::Cancelled) => { @@ -689,7 +746,6 @@ pub struct DownloadParams { /// like on every other command, not silently replaced with /// `/.socket/manifest.json`. pub manifest_path: PathBuf, - pub org: Option, pub save_only: bool, pub global: bool, pub global_prefix: Option, @@ -697,11 +753,6 @@ pub struct DownloadParams { pub silent: bool, /// `--download-mode` value forwarded to the apply step. pub download_mode: String, - /// API client overrides — propagates the caller's CLI flags - /// (`--api-url`, `--api-token`, `--proxy-url`) into the nested API - /// client constructed here. Without this, `download_and_apply_patches` - /// would only honor env vars and ignore the user's flags. - pub api_overrides: socket_patch_core::api::client::ApiClientEnvOverrides, /// When `false` (the default — narrow), a PyPI package with multiple /// release variants (`?artifact_id=...`) is filtered down to the one /// matching the locally-installed distribution before download. When @@ -723,6 +774,50 @@ pub struct DownloadParams { pub persist_blobs: bool, } +impl DownloadParams { + /// `--silent` is "errors only" and `--json` owns stdout: every + /// informational print in the engines is gated on this. + fn quiet(&self) -> bool { + self.json || self.silent + } + + /// The `.socket/` directory the manifest lives in (lock + blobs root) — + /// the one derivation every lock acquire and artifact probe uses. + fn socket_dir(&self) -> PathBuf { + crate::args::socket_dir_of(&self.manifest_path, &self.cwd) + } + + fn crawler_options(&self) -> CrawlerOptions { + CrawlerOptions { + cwd: self.cwd.clone(), + global: self.global, + global_prefix: self.global_prefix.clone(), + } + } +} + +/// Run-level context the download engines need beside `DownloadParams`: +/// the run's API client — built once, proxy fallback included, so the +/// engines never rebuild it from flags and repeat the org auto-resolve +/// round-trip — and the flags the nested apply must inherit. +pub struct DownloadRun<'a> { + /// The run's one API client; the nested apply runs on it too. + pub api_client: &'a ApiClient, + /// `--lock-timeout`: the wait budget for the apply lock, taken once + /// around the manifest write and the nested apply. + pub lock_timeout: Option, + /// `--verbose`, forwarded to the nested apply. + pub verbose: bool, +} + +fn crawler_options_for(common: &GlobalArgs) -> CrawlerOptions { + CrawlerOptions { + cwd: common.cwd.clone(), + global: common.global, + global_prefix: common.global_prefix.clone(), + } +} + /// Narrow a selection of patches down to the release variant(s) present /// in each locally-installed distribution. /// @@ -744,16 +839,27 @@ pub struct DownloadParams { /// /// Both fallbacks push a human-readable warning. /// -/// Returns the kept patches plus any warnings to surface to the caller -/// (also printed to stderr here, in human mode). With `--all-releases` -/// set this is a verbatim pass-through. +/// Returns the kept patches, any warnings to surface to the caller (also +/// printed to stderr here unless `quiet`), and the patch views fetched to +/// hash-match the KEPT variants (uuid-keyed) — the download loop serves +/// those from memory instead of fetching every view a second time. Only +/// successful fetches are cached: a variant whose view errored or 404'd is +/// re-fetched by the loop so the failure surfaces per patch as before. +/// With `--all-releases` set this is a verbatim pass-through. async fn filter_to_installed_releases( selected: &[PatchSearchResult], - params: &DownloadParams, - api_client: &socket_patch_core::api::client::ApiClient, -) -> (Vec, Vec) { - if params.all_releases { - return (selected.to_vec(), Vec::new()); + all_releases: bool, + crawler_options: &CrawlerOptions, + quiet: bool, + api_client: &ApiClient, +) -> ( + Vec, + Vec, + HashMap, +) { + let mut views: HashMap = HashMap::new(); + if all_releases { + return (selected.to_vec(), Vec::new(), views); } // Group release-variant ecosystem selections (PyPI / RubyGems / Maven) @@ -787,7 +893,7 @@ async fn filter_to_installed_releases( } if multi.is_empty() { - return (kept, warnings); + return (kept, warnings, views); } // Discover the on-disk path for each multi-variant base. The crawler @@ -801,12 +907,7 @@ async fn filter_to_installed_releases( .collect(); // All collected PURLs are PyPI; no ecosystem filter needed. let partitioned = partition_purls(&all_qualified, None); - let crawler_options = CrawlerOptions { - cwd: params.cwd.clone(), - global: params.global, - global_prefix: params.global_prefix.clone(), - }; - let paths = find_packages_for_rollback(&partitioned, &crawler_options, true).await; + let paths = find_packages_for_rollback(&partitioned, crawler_options, true).await; for (base, variants) in multi { // Any variant's resolved path works — they all map to the same @@ -824,13 +925,14 @@ async fn filter_to_installed_releases( }; // Fetch each variant's file hashes (the view carries them) so we - // can hash-match against the installed distribution. + // can hash-match against the installed distribution. The view is + // kept for the download loop — it is the same GET it would issue. let mut candidates: Vec<(String, HashMap)> = Vec::new(); for s in &variants { - // org slug is already stored in the client. - match api_client.fetch_patch(None, &s.uuid).await { + match api_client.fetch_patch(&s.uuid).await { Ok(Some(patch)) => { candidates.push((s.purl.clone(), files_with_both_hashes(&patch))); + views.insert(s.uuid.clone(), patch); } // On a fetch error/miss, keep the variant so the main // download loop can record the failure as it would today. @@ -863,12 +965,17 @@ async fn filter_to_installed_releases( } } - if !params.json && !params.silent { + if !quiet { for w in &warnings { eprintln!(" [note] {w}"); } } - (kept, warnings) + // Narrowed-out variants are never downloaded: drop their views (each + // carries every file's base64 content) so only the kept ones ride on. + let kept_uuids: std::collections::HashSet<&str> = + kept.iter().map(|s| s.uuid.as_str()).collect(); + views.retain(|uuid, _| kept_uuids.contains(uuid.as_str())); + (kept, warnings, views) } /// Does this purl carry an exact version (`pkg:type/name@version`)? An @@ -980,7 +1087,7 @@ async fn filter_to_installed_purls( use socket_patch_core::vendor::lock_inventory; use std::collections::HashSet; - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let canon = canonical_purl; // Deduped base purls, probed against the installed tree. The resolver // keys its result by the purls we pass, so canonicalize the found keys @@ -994,12 +1101,7 @@ async fn filter_to_installed_purls( .collect() }; let partitioned = partition_purls(&bases, None); - let crawler_options = CrawlerOptions { - cwd: common.cwd.clone(), - global: common.global, - global_prefix: common.global_prefix.clone(), - }; - let found = find_packages_for_rollback(&partitioned, &crawler_options, true).await; + let found = find_packages_for_rollback(&partitioned, &crawler_options_for(common), true).await; let mut present: HashSet = found.keys().map(|k| canon(k)).collect(); // Manifest membership counts as presence (read-only probe: a corrupt @@ -1148,246 +1250,435 @@ fn fold_narrowing_into_result( } } -/// The API-client overrides for a download run: the caller's CLI flags with -/// the override org slug defaulted to `--org` when none was given. -/// -/// Shared by the client built here AND by the nested `apply` step, which -/// constructs its own client and must resolve to the same endpoint/token — -/// see [`run_nested_apply`]. -fn resolved_api_overrides( - params: &DownloadParams, -) -> socket_patch_core::api::client::ApiClientEnvOverrides { - let mut overrides = params.api_overrides.clone(); - if overrides.org_slug.is_none() { - overrides.org_slug = params.org.clone(); - } - overrides +/// Which state store the shared fetch loop classifies each selected patch +/// against — the one non-presentational difference between the vendored +/// and agent download engines. +#[derive(Clone, Copy)] +enum RecordStore<'a> { + /// The vendor ledger (`scan` / `get --mode vendored`, the detached + /// posture): a detached entry already at the selected uuid is reused + /// without a fetch (`skipped`); a fetched patch is `downloaded`, with + /// `oldUuid` when the ledger wires the purl at another uuid. + Ledger(&'a HashMap), + /// `.socket/manifest.json` (agent mode): the fetched view is classified + /// by [`decide_patch_action`] — `added` / `updated` (+ `oldUuid`) / + /// `skipped` (the same uuid is already recorded). + Manifest(&'a PatchManifest), } -/// Build the API client for a download run. -async fn api_client_for(params: &DownloadParams) -> socket_patch_core::api::client::ApiClient { - get_api_client_with_overrides(resolved_api_overrides(params)) - .await - .0 +/// A fetched patch the shared loop accepted — recordable files, blobs +/// persisted when asked — handed to the engine wrapper to record. +struct FetchedPatch { + patch: PatchResponse, + files: HashMap, + action: PatchAction, + /// Blob hashes this fetch NEWLY wrote under `.socket/blobs/` (empty + /// when blobs are not persisted) — what a failed record write unwinds. + new_blobs: Vec, } -/// Download and apply a set of selected patches. -/// -/// Used by both `get` and `scan` commands. Returns (exit_code, json_result). -/// Download patches and their blobs WITHOUT touching the manifest, and -/// return the fetched records keyed by purl — the `scan --vendor -/// --detached` download phase, where the vendor ledger (not the manifest) -/// carries the records. Honors the same installed-release narrowing as -/// [`download_and_apply_patches`]. A purl already vendored DETACHED at the -/// selected uuid skips the network fetch and reuses the ledger's embedded -/// record, so idempotent re-runs stay cheap (mirrors what -/// `decide_patch_action` does for the manifest-tracked flow). -pub(crate) async fn download_patch_records( - selected: &[PatchSearchResult], - params: &DownloadParams, -) -> (i32, serde_json::Value, HashMap) { - let api_client = api_client_for(params).await; - - let socket_dir = params - .manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - let blobs_dir = socket_dir.join("blobs"); - if params.persist_blobs { - if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { - let err = format!("Failed to create blobs directory: {}", e); - report_error(params.json, &err); - return ( - 1, - serde_json::json!({"status": "error", "error": err}), - HashMap::new(), - ); +/// What the shared fetch loop produced over one selection. +struct FetchBatch { + /// Selection size after installed-release narrowing. + found: usize, + skipped: usize, + failed: usize, + /// Fetched, recordable patches in selection order. + fetched: Vec, + /// Ledger store only: `(purl, record)` reused from a detached entry + /// already at the selected uuid (no fetch). + reused: Vec<(String, PatchRecord)>, + /// Per-patch JSON records in selection order (the contract vocabulary). + patches_json: Vec, + /// Release-narrowing fallbacks (uninstalled base, no matching variant). + warnings: Vec, +} + +impl FetchBatch { + /// Record a per-patch failure. `line` is the stderr text — an error, so + /// exempt from `--silent`; JSON runs carry the detail in the envelope + /// instead — or `None` when the failure already printed its own detail. + fn fail( + &mut self, + json: bool, + line: Option, + purl: &str, + uuid: &str, + error: &str, + error_code: Option<&str>, + ) { + if let (false, Some(line)) = (json, line) { + eprintln!(" {line}"); + } + let mut record = serde_json::json!({ + "purl": purl, + "uuid": uuid, + "action": "failed", + }); + if let Some(code) = error_code { + record["errorCode"] = serde_json::json!(code); } + record["error"] = serde_json::json!(error); + self.patches_json.push(record); + self.failed += 1; } +} - let (selected, narrow_warnings) = - filter_to_installed_releases(selected, params, &api_client).await; +/// The fetch loop both download engines share: installed-release +/// narrowing, the caller's Bun refusal, 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 pinned +/// stderr line and JSON action lives here once. +async fn fetch_selected_patches( + selected: &[PatchSearchResult], + params: &DownloadParams, + api_client: &ApiClient, + store: RecordStore<'_>, + blobs_dir: Option<&Path>, + bun_refusal: Option<&BunVendorRefusal>, + mut prefetched: HashMap, +) -> FetchBatch { + let quiet = params.quiet(); + // Narrow multi-release selections to the installed distribution unless + // --all-releases was passed (a no-op for non-variant ecosystems and + // single-variant packages). The views it fetched serve the loop below. + let (selected, warnings, views) = filter_to_installed_releases( + selected, + params.all_releases, + ¶ms.crawler_options(), + quiet, + api_client, + ) + .await; + prefetched.extend(views); + if matches!(store, RecordStore::Manifest(_)) && !quiet { + eprintln!("\nDownloading {} patch(es)...", selected.len()); + } - // The ledger load outcome is handed to the preflight 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 idempotency lookup below it degrades to empty (no detached - // entry to reuse — the vendor step reports the corruption itself). - let vendor_state = socket_patch_core::vendor::load_state(¶ms.cwd).await; - - // The same Bun preflight the manifest-tracked download runs (see - // `download_and_apply_patches`): a detached run feeds the same vendor - // engine, so it must refuse the same projects BEFORE fetching. Without - // it the patch view was downloaded for nothing and — for a package - // installed under an alias directory, resolvable only through the - // unreadable bun.lockb inventory — the vendor step then misreported - // `package_not_installed` instead of the real `vendor_bun_*` code. - // `persist_blobs` is never set on this (vendor-only) path; the gate - // mirrors the manifest-tracked download's posture defensively. - let bun_refusal = if params.persist_blobs { - None - } else { - bun_vendor_preflight_with_ledger( - ¶ms.cwd, - &selected, - vendor_state.as_ref().map(|s| &s.entries), - ) - .await + let mut batch = FetchBatch { + found: selected.len(), + skipped: 0, + failed: 0, + fetched: Vec::new(), + reused: Vec::new(), + patches_json: Vec::new(), + warnings, }; - let vendor_state = vendor_state.unwrap_or_default(); - - let mut records: HashMap = HashMap::new(); - let mut downloaded = 0usize; - let mut skipped = 0usize; - let mut failed = 0usize; - let mut patch_records_json: Vec = Vec::new(); for search_result in &selected { - // Idempotency: a detached entry already at this uuid carries its - // own record — no view fetch needed. - let existing = - socket_patch_core::vendor::lookup_entry(&vendor_state.entries, &search_result.purl) - .filter(|e| e.detached && e.uuid == search_result.uuid); - if let Some(record) = existing.and_then(|e| e.record.clone()) { - if !params.json && !params.silent { - eprintln!(" [skip] {} (already vendored)", search_result.purl); - } - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "skipped", - })); - records.insert(search_result.purl.clone(), record); - skipped += 1; + let (purl, uuid) = (search_result.purl.as_str(), search_result.uuid.as_str()); + + // 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(refusal) = bun_refusal.filter(|r| r.applies_to(purl)) { + batch.fail( + params.json, + Some(format!( + "[error] {purl} ({}): {}", + refusal.code, refusal.detail + )), + purl, + uuid, + &refusal.detail, + Some(refusal.code), + ); continue; } - if let Some(refusal) = bun_refusal - .as_ref() - .filter(|r| r.applies_to(&search_result.purl)) - { - // Errors are exempt from --silent ("errors only"); JSON runs - // carry the code + detail in the envelope instead. - if !params.json { + // Idempotency (ledger store): a detached entry already at this uuid + // carries its own record — no view fetch needed. + if let RecordStore::Ledger(entries) = store { + if let Some(record) = lookup_entry(entries, purl) + .filter(|e| e.detached && e.uuid == uuid) + .and_then(|e| e.record.clone()) + { + if !quiet { + eprintln!(" [skip] {purl} (already vendored)"); + } + batch.patches_json.push(serde_json::json!({ + "purl": purl, + "uuid": uuid, + "action": "skipped", + })); + batch.reused.push((purl.to_string(), record)); + batch.skipped += 1; + continue; + } + } + + // The view: from memory when the narrowing (or the uuid path's own + // identifier fetch) already fetched it, else the network. + let view = match prefetched.remove(uuid) { + Some(patch) => Ok(Some(patch)), + None => api_client.fetch_patch(uuid).await, + }; + let patch = match view { + Ok(Some(patch)) => patch, + Ok(None) => { + batch.fail( + params.json, + Some(format!("[fail] {purl} (could not fetch details)")), + purl, + uuid, + "could not fetch details", + None, + ); + continue; + } + Err(e) => { + batch.fail( + params.json, + Some(format!("[fail] {purl} ({e})")), + purl, + uuid, + &e.to_string(), + None, + ); + continue; + } + }; + + // Classify against the store BEFORE anything is written. `Skipped` + // early-continues; `Updated` is preserved so the per-patch record + // can carry `oldUuid`. + let action = match store { + RecordStore::Manifest(manifest) => { + decide_patch_action(manifest, &patch.purl, &patch.uuid) + } + RecordStore::Ledger(entries) => match lookup_entry(entries, &patch.purl) { + Some(entry) if entry.uuid != patch.uuid => PatchAction::Updated { + old_uuid: entry.uuid.clone(), + }, + _ => PatchAction::Added, + }, + }; + if action == PatchAction::Skipped { + if !quiet { eprintln!( - " [error] {} ({}): {}", - search_result.purl, refusal.code, refusal.detail + " [skip] {} (already in manifest)", + normalize_purl(&patch.purl) ); } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "errorCode": refusal.code, - "error": refusal.detail, + batch.patches_json.push(serde_json::json!({ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "skipped", })); + batch.skipped += 1; continue; } - // org slug is already stored in the client. - match api_client.fetch_patch(None, &search_result.uuid).await { - Ok(Some(patch)) => { - // Record every file the patch touches, added files - // included (empty-beforeHash sentinel); see - // `files_for_manifest`. - let files = files_for_manifest(&patch); - // GUARDRAIL: a patch that yields NO recordable files - // cannot be vendored — recording an empty `files` map and - // reporting the purl as vendored would claim protection - // while writing nothing. Fail loudly instead. - if files.is_empty() { - // Errors are exempt from --silent ("errors only"); - // JSON runs carry them in the envelope instead. - if !params.json { - eprintln!( - " [fail] {} (patch has no applicable files)", - search_result.purl - ); - } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "patch has no applicable files", - })); - continue; - } - // Blob failures are errors: only JSON mode suppresses the - // per-file detail line (the envelope carries the error). - let quiet = params.json; - // Vendor flows keep blob content in memory (the vendor - // step re-fetches what it needs); persisting blobs here - // would litter .socket/blobs for no consumer. - if params.persist_blobs - && write_all_patch_blobs(&blobs_dir, &patch, quiet) - .await - .is_err() - { - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "Blob decode or write failed", - })); + // Record every file the patch touches, added files included + // (empty-beforeHash sentinel); see `files_for_manifest`. + let files = files_for_manifest(&patch); + // GUARDRAIL: a patch that yields NO recordable files cannot be + // applied or vendored — recording an empty `files` map and then + // reporting it protected would claim protection while writing + // nothing. Count it as a failure so the status/exit code degrade. + if files.is_empty() { + batch.fail( + params.json, + Some(format!( + "[fail] {} (patch has no applicable files)", + patch.purl + )), + &patch.purl, + &patch.uuid, + "patch has no applicable files", + None, + ); + continue; + } + // Blob failures are errors: only JSON mode suppresses the per-file + // detail line (the envelope carries the error). Vendor flows pass no + // blobs dir — their content stays in memory for the vendor step. + let mut new_blobs = Vec::new(); + if let Some(blobs_dir) = blobs_dir { + match write_all_patch_blobs(blobs_dir, &patch, params.json).await { + Ok(created) => new_blobs = created, + Err(()) => { + batch.fail( + params.json, + None, + &patch.purl, + &patch.uuid, + "Blob decode or write failed", + None, + ); continue; } - if !params.json && !params.silent { - eprintln!(" [fetch] {}", patch.purl); - } - let mut record_json = serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "downloaded", - }); - merge_metadata(&mut record_json, patch_event_metadata(&patch)); - patch_records_json.push(record_json); - records.insert(patch.purl.clone(), build_patch_record(&patch, files)); - downloaded += 1; - } - Ok(None) => { - if !params.json { - eprintln!(" [fail] {} (could not fetch details)", search_result.purl); - } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": "could not fetch details", - })); } - Err(e) => { - if !params.json { - eprintln!(" [fail] {} ({e})", search_result.purl); - } - failed += 1; - patch_records_json.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": e.to_string(), - })); + } + + let (label, tag) = match (store, &action) { + (RecordStore::Ledger(_), _) => ("downloaded", "fetch"), + (RecordStore::Manifest(_), PatchAction::Updated { .. }) => ("updated", "update"), + (RecordStore::Manifest(_), _) => ("added", "add"), + }; + let mut record = serde_json::json!({ + "purl": patch.purl, + "uuid": patch.uuid, + "action": label, + }); + if let PatchAction::Updated { old_uuid } = &action { + if !quiet { + // Defensive: a malformed/short UUID in the store must not + // panic the loop — `short_uuid` never does. + eprintln!( + " [{tag}] {} (replacing {})", + patch.purl, + short_uuid(old_uuid) + ); } + record["oldUuid"] = serde_json::json!(old_uuid); + } else if !quiet { + eprintln!(" [{tag}] {}", patch.purl); } + // Splice description / severity / vulnerability IDs into the record + // so PR-comment bots, dashboards, and CLI consumers can render the + // patch without a second round-trip to the API. + merge_metadata(&mut record, patch_event_metadata(&patch)); + batch.patches_json.push(record); + batch.fetched.push(FetchedPatch { + patch, + files, + action, + new_blobs, + }); } + batch +} +/// The vendored download phase's result: `(exit code, download JSON, +/// records by purl, blob seed)` — the seed is every fetched view's decoded +/// `blobContent` keyed by after-hash, for the vendor stager +/// (`fetch_stage::stage_vendor_sources_in_memory`), so the step never +/// fetches a view this phase already holds. +pub(crate) type DetachedDownload = ( + i32, + serde_json::Value, + HashMap, + HashMap>, +); + +/// Download patches WITHOUT touching the manifest and return the fetched +/// records keyed by purl — the download phase of every vendored run +/// (`scan` / `get --mode vendored`), where the vendor ledger carries the +/// records (`detached`). Honors the same installed-release narrowing as +/// [`download_and_apply_patches_with`]. A purl already vendored detached at the +/// selected uuid skips the network fetch and reuses the ledger's embedded +/// record, so idempotent re-runs stay cheap. +/// +/// `api_client` is the run's client (built once, proxy fallback included). +/// `prefetched` maps uuid → an already-fetched view: the `get ` path +/// resolved its identifier by fetching the view, and scan's interactive +/// arm pre-verified baselines from the views — neither must fetch again (a +/// fresh fetch could re-hit the 401 the proxy fallback just recovered +/// from). The ledger idempotency check runs before the cache lookup, and a +/// cache miss still fetches. +/// +/// The blob seed is best-effort: an undecodable or missing `blobContent` +/// contributes nothing and is NOT a failed record (the stager reports what +/// it cannot source). +pub(crate) async fn download_patch_records_with( + selected: &[PatchSearchResult], + params: &DownloadParams, + api_client: &ApiClient, + prefetched: HashMap, +) -> DetachedDownload { + // The ledger load outcome is handed to the preflight 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). + let vendor_state = load_state(¶ms.cwd).await; + // Bun preflight (see `BunVendorRefusal`): this phase feeds the vendor + // engine, so it must refuse the same projects BEFORE fetching — + // otherwise the view was downloaded for nothing and a package + // resolvable only through the unreadable bun.lockb inventory + // misreported `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; + download_patch_records_preflighted( + selected, + params, + api_client, + prefetched, + vendor_state, + bun_refusal.as_ref(), + ) + .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. +async fn download_patch_records_preflighted( + selected: &[PatchSearchResult], + params: &DownloadParams, + api_client: &ApiClient, + prefetched: HashMap, + vendor_state: std::io::Result, + bun_refusal: Option<&BunVendorRefusal>, +) -> DetachedDownload { + let vendor_state = vendor_state.unwrap_or_default(); + + let blobs_dir = params.socket_dir().join("blobs"); + let batch = fetch_selected_patches( + selected, + params, + api_client, + RecordStore::Ledger(&vendor_state.entries), + params.persist_blobs.then_some(blobs_dir.as_path()), + bun_refusal, + prefetched, + ) + .await; + + let downloaded = batch.fetched.len(); + let mut records: HashMap = batch.reused.into_iter().collect(); + let mut blobs: HashMap> = HashMap::new(); + for FetchedPatch { patch, files, .. } in batch.fetched { + for info in patch.files.values() { + // Same key guard as the blob writers: the hash names the lookup + // key the apply pipeline gates writes on. + let (Some(b64), Some(hash)) = (&info.blob_content, &info.after_hash) else { + continue; + }; + if !is_valid_blob_hash(hash) || blobs.contains_key(hash) { + continue; + } + if let Ok(bytes) = base64_decode(b64) { + blobs.insert(hash.clone(), bytes); + } + } + records.insert(patch.purl.clone(), build_patch_record(&patch, files)); + } let mut result_json = serde_json::json!({ - "found": selected.len(), + "found": batch.found, "downloaded": downloaded, - "skipped": skipped, - "failed": failed, + "skipped": batch.skipped, + "failed": batch.failed, "detached": true, - "patches": patch_records_json, + "patches": batch.patches_json, }); - if !narrow_warnings.is_empty() { - result_json["warnings"] = serde_json::json!(narrow_warnings); + if !batch.warnings.is_empty() { + result_json["warnings"] = serde_json::json!(batch.warnings); } - (i32::from(failed > 0), result_json, records) + (i32::from(batch.failed > 0), result_json, records, blobs) } /// Emit a warning (stderr `[note]` + `warnings[]`) for every added/updated @@ -1395,7 +1686,7 @@ pub(crate) async fn download_patch_records( /// uuid — VEX verification fails closed (`vendor_uuid_mismatch`) until a /// `vendor` run refreshes the committed artifact. /// -/// Kept out of [`download_and_apply_patches`]'s body on purpose: that +/// Kept out of [`download_and_apply_patches_with`]'s body on purpose: that /// function sits on the in-process scan→download→apply chain, whose summed /// poll frames must fit Windows' 1 MiB main-thread stack in debug builds. async fn warn_on_vendored_uuid_drift( @@ -1404,7 +1695,7 @@ async fn warn_on_vendored_uuid_drift( downloaded_patches: &[serde_json::Value], warnings: &mut Vec, ) { - let Ok(vendor_state) = socket_patch_core::vendor::load_state(cwd).await else { + let Ok(vendor_state) = load_state(cwd).await else { return; }; if vendor_state.entries.is_empty() { @@ -1417,7 +1708,7 @@ async fn warn_on_vendored_uuid_drift( if !matches!(rec["action"].as_str(), Some("added" | "updated")) { continue; } - let entry = socket_patch_core::vendor::lookup_entry(&vendor_state.entries, purl); + let entry = lookup_entry(&vendor_state.entries, purl); if let Some(entry) = entry.filter(|e| e.uuid != uuid) { let w = format!( "{purl} is vendored at patch {} but the manifest now records {uuid}; \ @@ -1432,106 +1723,118 @@ async fn warn_on_vendored_uuid_drift( } } -/// Run the nested `apply` step over the manifest under `cwd`. Returns -/// whether apply exited 0. Callers print their own "Applying patches..." -/// line (they differ on stdout vs stderr). `get` drives apply internally: -/// the read-only cargo-redirect verifier stays off and embedded VEX is +/// The `GlobalArgs` a nested apply runs with: the caller's flags verbatim +/// (`--verbose`, `--strict`, `--ecosystems`, `--download-mode` … all flow +/// through; the API flags ride along but are inert — the nested apply runs +/// on the caller's client), with the fields `get` owns overridden: the +/// already-resolved manifest path (apply re-resolves a +/// relative path against ITS `--cwd`, which double-joins ours — absolutize +/// so it passes through verbatim), `silent` = quiet and `json: false` (the +/// nested apply must never print a second JSON document), and `dry_run: +/// false` — agent-mode `get` ignores `--dry-run` by contract, and the +/// manifest + blobs it just wrote for real must be applied for real too. +fn nested_apply_args(common: &GlobalArgs, manifest_path: &Path, quiet: bool) -> GlobalArgs { + let manifest_path = + std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf()); + GlobalArgs { + manifest_path: manifest_path.display().to_string(), + silent: quiet, + json: false, + dry_run: false, + ..common.clone() + } +} + +/// The caller flags a `DownloadParams` + [`DownloadRun`] pair reconstructs +/// for the nested apply (the engine never sees a `GlobalArgs`). No API +/// fields: the nested apply runs on the run's client (`run.api_client`), +/// which was built from the caller's flags. +fn nested_apply_args_from_params( + params: &DownloadParams, + run: &DownloadRun<'_>, + manifest_path: &Path, +) -> GlobalArgs { + let common = GlobalArgs { + cwd: params.cwd.clone(), + global: params.global, + global_prefix: params.global_prefix.clone(), + download_mode: params.download_mode.clone(), + strict: params.strict, + // Scope the nested apply like the caller was scoped: leaving this + // at the default `None` made `scan --ecosystems gem --sync` apply + // the WHOLE manifest, mutating other ecosystems' packages the user + // filtered out. + ecosystems: params.ecosystems.clone(), + lock_timeout: run.lock_timeout, + verbose: run.verbose, + ..GlobalArgs::default() + }; + nested_apply_args(&common, manifest_path, params.quiet()) +} + +/// Run the nested `apply` step with `common` (see [`nested_apply_args`]) +/// on the caller's `client`, under the apply `lock` the caller took for +/// its manifest write — one lock window for download → manifest write → +/// apply (a same-process re-acquire would contend), released by apply once +/// its last mutation is done. Returns whether apply exited 0. Callers print +/// their own "Applying patches..." line (they differ on stdout vs stderr). +/// The read-only cargo-redirect verifier stays off and embedded VEX is /// opt-in on the top-level command only, never on this internal /// invocation. -/// -/// `api` carries the caller's API-client flags and is NOT optional: apply -/// builds its own clients from the `GlobalArgs` handed to it (its telemetry -/// client, and `fetch_stage`'s artifact fetcher), and those only ever see -/// this struct. Leaving the fields at their `GlobalArgs::default()` `None` -/// dropped `--api-url` / `--api-token` / `--org` / `--proxy-url` on the -/// floor, so a token supplied purely as a CLI flag fell through to env → -/// socket-cli config → the token-less public proxy. That breaks the flow -/// for real: a patch view that omits `blobContent` for a file (`Option` on -/// the wire, which is why `--download-mode diff` exists) leaves `get` with -/// no blob to write, and the nested apply must download it — with the wrong -/// client, against the wrong host. -#[allow(clippy::too_many_arguments)] async fn run_nested_apply( - cwd: &Path, - manifest_path: &Path, - global: bool, - global_prefix: Option, + common: GlobalArgs, quiet: bool, - download_mode: String, - strict: bool, - api: socket_patch_core::api::client::ApiClientEnvOverrides, - ecosystems: Option>, + client: &ApiClient, + lock: LockGuard, ) -> bool { - // Apply re-resolves a relative manifest path against ITS `--cwd` - // (`resolved_manifest_path`), but ours is already cwd-resolved — - // passing it through relative double-joins the cwd (`proj/proj/...`), - // and apply then no-ops on the missing manifest while reporting - // success. Absolutize so it passes through verbatim. - let manifest_path = - std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf()); + let manifest_path = common.resolved_manifest_path(); let apply_args = super::apply::ApplyArgs { - common: crate::args::GlobalArgs { - manifest_path: manifest_path.display().to_string(), - cwd: cwd.to_path_buf(), - global, - global_prefix, - silent: quiet, - download_mode, - strict, - api_url: api.api_url, - api_token: api.api_token, - org: api.org_slug, - proxy_url: api.proxy_url, - // Scope the nested apply like the caller was scoped: leaving - // this at the default `None` made `scan --ecosystems gem --sync` - // apply the WHOLE manifest, mutating other ecosystems' packages - // the user filtered out. - ecosystems, - ..crate::args::GlobalArgs::default() - }, + common, force: false, check: false, vex: Default::default(), }; - let code = super::apply::run(apply_args).await; + let code = super::apply::run_locked(apply_args, manifest_path, client, lock).await; if code != 0 && !quiet { eprintln!("\nSome patches could not be applied."); } code == 0 } -pub async fn download_and_apply_patches( +/// Download the selected patches into `.socket/` (manifest records + +/// blobs) and, unless `save_only`, apply them in place — the agent-mode +/// engine behind `get` and `scan --apply/--sync`, over the caller's +/// run-level context (`run`: the client the run already built, plus the +/// `--lock-timeout` / `--verbose` the manifest lock and the nested apply +/// honor). Returns `(exit_code, json)`. +pub async fn download_and_apply_patches_with( selected: &[PatchSearchResult], params: &DownloadParams, + run: &DownloadRun<'_>, ) -> (i32, serde_json::Value) { - let api_client = api_client_for(params).await; - + let quiet = params.quiet(); let manifest_path = params.manifest_path.clone(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - let blobs_dir = socket_dir.join("blobs"); - - if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { - let err = format!("Failed to create .socket directory: {}", e); - report_error(params.json, &err); - return (1, serde_json::json!({"status": "error", "error": err})); - } - if params.persist_blobs { - if let Err(e) = tokio::fs::create_dir_all(&blobs_dir).await { - let err = format!("Failed to create blobs directory: {}", e); - report_error(params.json, &err); - return (1, serde_json::json!({"status": "error", "error": err})); - } - } + let socket_dir = params.socket_dir(); + let lock_timeout = Duration::from_secs(run.lock_timeout.unwrap_or(0)); + + // The manifest read-modify-write — and the blob writes it records — + // runs under the apply lock: `remove`/`rollback` RMW the same file under + // it, and an unlocked writer here lost their update or had its own + // record clobbered. `acquire` creates `.socket/` itself; the guard's + // drop removes `apply.lock` and prunes an otherwise-empty `.socket/`, so + // a run that records nothing leaves no residue. The nested apply runs + // under this SAME guard (one lock window; see `run_nested_apply`). + let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { + Ok(guard) => guard, + Err(e) => return (1, report_lock_failure(params.json, &e, lock_timeout)), + }; let mut manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => PatchManifest::new(), // Fail closed on a manifest that exists but can't be read/parsed: - // treating it as empty would let the unconditional write below - // replace the file and destroy every tracked patch record. + // treating it as empty would let the write below replace the file + // and destroy every tracked patch record. Err(e) => { let err = format!("Failed to read manifest: {e}"); report_error(params.json, &err); @@ -1539,218 +1842,71 @@ pub async fn download_and_apply_patches( } }; - // Narrow multi-release selections to the installed distribution - // unless --all-releases was passed. `filter_to_installed_releases` - // is a no-op for non-variant ecosystems and single-variant packages. - let (selected, mut narrow_warnings) = - filter_to_installed_releases(selected, params, &api_client).await; + // No Bun preflight 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. + let blobs_dir = socket_dir.join("blobs"); + let batch = fetch_selected_patches( + selected, + params, + run.api_client, + RecordStore::Manifest(&manifest), + params.persist_blobs.then_some(blobs_dir.as_path()), + None, + HashMap::new(), + ) + .await; - if !params.json && !params.silent { - eprintln!("\nDownloading {} patch(es)...", selected.len()); + // `added` and `updated` are DISJOINT — one patch lands in exactly one, + // matching the per-patch `action` vocabulary (CLI_CONTRACT.md) and the + // single-uuid flow's summary in `save_and_apply_patch`; `downloaded` is + // their sum (a replacement was fetched and applied just like a new + // record) and gates the apply step. + let downloaded = batch.fetched.len(); + let mut updated = 0usize; + let mut new_blobs: Vec = Vec::new(); + for FetchedPatch { + patch, + files, + action, + new_blobs: created, + } in batch.fetched + { + if matches!(action, PatchAction::Updated { .. }) { + updated += 1; + } + new_blobs.extend(created); + manifest + .patches + .insert(patch.purl.clone(), build_patch_record(&patch, files)); } - - // `patches_added` and `patches_updated` are DISJOINT — one patch lands in - // exactly one of them, matching the per-patch `action` vocabulary - // (CLI_CONTRACT.md: `added` | `updated` | ...) and the single-UUID flow's - // summary in `save_and_apply_patch`. `patches_downloaded` is their sum: - // the JSON `downloaded` / `applied` counts cover both (a replacement was - // fetched and applied just like a new record), and it gates the apply - // step. Counting an update in `patches_added` too made the human summary - // print `Added: 1` AND `Updated: 1` for the one entry it had swapped. - let mut patches_added = 0; - let mut patches_skipped = 0; - let mut patches_failed = 0; - let mut patches_updated = 0; - let mut patches_downloaded = 0; - let mut downloaded_patches: Vec = Vec::new(); - - // Vendored downloads must not claim a patch in the manifest when Bun - // cannot consume its artifact (see `BunVendorRefusal`). Agent/save-only - // flows (`persist_blobs`) retain their record-only intent: the preflight - // is scoped to the `save_only && !persist_blobs` posture the vendored - // flows use, never the agent download. - let bun_refusal = if params.save_only && !params.persist_blobs { - bun_vendor_preflight(¶ms.cwd, &selected).await + let added = downloaded - updated; + // Write only when a record changed: an all-skipped or all-failed run + // leaves the manifest bytes (and a fresh project's tree) untouched. + if downloaded > 0 { + if let Err(e) = write_manifest(&manifest_path, &manifest).await { + // The blobs this run just wrote have no record pointing at them: + // unwind exactly those (a pre-existing record's blobs stay). + unwind_new_blobs(&blobs_dir, &new_blobs).await; + let msg = format!("Error writing manifest: {e}"); + let err_json = serde_json::json!({ "status": "error", "error": &msg }); + if params.json { + print_json(&err_json); + } else { + eprintln!("{msg}"); + } + return (1, err_json); + } + } + // The lock outlives the manifest write only when a nested apply follows + // (it is handed the guard and releases it after its last mutation); + // otherwise nothing more is written and it is released here. + let apply_lock = if !params.save_only && downloaded > 0 { + Some(guard) } else { + drop(guard); None }; - for search_result in &selected { - if let Some(refusal) = bun_refusal - .as_ref() - .filter(|r| r.applies_to(&search_result.purl)) - { - patches_failed += 1; - downloaded_patches.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "errorCode": refusal.code, - "error": refusal.detail, - })); - // Errors are exempt from --silent ("errors only", like the - // `[fail]` lines below); JSON runs carry the code + detail in - // the envelope instead. Code-tagged so a `--silent` operator - // can grep the stable code, not just the prose. - if !params.json { - eprintln!( - " [error] {} ({}): {}", - search_result.purl, refusal.code, refusal.detail - ); - } - continue; - } - // org slug is already stored in the client. - match api_client.fetch_patch(None, &search_result.uuid).await { - Ok(Some(patch)) => { - // Classify against the manifest state BEFORE we touch it. - // `Skipped` early-returns; `Updated` is preserved so the - // per-patch JSON record below can include `oldUuid`. - let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); - if let PatchAction::Skipped = action { - if !params.json && !params.silent { - eprintln!( - " [skip] {} (already in manifest)", - normalize_purl(&patch.purl) - ); - } - downloaded_patches.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "skipped", - })); - patches_skipped += 1; - continue; - } - - // Build the manifest `files` map. Retains patch-added new - // files (empty-beforeHash sentinel) so scan/apply/vendor - // record and write them; see `files_for_manifest`. - let files = files_for_manifest(&patch); - - // GUARDRAIL: a patch that yields NO recordable files - // cannot be applied — recording an empty `files` map and - // then reporting `applied` would tell the user we protected - // them while writing nothing. Count it as a failure so the - // status/exit code degrade and it is never auto-applied. - if files.is_empty() { - // Errors are exempt from --silent ("errors only"); - // JSON runs carry them in the envelope instead. - if !params.json { - eprintln!(" [fail] {} (patch has no applicable files)", patch.purl); - } - downloaded_patches.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "patch has no applicable files", - })); - patches_failed += 1; - continue; - } - - // Blob failures are errors: only JSON mode suppresses the - // per-file detail line (the envelope carries the error). - let quiet = params.json; - // Vendor flows keep blob content in memory (the vendor - // step re-fetches what it needs); persisting blobs here - // would litter .socket/blobs for no consumer. - if params.persist_blobs - && write_all_patch_blobs(&blobs_dir, &patch, quiet) - .await - .is_err() - { - patches_failed += 1; - downloaded_patches.push(serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "error": "Blob decode or write failed", - })); - continue; - } - - manifest - .patches - .insert(patch.purl.clone(), build_patch_record(&patch, files)); - - let mut action_record = match &action { - PatchAction::Updated { old_uuid } => { - patches_updated += 1; - if !params.json && !params.silent { - // Defensive: a malformed/short UUID in the manifest - // must not panic the download loop. `&uuid[..8]` - // would; `short_uuid` falls back to the whole string. - eprintln!( - " [update] {} (replacing {})", - patch.purl, - short_uuid(old_uuid) - ); - } - serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "updated", - "oldUuid": old_uuid, - }) - } - _ => { - patches_added += 1; - if !params.json && !params.silent { - eprintln!(" [add] {}", patch.purl); - } - serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "added", - }) - } - }; - // Splice description / severity / vulnerability IDs into - // the per-patch record so PR-comment bots, dashboards, and - // CLI consumers can render the patch without a second - // round-trip to the API. - merge_metadata(&mut action_record, patch_event_metadata(&patch)); - downloaded_patches.push(action_record); - patches_downloaded += 1; - } - Ok(None) => { - if !params.json { - eprintln!(" [fail] {} (could not fetch details)", search_result.purl); - } - downloaded_patches.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": "could not fetch details", - })); - patches_failed += 1; - } - Err(e) => { - if !params.json { - eprintln!(" [fail] {} ({e})", search_result.purl); - } - downloaded_patches.push(serde_json::json!({ - "purl": search_result.purl, - "uuid": search_result.uuid, - "action": "failed", - "error": e.to_string(), - })); - patches_failed += 1; - } - } - } - - // Write manifest - if let Err(e) = write_manifest(&manifest_path, &manifest).await { - let msg = format!("Error writing manifest: {e}"); - let err_json = serde_json::json!({ "status": "error", "error": &msg }); - if params.json { - print_json(&err_json); - } else { - eprintln!("{msg}"); - } - return (1, err_json); - } // Vendored-uuid drift: an explicit `get` is allowed to move the // manifest past the patch uuid the vendor ledger still wires (the user @@ -1759,44 +1915,34 @@ pub async fn download_and_apply_patches( // uuid — tell the operator now instead of letting VEX surprise them // later. (`scan` never hits this: it filters vendored purls before // download.) The nested apply below skips the vendored purl either way. - warn_on_vendored_uuid_drift( - ¶ms.cwd, - params.json || params.silent, - &downloaded_patches, - &mut narrow_warnings, - ) - .await; + let mut warnings = batch.warnings; + warn_on_vendored_uuid_drift(¶ms.cwd, quiet, &batch.patches_json, &mut warnings).await; - if !params.json && !params.silent { + if !quiet { eprintln!("\nPatches saved to {}", manifest_path.display()); - eprintln!(" Added: {patches_added}"); - if patches_skipped > 0 { - eprintln!(" Skipped: {patches_skipped}"); + eprintln!(" Added: {added}"); + if batch.skipped > 0 { + eprintln!(" Skipped: {}", batch.skipped); } - if patches_failed > 0 { - eprintln!(" Failed: {patches_failed}"); + if batch.failed > 0 { + eprintln!(" Failed: {}", batch.failed); } - if patches_updated > 0 { - eprintln!(" Updated: {patches_updated}"); + if updated > 0 { + eprintln!(" Updated: {updated}"); } } - // Auto-apply unless --save-only + // Auto-apply unless --save-only (the lock decision above). let mut apply_succeeded = false; - if !params.save_only && patches_downloaded > 0 { - if !params.json && !params.silent { + if let Some(lock) = apply_lock { + if !quiet { eprintln!("\nApplying patches..."); } apply_succeeded = run_nested_apply( - ¶ms.cwd, - &manifest_path, - params.global, - params.global_prefix.clone(), - params.json || params.silent, - params.download_mode.clone(), - params.strict, - resolved_api_overrides(params), - params.ecosystems.clone(), + nested_apply_args_from_params(params, run, &manifest_path), + quiet, + run.api_client, + lock, ) .await; } @@ -1807,23 +1953,23 @@ pub async fn download_and_apply_patches( // alongside a non-zero exit code misleads JSON consumers (the scan // wrapper recomputes status from the exit code for exactly this // reason, but `get` surfaces this envelope directly). - let apply_failed = !apply_succeeded && patches_downloaded > 0 && !params.save_only; - let (status, exit_code) = run_outcome(patches_failed > 0, apply_failed); + let apply_failed = !apply_succeeded && downloaded > 0 && !params.save_only; + let (status, exit_code) = run_outcome(batch.failed > 0, apply_failed); let mut result_json = serde_json::json!({ "status": status, - "found": selected.len(), - "downloaded": patches_downloaded, - "skipped": patches_skipped, - "failed": patches_failed, - "applied": if apply_succeeded { patches_downloaded } else { 0 }, - "updated": patches_updated, - "patches": downloaded_patches, + "found": batch.found, + "downloaded": downloaded, + "skipped": batch.skipped, + "failed": batch.failed, + "applied": if apply_succeeded { downloaded } else { 0 }, + "updated": updated, + "patches": batch.patches_json, }); // Surface release-narrowing fallbacks (uninstalled package / no // matching variant) so JSON consumers can see why all variants were // kept. Omitted entirely when narrowing was clean. - if !narrow_warnings.is_empty() { - result_json["warnings"] = serde_json::json!(narrow_warnings); + if !warnings.is_empty() { + result_json["warnings"] = serde_json::json!(warnings); } (exit_code, result_json) @@ -1906,9 +2052,6 @@ pub async fn run(args: GetArgs) -> i32 { // incidence of stale-token fallbacks. let mut fallback_to_proxy = false; - // org slug is already stored in the client - let effective_org_slug: Option<&str> = None; - // Determine identifier type let id_type = if args.id { IdentifierType::Uuid @@ -1935,9 +2078,7 @@ pub async fn run(args: GetArgs) -> i32 { if !quiet { println!("Fetching patch by UUID: {}", args.identifier); } - let mut fetch_result = api_client - .fetch_patch(effective_org_slug, &args.identifier) - .await; + let mut fetch_result = api_client.fetch_patch(&args.identifier).await; // 401/403 from the auth endpoint → swap to the public proxy // and retry once. Free patches still surface; paid patches // come back as the existing "paid_required" branch below. @@ -1951,9 +2092,7 @@ pub async fn run(args: GetArgs) -> i32 { api_client = build_proxy_fallback_client(&overrides); use_public_proxy = true; fallback_to_proxy = true; - fetch_result = api_client - .fetch_patch(effective_org_slug, &args.identifier) - .await; + fetch_result = api_client.fetch_patch(&args.identifier).await; } } } @@ -2004,23 +2143,30 @@ pub async fn run(args: GetArgs) -> i32 { telemetry_org.as_deref(), ) .await; - // Mode dispatch. All three reuse THIS fetched patch (and, - // for hosted, this possibly-proxy-fallback client) rather - // than re-fetching with a fresh client, which would re-hit - // the 401/403 the fallback just recovered from. An explicit + // Mode dispatch. All three reuse THIS fetched patch and + // this possibly-proxy-fallback client rather than + // re-fetching with a fresh one, which would re-hit the + // 401/403 the fallback just recovered from. An explicit // UUID is exempt from installed narrowing (exact intent). return match mode { // Save to manifest and apply in place (today's flow). - super::scan::ScanMode::Agent => save_and_apply_patch(&args, &patch).await, + super::scan::ScanMode::Agent => { + save_and_apply_patch(&args, &api_client, &patch).await + } super::scan::ScanMode::Hosted => { let selected = vec![search_result_from_response(&patch)]; - run_get_hosted(&args, &api_client, effective_org_slug, &selected, &[], &[]) - .await + run_get_hosted(&args, &api_client, &selected, &[], &[]).await } super::scan::ScanMode::Vendored => { - run_get_vendored_uuid( + let selected = vec![search_result_from_response(&patch)]; + run_get_vendored( &args, - &patch, + &api_client, + use_public_proxy, + &selected, + Some(&patch), + &[], + &[], telemetry_token.as_deref(), telemetry_org.as_deref(), ) @@ -2064,29 +2210,13 @@ pub async fn run(args: GetArgs) -> i32 { let search_response: SearchResponse = match id_type { IdentifierType::Cve | IdentifierType::Ghsa | IdentifierType::Purl => { if !quiet { - let label = match id_type { - IdentifierType::Cve => "CVE", - IdentifierType::Ghsa => "GHSA", - IdentifierType::Purl => "PURL", - _ => unreachable!(), - }; - println!("Searching patches for {label}: {}", args.identifier); + println!("Searching patches for {id_type}: {}", args.identifier); } let result = match id_type { - IdentifierType::Cve => { - api_client - .search_patches_by_cve(effective_org_slug, &args.identifier) - .await - } - IdentifierType::Ghsa => { - api_client - .search_patches_by_ghsa(effective_org_slug, &args.identifier) - .await - } + IdentifierType::Cve => api_client.search_patches_by_cve(&args.identifier).await, + IdentifierType::Ghsa => api_client.search_patches_by_ghsa(&args.identifier).await, IdentifierType::Purl => { - api_client - .search_patches_by_package(effective_org_slug, &args.identifier) - .await + api_client.search_patches_by_package(&args.identifier).await } _ => unreachable!(), }; @@ -2109,12 +2239,8 @@ pub async fn run(args: GetArgs) -> i32 { if !quiet { println!("Enumerating packages..."); } - let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - }; - let (all_packages, _) = crawl_all_ecosystems(&crawler_options).await; + let (all_packages, _, _) = + crawl_all_ecosystems(&crawler_options_for(&args.common)).await; if all_packages.is_empty() { if args.common.json { @@ -2123,13 +2249,7 @@ pub async fn run(args: GetArgs) -> i32 { if args.common.global { println!("No global packages found."); } else { - #[allow(unused_mut)] - let mut install_cmds = String::from("npm/yarn/pnpm/pip"); - install_cmds.push_str("/cargo"); - install_cmds.push_str("/go"); - install_cmds.push_str("/mvn"); - install_cmds.push_str("/composer"); - println!("No packages found. Run {install_cmds} install first."); + println!("No packages found. Run your package manager's install first."); } } return 0; @@ -2157,12 +2277,9 @@ pub async fn run(args: GetArgs) -> i32 { ); } - // Search for patches for the best match + // Search for patches for the best match. let best_match = &matches[0]; - match api_client - .search_patches_by_package(effective_org_slug, &best_match.purl) - .await - { + match api_client.search_patches_by_package(&best_match.purl).await { Ok(r) => r, Err(e) => { return report_fetch_failure( @@ -2306,7 +2423,9 @@ pub async fn run(args: GetArgs) -> i32 { return 0; } - // Smart patch selection: pick one patch per PURL + // Smart patch selection: pick one patch per PURL. `accessible` is + // non-empty here and every entry passes the selector's tier filter, so + // the selection is never empty (one patch per purl group, or `Err`). let selected = match select_patches( &accessible, search_response.can_access_paid_patches, @@ -2316,13 +2435,6 @@ pub async fn run(args: GetArgs) -> i32 { Err(code) => return code, }; - if selected.is_empty() { - if !quiet { - println!("No patches selected."); - } - return 0; - } - // Confirm before acting (default YES), with mode-appropriate wording. // Hosted/vendored dry-runs skip the prompt — nothing mutates (scan's // dry-run posture); agent mode keeps today's behavior. @@ -2354,25 +2466,16 @@ pub async fn run(args: GetArgs) -> i32 { // granted and rewritten, not just the installed distribution. // Same fallbacks as everywhere else: uninstalled/unmatched // bases keep all variants with a warning; --all-releases - // passes through. - let filter_params = DownloadParams { - cwd: args.common.cwd.clone(), - manifest_path: args.common.resolved_manifest_path(), - org: args.common.org.clone(), - save_only: true, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: args.common.json, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - ecosystems: args.common.ecosystems.clone(), - persist_blobs: false, - }; - let (selected, variant_warnings) = - filter_to_installed_releases(&selected, &filter_params, &api_client).await; + // passes through. (The views it fetched are not needed here: + // hosted never downloads.) + let (selected, variant_warnings, _views) = filter_to_installed_releases( + &selected, + args.all_releases, + &crawler_options_for(&args.common), + quiet, + &api_client, + ) + .await; let mut narrow_warnings = narrow_warnings; narrow_warnings.extend( variant_warnings @@ -2382,7 +2485,6 @@ pub async fn run(args: GetArgs) -> i32 { return run_get_hosted( &args, &api_client, - effective_org_slug, &selected, &narrow_skips, &narrow_warnings, @@ -2390,9 +2492,12 @@ pub async fn run(args: GetArgs) -> i32 { .await; } super::scan::ScanMode::Vendored => { - return run_get_vendored_search( + return run_get_vendored( &args, + &api_client, + use_public_proxy, &selected, + None, &narrow_skips, &narrow_warnings, telemetry_token.as_deref(), @@ -2403,30 +2508,18 @@ pub async fn run(args: GetArgs) -> i32 { super::scan::ScanMode::Agent => {} } - // Download and apply (agent mode) - let params = DownloadParams { - cwd: args.common.cwd.clone(), - manifest_path: args.common.resolved_manifest_path(), - org: args.common.org.clone(), - save_only: args.save_only, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: args.common.json, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - ecosystems: args.common.ecosystems.clone(), - persist_blobs: true, + // Download and apply (agent mode), with the run's client and flags. + let params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); + let run = DownloadRun { + api_client: &api_client, + lock_timeout: args.common.lock_timeout, + verbose: args.common.verbose, }; - - let (code, mut result_json) = download_and_apply_patches(&selected, ¶ms).await; - // A download-phase HARD error (unreadable manifest, unwritable - // .socket, failed manifest write) is an `error`-status envelope the - // engine has ALREADY printed — printing below would put a second JSON - // document on stdout (get's `--json` contract is exactly one per - // run; `run_get_vendored_search` has the same guard). Per-patch + let (code, mut result_json) = download_and_apply_patches_with(&selected, ¶ms, &run).await; + // A download-phase HARD error (lock refused, unreadable manifest, + // failed manifest write) is an `error`-status envelope the engine has + // ALREADY printed — printing below would put a second JSON document on + // stdout (get's `--json` contract is exactly one per run). Per-patch // failures are NOT this case: they ride a success-shaped // (`partial_failure`) envelope the engine leaves for us to print. if result_json["status"] == "error" { @@ -2435,11 +2528,7 @@ pub async fn run(args: GetArgs) -> i32 { fold_narrowing_into_result(&mut result_json, &narrow_skips, &narrow_warnings); if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&result_json) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result_json); } code @@ -2495,57 +2584,26 @@ fn display_search_results(patches: &[PatchSearchResult], can_access_paid: bool) } } -/// Save an already-fetched patch to the manifest and (unless -/// `--save-only`) apply it. Takes the `PatchResponse` the caller fetched -/// rather than re-fetching by UUID: the caller's client may have fallen -/// back to the public proxy after a 401/403, and a fresh client built -/// here would hit the same auth failure again, breaking the fallback -/// end to end. -/// The manifest-record half of the single-uuid save — blobs dir + blob -/// writes (when `persist_blobs`), fail-closed manifest read, the -/// no-applicable-files guardrail, action classification, and the manifest -/// write — WITHOUT the nested apply, drift warning, or terminal JSON -/// envelope. Shared by the agent-mode [`save_and_apply_patch`] terminal -/// (`persist_blobs: true`, `insert_when_skipped: true` — today's exact -/// behavior, a same-uuid re-get still rewrites the record bytes) and the -/// `--mode vendored` uuid path (`false`/`false`: the vendor step stages -/// patch content in memory so nothing lands in `.socket/blobs`, and an -/// idempotent re-get leaves the manifest bytes untouched, matching the -/// multi-patch download loop's Skipped `continue`). +/// The manifest-record half of the agent single-uuid save, under the apply +/// lock: fail-closed manifest read, the no-applicable-files guardrail, +/// action classification against the manifest, and — unless the same uuid +/// is already recorded — the blob writes and the manifest write. Takes the +/// `PatchResponse` the caller fetched rather than re-fetching by UUID: the +/// caller's client may have fallen back to the public proxy after a +/// 401/403, and a fresh client would hit the same auth failure again. A +/// same-uuid re-get writes nothing (matching the multi-patch engine's +/// `skipped`). Runs under the apply lock the caller (`save_and_apply_patch`) +/// holds — the RMW must be serialized against `remove`/`rollback`, and the +/// nested apply then runs under that same guard. /// -/// Errors are reported here exactly as before the extraction and surface -/// as `Err(exit_code)`. +/// Errors are reported here and surface as `Err(exit_code)`. async fn save_patch_record( args: &GetArgs, + manifest_path: &Path, + socket_dir: &Path, patch: &PatchResponse, - persist_blobs: bool, - insert_when_skipped: bool, ) -> Result { - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - - if persist_blobs { - if let Err(e) = tokio::fs::create_dir_all(socket_dir.join("blobs")).await { - report_error( - args.common.json, - format!("Failed to create blobs directory: {e}"), - ); - return Err(1); - } - } else if let Err(e) = tokio::fs::create_dir_all(&socket_dir).await { - // No blobs dir in vendored mode, but the manifest write below (and - // the vendor step's apply lock) still need `.socket/` itself. - report_error( - args.common.json, - format!("Failed to create .socket directory: {e}"), - ); - return Err(1); - } - - let mut manifest = match read_manifest(&manifest_path).await { + let mut manifest = match read_manifest(manifest_path).await { Ok(Some(m)) => m, Ok(None) => PatchManifest::new(), // Fail closed like the download flow: an unreadable manifest @@ -2577,11 +2635,17 @@ async fn save_patch_record( return Err(1); } - if persist_blobs - && write_all_patch_blobs(&socket_dir.join("blobs"), patch, args.common.json) - .await - .is_err() - { + // Classify against the manifest state BEFORE the insert, with the same + // vocabulary `download_and_apply_patches_with` emits (CLI_CONTRACT.md): a + // different uuid already recorded at this purl is `updated` (+`oldUuid`), + // not `added` — consumers diff manifest replacements on that action. + let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); + if action == PatchAction::Skipped { + return Ok(action); + } + + let blobs_dir = socket_dir.join("blobs"); + let Ok(new_blobs) = write_all_patch_blobs(&blobs_dir, patch, args.common.json).await else { if args.common.json { print_json(&serde_json::json!({ "status": "error", @@ -2603,45 +2667,62 @@ async fn save_patch_record( ); } return Err(1); - } - - // Classify against the manifest state BEFORE the insert, with the same - // vocabulary `download_and_apply_patches` emits (CLI_CONTRACT.md): a - // different uuid already recorded at this purl is `updated` (+`oldUuid`), - // not `added` — consumers diff manifest replacements on that action. - let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); - - if insert_when_skipped || action != PatchAction::Skipped { - manifest - .patches - .insert(patch.purl.clone(), build_patch_record(patch, files)); + }; - if let Err(e) = write_manifest(&manifest_path, &manifest).await { - report_error(args.common.json, format!("Error writing manifest: {e}")); - return Err(1); - } + manifest + .patches + .insert(patch.purl.clone(), build_patch_record(patch, files)); + if let Err(e) = write_manifest(manifest_path, &manifest).await { + // No record points at the blobs just written: unwind exactly those. + unwind_new_blobs(&blobs_dir, &new_blobs).await; + report_error(args.common.json, format!("Error writing manifest: {e}")); + return Err(1); } Ok(action) } -async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { +/// The uuid path's agent arm: record `patch` in the manifest and, unless +/// `--save-only`, apply it — under ONE apply lock, on the `client` the +/// fetch used (a fresh client could re-hit the 401/403 its proxy fallback +/// just recovered from). +async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchResponse) -> i32 { // Same "errors only" gate as `run` — informational prints respect // `--silent`; errors and the JSON envelope do not. let quiet = args.common.json || args.common.silent; let manifest_path = args.common.resolved_manifest_path(); + let socket_dir = args.common.socket_dir(); + let lock_timeout = Duration::from_secs(args.common.lock_timeout.unwrap_or(0)); + // See `download_and_apply_patches_with`: the RMW runs under the lock, + // which also creates `.socket/` and prunes it again when nothing lands; + // an error return below drops the guard. + let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { + Ok(guard) => guard, + Err(e) => { + report_lock_failure(args.common.json, &e, lock_timeout); + return 1; + } + }; - let action = match save_patch_record(args, patch, true, true).await { + let action = match save_patch_record(args, &manifest_path, &socket_dir, patch).await { Ok(action) => action, Err(code) => return code, }; let changed = action != PatchAction::Skipped; + // Carried into the nested apply when one follows (it releases the lock + // after its last mutation), released here otherwise. + let apply_lock = if !args.save_only && changed { + Some(guard) + } else { + drop(guard); + None + }; let action_label = match &action { PatchAction::Added => "added", PatchAction::Updated { .. } => "updated", PatchAction::Skipped => "skipped", }; - // Vendored-uuid drift (mirrors `download_and_apply_patches`): the user + // Vendored-uuid drift (mirrors `download_and_apply_patches_with`): the user // explicitly fetched this uuid; if the vendor ledger still wires a // different one, VEX verification fails closed (`vendor_uuid_mismatch`) // until a `vendor` run refreshes the committed artifact. @@ -2672,20 +2753,15 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { } let mut apply_succeeded = false; - if !args.save_only && changed { + if let Some(lock) = apply_lock { if !quiet { println!("\nApplying patches..."); } apply_succeeded = run_nested_apply( - &args.common.cwd, - &manifest_path, - args.common.global, - args.common.global_prefix.clone(), + nested_apply_args(&args.common, &manifest_path, quiet), quiet, - args.common.download_mode.clone(), - args.common.strict, - args.common.api_client_overrides(), - args.common.ecosystems.clone(), + client, + lock, ) .await; } @@ -2720,15 +2796,11 @@ async fn save_and_apply_patch(args: &GetArgs, patch: &PatchResponse) -> i32 { "applied": if apply_succeeded { 1 } else { 0 }, "patches": [patch_record], }); - // Same contract as `download_and_apply_patches`: omitted when clean. + // Same contract as `download_and_apply_patches_with`: omitted when clean. if !warnings.is_empty() { result_json["warnings"] = serde_json::json!(warnings); } - println!( - "{}", - serde_json::to_string_pretty(&result_json) - .expect("serializing an in-memory JSON value cannot fail") - ); + print_json(&result_json); } exit_code @@ -2748,46 +2820,24 @@ fn search_result_from_response(patch: &PatchResponse) -> PatchSearchResult { } } -/// Transient-frame boxed constructor for the vendored-mode download phase — -/// `download_and_apply_patches`' future embeds the in-process apply engine, -/// and `run_get_vendored_search`'s poll frame must not carry it inline -/// (Windows 1 MiB main-thread stack; scan's vendor flow boxes the same call). -fn boxed_download_and_apply<'a>( - selected: &'a [PatchSearchResult], - params: &'a DownloadParams, -) -> std::pin::Pin + 'a>> { - Box::pin(download_and_apply_patches(selected, params)) -} - -/// Print the whole-manifest blast-radius note for `--mode vendored`: the -/// vendor step is scan's — it reconciles and (re)vendors EVERY manifest -/// record, not just the one(s) this get selected. -async fn note_vendored_whole_manifest_scope( - manifest_path: &Path, - selected_purls: &[&str], - quiet: bool, -) { - if quiet { - return; - } - let Ok(Some(manifest)) = read_manifest(manifest_path).await else { - return; - }; - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let selected_canon: std::collections::HashSet = - selected_purls.iter().map(|p| canon(p)).collect(); - let others = manifest - .patches - .keys() - .filter(|k| !selected_canon.contains(&canon(k))) - .count(); - if others > 0 { - eprintln!( - " [note] --mode vendored runs the vendor engine over the whole manifest: \ - {others} existing record(s) will also be verified/re-vendored, and records \ - whose packages left the manifest may have their vendored state reverted \ - (same behavior as `scan --mode vendored`)." - ); +/// The `DownloadParams` a `get` run hands its download engine. Only the +/// posture differs per mode: agent persists blobs and applies unless +/// `--save-only`; vendored holds content in memory (`save_only`, no blobs) +/// because the vendor step is the persistence. +fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> DownloadParams { + DownloadParams { + cwd: args.common.cwd.clone(), + manifest_path: args.common.resolved_manifest_path(), + save_only, + global: args.common.global, + global_prefix: args.common.global_prefix.clone(), + json: args.common.json, + silent: args.common.silent, + download_mode: args.common.download_mode.clone(), + all_releases: args.all_releases, + strict: args.common.strict, + ecosystems: args.common.ecosystems.clone(), + persist_blobs, } } @@ -2799,8 +2849,7 @@ async fn note_vendored_whole_manifest_scope( /// `redirect` block into the get base envelope passed as `scan_result`. async fn run_get_hosted( args: &GetArgs, - api_client: &socket_patch_core::api::client::ApiClient, - effective_org_slug: Option<&str>, + api_client: &ApiClient, selected: &[PatchSearchResult], narrow_skips: &[serde_json::Value], narrow_warnings: &[(String, String)], @@ -2830,32 +2879,40 @@ async fn run_get_hosted( &vex, /*prune_requested=*/ false, api_client, - effective_org_slug, &pairs, scan_result, ) .await } -/// `get … --mode vendored` (search path): scan's vendored posture end to -/// end — download phase writing ONLY the manifest (blobs in memory), then -/// scan's whole-manifest vendor step, telemetry included — so the result -/// matches `scan --mode vendored` selecting the same patches. -async fn run_get_vendored_search( +/// `get … --mode vendored`, both identifier paths: scan's vendored posture +/// end to end — the detached download phase ([`download_patch_records_with`]: +/// records fetched into memory, no manifest, no blobs) feeding scan's +/// detached vendor step (apply lock, in-memory staging seeded with the +/// downloaded blobs, the vendor engine over the same run-level client; the +/// ledger carries every record `detached: true`), telemetry included — so +/// the result matches `scan --mode vendored` selecting the same patches. +/// `.socket/manifest.json` is never read or written here. +/// +/// `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 +/// the vendor step (scan parity). +#[allow(clippy::too_many_arguments)] +async fn run_get_vendored( args: &GetArgs, + api_client: &ApiClient, + use_public_proxy: bool, selected: &[PatchSearchResult], + prefetched: Option<&PatchResponse>, narrow_skips: &[serde_json::Value], narrow_warnings: &[(String, String)], telemetry_token: Option<&str>, telemetry_org: Option<&str>, ) -> i32 { - let quiet = args.common.json || args.common.silent; - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - // Dry run: ledger-classification preview only (scan's posture) — no // download, no vendor step, no writes. if args.common.dry_run { @@ -2879,250 +2936,121 @@ async fn run_get_vendored_search( return 0; } - let selected_purls: Vec<&str> = selected.iter().map(|s| s.purl.as_str()).collect(); - note_vendored_whole_manifest_scope(&manifest_path, &selected_purls, quiet).await; - - // Download phase — scan's vendored posture: manifest-only writes, blobs - // held in memory, the nested apply never runs (save_only). - let params = DownloadParams { - cwd: args.common.cwd.clone(), - manifest_path: manifest_path.clone(), - org: args.common.org.clone(), - save_only: true, - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), - json: args.common.json, - silent: args.common.silent, - download_mode: args.common.download_mode.clone(), - api_overrides: args.common.api_client_overrides(), - all_releases: args.all_releases, - strict: args.common.strict, - ecosystems: args.common.ecosystems.clone(), - persist_blobs: false, - }; - let (dl_code, mut result) = boxed_download_and_apply(selected, ¶ms).await; - // A download-phase HARD error (unreadable manifest, unwritable - // .socket, failed manifest write — an `error`-status envelope the - // engine has ALREADY printed) aborts before the vendor step: get's - // `--json` contract is exactly one JSON document per run, and the - // vendor step would only re-fail on the same broken state and print a - // second, different document. Per-patch failures are NOT this case — - // they ride a success-shaped envelope and the vendor step still runs - // (scan parity: previously-recorded patches still (re)vendor). - if result["status"] == "error" { - return dl_code; - } - let mut has_errors = dl_code != 0; - fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); - if let Some(obj) = result.as_object_mut() { - // save_only: the nested apply structurally never ran, so `applied` - // would misleadingly report 0 — drop it (scan's vendored download - // sub-object gets the same surgery). - obj.remove("applied"); - } - - // The vendor step (scan's, verbatim): apply lock, whole-manifest - // reconcile + staging + engine. A per-patch download failure does not - // skip it — previously-recorded patches still (re)vendor, like scan. - match super::scan::boxed_scan_vendor_step(&args.common, &manifest_path, &socket_dir, None).await - { - Ok((vendor_errors, venv)) => { - has_errors |= vendor_errors; - // Telemetry follows the RUN outcome, not the vendor step alone: - // a download-phase refusal/failure exits 1 and must not report - // a successful vendoring of zero patches (scan's arms agree). - crate::commands::vendor::track_outcomes_for_vendor( - has_errors, - &venv, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - if args.common.json { - result["status"] = serde_json::json!(if has_errors { - "partial_failure" - } else { - "success" - }); - result["vendor"] = - serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); - print_json(&result); - } - i32::from(has_errors) - } - Err((code, message, venv)) => { - socket_patch_core::telemetry::track_patch_vendor_failed( - &message, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - if args.common.json { - // A pre-failure reconcile already mutated the vendor ledger - // on disk; its envelope (events included) must reach the - // JSON consumer even though the run aborts here. - if let Some(venv) = venv { - result["vendor"] = - serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); - } - result["status"] = serde_json::json!("error"); - result["error"] = serde_json::json!({ "code": code, "message": message }); - print_json(&result); - } else { - eprintln!("Error ({code}): {message}"); - } - 1 - } - } -} - -/// `get --mode vendored`: record the ALREADY-FETCHED patch in the -/// manifest (no blobs, no nested apply — the vendor step stages content in -/// memory), then run scan's whole-manifest vendor step. Reuses the fetched -/// `PatchResponse` so the uuid path's proxy-fallback survives the record -/// save; the vendor step builds its own client from the flags, exactly as -/// scan's does. -async fn run_get_vendored_uuid( - args: &GetArgs, - patch: &PatchResponse, - telemetry_token: Option<&str>, - telemetry_org: Option<&str>, -) -> i32 { - let quiet = args.common.json || args.common.silent; - let manifest_path = args.common.resolved_manifest_path(); - let socket_dir = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .to_path_buf(); - - if args.common.dry_run { - let selected = vec![search_result_from_response(patch)]; - let preview = super::scan::preview_vendor_json(&args.common.cwd, &selected).await; - if args.common.json { - let mut result = serde_json::json!({ - "status": "success", - "found": 1, - "patches": [], - }); - result["vendor"] = preview; - print_json(&result); - } else if !args.common.silent { - println!("[dry-run] Would download and vendor 1 patch."); - super::scan::print_dry_run_refusals(&preview); - } - return 0; - } - - // Bun preflight (see `BunVendorRefusal`): refuse BEFORE the manifest - // record is saved and before 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 shape (contract: `get --mode vendored` pre-record refusal; - // the record carries BOTH `errorCode` and `error` like the search path's - // failed records, and the envelope carries `skipped` like this path's - // success shape): - // - // { - // "status": "error", - // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, - // "error": { "code": "", "message": "" }, - // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", - // "errorCode": "", "error": "" }] - // } - // - // Human: `Error (): ` on stderr — an error, so it is - // exempt from `--silent` like every other `Error (…)` line here. - let selected = vec![search_result_from_response(patch)]; - if let Some(refusal) = bun_vendor_preflight(&args.common.cwd, &selected) - .await - .filter(|r| r.applies_to(&patch.purl)) - { - let BunVendorRefusal { code, detail, .. } = refusal; - // 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, - args.common.dry_run, - telemetry_token, - telemetry_org, - ) - .await; - if args.common.json { - print_json(&serde_json::json!({ - "status": "error", - "found": 1, - "downloaded": 0, - "skipped": 0, - "failed": 1, - "error": { "code": code, "message": detail }, - "patches": [{ - "purl": patch.purl, - "uuid": patch.uuid, - "action": "failed", - "errorCode": code, - "error": detail, - }], - })); - } else { - eprintln!("Error ({code}): {detail}"); - } - return 1; - } - - note_vendored_whole_manifest_scope(&manifest_path, &[patch.purl.as_str()], quiet).await; - - let action = match save_patch_record(args, patch, false, false).await { - Ok(action) => action, - Err(code) => return code, - }; - let changed = action != PatchAction::Skipped; - let action_label = match &action { - PatchAction::Added => "added", - PatchAction::Updated { .. } => "updated", - PatchAction::Skipped => "skipped", - }; - if !quiet { - println!("\nPatch record saved to {}", manifest_path.display()); - match &action { - PatchAction::Added => println!(" Added: 1"), - PatchAction::Updated { old_uuid } => { - println!(" Updated: 1 (replacing {})", short_uuid(old_uuid)); + // 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; + 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. + // + // JSON shape (contract: `get --mode vendored` pre-record + // refusal; the record carries BOTH `errorCode` and `error` like the + // search path's failed records, and the envelope carries `skipped` + // like this path's success shape): + // + // { + // "status": "error", + // "found": 1, "downloaded": 0, "skipped": 0, "failed": 1, + // "error": { "code": "", "message": "" }, + // "patches": [{ "purl": "…", "uuid": "…", "action": "failed", + // "errorCode": "", "error": "" }] + // } + // + // 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.cwd, selected).await; + if let Some(refusal) = bun_refusal.as_ref().filter(|r| r.applies_to(&patch.purl)) { + let BunVendorRefusal { code, detail, .. } = refusal; + // 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, + args.common.dry_run, + telemetry_token, + telemetry_org, + ) + .await; + if args.common.json { + print_json(&serde_json::json!({ + "status": "error", + "found": 1, + "downloaded": 0, + "skipped": 0, + "failed": 1, + "error": { "code": code, "message": detail }, + "patches": [{ + "purl": patch.purl, + "uuid": patch.uuid, + "action": "failed", + "errorCode": code, + "error": detail, + }], + })); + } else { + eprintln!("Error ({code}): {detail}"); } - PatchAction::Skipped => println!(" Skipped: 1 (already exists)"), + return 1; } } - let mut result = if args.common.json { - let mut patch_record = serde_json::json!({ - "purl": patch.purl, - "uuid": patch.uuid, - "action": action_label, - }); - if let PatchAction::Updated { old_uuid } = &action { - patch_record["oldUuid"] = serde_json::json!(old_uuid); - } - if changed { - merge_metadata(&mut patch_record, patch_event_metadata(patch)); - } - serde_json::json!({ - "status": "success", - "found": 1, - "downloaded": if changed { 1 } else { 0 }, - "skipped": if changed { 0 } else { 1 }, - "patches": [patch_record], - }) + // 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 (dl_code, mut result, records, blobs) = if prefetched.is_some() { + // The preflight above already read the lock: hand its outcome down. + let vendor_state = load_state(&args.common.cwd).await; + Box::pin(download_patch_records_preflighted( + selected, + ¶ms, + api_client, + prefetched_views, + vendor_state, + bun_refusal.as_ref(), + )) + .await } else { - serde_json::Value::Null + Box::pin(download_patch_records_with( + selected, + ¶ms, + api_client, + prefetched_views, + )) + .await }; + let mut has_errors = dl_code != 0; + fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); - match super::scan::boxed_scan_vendor_step(&args.common, &manifest_path, &socket_dir, None).await + // The vendor step (scan's, verbatim): apply lock, in-memory staging + // seeded with the blobs fetched above, the engine over exactly the + // records fetched above (moved in — nothing here needs them afterwards) + // and over this run's client. A per-patch download failure does not + // skip it (scan parity). + match super::scan::boxed_scan_vendor_step( + &args.common, + records, + blobs, + api_client.clone(), + use_public_proxy, + ) + .await { Ok((vendor_errors, venv)) => { + has_errors |= vendor_errors; + // Telemetry follows the RUN outcome, not the vendor step alone: + // a download-phase refusal/failure exits 1 and must not report + // a successful vendoring of zero patches (scan's arms agree). crate::commands::vendor::track_outcomes_for_vendor( - vendor_errors, + has_errors, &venv, args.common.dry_run, telemetry_token, @@ -3130,7 +3058,7 @@ async fn run_get_vendored_uuid( ) .await; if args.common.json { - result["status"] = serde_json::json!(if vendor_errors { + result["status"] = serde_json::json!(if has_errors { "partial_failure" } else { "success" @@ -3139,7 +3067,7 @@ async fn run_get_vendored_uuid( serde_json::to_value(&venv).unwrap_or_else(|_| serde_json::json!({})); print_json(&result); } - i32::from(vendor_errors) + i32::from(has_errors) } Err((code, message, venv)) => { socket_patch_core::telemetry::track_patch_vendor_failed( @@ -3150,6 +3078,9 @@ async fn run_get_vendored_uuid( ) .await; if args.common.json { + // A vendor envelope built before the failure (events + // included) must reach the JSON consumer even though the + // run aborts here. if let Some(venv) = venv { result["vendor"] = serde_json::to_value(&*venv).unwrap_or_else(|_| serde_json::json!({})); @@ -3165,6 +3096,13 @@ async fn run_get_vendored_uuid( } } +/// Decode a patch view's `blobContent` (canonical, padded base64 as the API +/// produces it). Hand-rolled only because `base64` is a dev-dependency of +/// this crate today — once it is a plain dependency (it already is one of +/// `socket-patch-core`, pinned workspace-wide), this body should become +/// `base64::engine::general_purpose::STANDARD.decode(input)` with +/// `DecodeError::InvalidByte(_, b)` mapped to the +/// `Invalid base64 character: ` message below (pinned by a unit test). pub(crate) fn base64_decode(input: &str) -> Result, String> { let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut table = [255u8; 256]; @@ -3659,7 +3597,7 @@ mod tests { // --- decide_patch_action --------------------------------------------- // Locks in the per-patch action vocabulary surfaced by - // download_and_apply_patches in JSON mode. See CLI_CONTRACT.md. + // download_and_apply_patches_with in JSON mode. See CLI_CONTRACT.md. fn manifest_with_entry(purl: &str, uuid: &str) -> PatchManifest { let mut m = PatchManifest::new(); @@ -3721,7 +3659,7 @@ mod tests { // --- severity_rank / max_vuln_severity / patch_event_metadata -------- // Pins the JSON shape of the metadata spliced into `added` / `updated` - // per-patch records by `download_and_apply_patches`. PR-comment bots + // per-patch records by `download_and_apply_patches_with`. PR-comment bots // rely on these fields — see CLI_CONTRACT.md (`get` / `scan` JSON // output, patches array). @@ -4347,11 +4285,87 @@ mod tests { !tmp.path().join("escaped").exists(), "nothing may be written outside the blobs dir" ); + assert!( + !blobs_dir.exists(), + "no blob may be written for a rejected patch, and the empty blobs/ husk is pruned" + ); + } + + /// A patch that fails HALF-WAY (its after-blob landed, its before-blob is + /// rejected) must not leave the first blob behind as an orphan no record + /// points at: the blobs this call created are unwound and the emptied + /// `blobs/` pruned. The after entry is always written before the before + /// entry of the same file, so one file suffices to pin the order. + #[tokio::test] + async fn write_all_patch_blobs_unwinds_its_own_blobs_on_a_later_failure() { + let tmp = tempfile::tempdir().unwrap(); + let blobs_dir = tmp.path().join(".socket/blobs"); + + let after = "a".repeat(64); + let mut files = HashMap::new(); + let mut info = file_resp(Some("../escaped"), Some(&after)); + info.blob_content = Some(BLOB_B64.to_string()); + info.before_blob_content = Some(BLOB_B64.to_string()); + files.insert("package/index.js".to_string(), info); + let patch = patch_with_files(files); + + let res = write_all_patch_blobs(&blobs_dir, &patch, /*quiet=*/ true).await; + assert_eq!(res, Err(())); + assert!( + !blobs_dir.join(&after).exists(), + "the after-blob written before the failure is unwound" + ); + assert!(!blobs_dir.exists(), "the emptied blobs/ husk is pruned"); + assert!( + tmp.path().join(".socket").is_dir(), + "the prune stops at .socket/ (the lock guard's to remove)" + ); + } + + /// The unwind removes only blobs THIS call created: a blob that already + /// existed (a live record's revert data, content-addressed and shared) + /// survives a later failure of the same patch byte-identical. + #[tokio::test] + async fn write_all_patch_blobs_unwind_spares_preexisting_blobs() { + let tmp = tempfile::tempdir().unwrap(); + let blobs_dir = tmp.path().join(".socket/blobs"); + tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); + let after = "a".repeat(64); + tokio::fs::write(blobs_dir.join(&after), b"patched\n") + .await + .unwrap(); + + let mut files = HashMap::new(); + let mut info = file_resp(Some("../escaped"), Some(&after)); + info.blob_content = Some(BLOB_B64.to_string()); + info.before_blob_content = Some(BLOB_B64.to_string()); + files.insert("package/index.js".to_string(), info); + let patch = patch_with_files(files); + + let res = write_all_patch_blobs(&blobs_dir, &patch, /*quiet=*/ true).await; + assert_eq!(res, Err(())); + assert_eq!( + tokio::fs::read(blobs_dir.join(&after)).await.unwrap(), + b"patched\n", + "a pre-existing blob is never this call's to remove" + ); + + // And a fully successful write reports exactly the NEW hashes. + let before = "b".repeat(64); + let mut files = HashMap::new(); + let mut info = file_resp(Some(&before), Some(&after)); + info.blob_content = Some(BLOB_B64.to_string()); + info.before_blob_content = Some(BLOB_B64.to_string()); + files.insert("package/index.js".to_string(), info); + let created = write_all_patch_blobs(&blobs_dir, &patch_with_files(files), true) + .await + .unwrap(); assert_eq!( - std::fs::read_dir(&blobs_dir).unwrap().count(), - 0, - "no blob may be written for a rejected patch" + created, + vec![before.clone()], + "the pre-existing after-blob is not new" ); + assert!(blobs_dir.join(&before).is_file()); } // --- fold_narrowing_into_result ---------------------------------------- @@ -4394,27 +4408,17 @@ mod tests { ); } - // --- resolved_api_overrides -------------------------------------------- - // The org the nested client resolves to is behavior-bearing: an explicit - // override wins; otherwise `--org` (params.org) fills the gap. - - fn dl_params_for_org(org: Option, org_slug: Option) -> DownloadParams { + /// Engine params for the nested-apply arg tests below. + fn dl_params() -> DownloadParams { DownloadParams { cwd: PathBuf::from("."), manifest_path: PathBuf::from(".socket/manifest.json"), - org, save_only: true, global: false, global_prefix: None, json: true, silent: true, download_mode: "diff".to_string(), - api_overrides: socket_patch_core::api::client::ApiClientEnvOverrides { - api_url: None, - api_token: None, - org_slug, - proxy_url: None, - }, all_releases: false, strict: false, ecosystems: None, @@ -4422,26 +4426,6 @@ mod tests { } } - #[test] - fn resolved_api_overrides_falls_back_to_params_org() { - let p = dl_params_for_org(Some("from-org".into()), None); - assert_eq!( - resolved_api_overrides(&p).org_slug.as_deref(), - Some("from-org"), - "a missing override org must fall back to --org" - ); - } - - #[test] - fn resolved_api_overrides_explicit_org_slug_wins() { - let p = dl_params_for_org(Some("from-org".into()), Some("explicit".into())); - assert_eq!( - resolved_api_overrides(&p).org_slug.as_deref(), - Some("explicit"), - "an explicit override org must not be clobbered by --org" - ); - } - // --- format_patch_option: vulnerability summaries in the option lines -- #[test] @@ -4525,23 +4509,16 @@ mod tests { } } - fn detached_params(root: &Path, server_url: String) -> DownloadParams { + fn detached_params(root: &Path) -> DownloadParams { DownloadParams { cwd: root.to_path_buf(), manifest_path: root.join(".socket/manifest.json"), - org: Some("test-org".to_string()), save_only: true, global: false, global_prefix: None, json: true, silent: true, download_mode: "diff".to_string(), - api_overrides: socket_patch_core::api::client::ApiClientEnvOverrides { - api_url: Some(server_url), - api_token: Some("fake".to_string()), - org_slug: Some("test-org".to_string()), - proxy_url: None, - }, all_releases: false, strict: false, ecosystems: None, @@ -4550,6 +4527,34 @@ mod tests { } } + /// The test client every hermetic engine test drives: the mock server + /// as API URL, a fake token, the fixture org — every override explicit + /// so no ambient `SOCKET_*` can steer it. + async fn test_client(server_url: &str) -> ApiClient { + get_api_client_with_overrides(socket_patch_core::api::client::ApiClientEnvOverrides { + api_url: Some(server_url.to_string()), + api_token: Some("fake".to_string()), + org_slug: Some("test-org".to_string()), + proxy_url: None, + }) + .await + .0 + } + + /// The 3-arg shape the vendored-download unit tests below drive: builds + /// the run's client against `server_url`, and drops the blob seed (the + /// stager's concern, pinned by fetch_stage's tests). + async fn download_patch_records( + selected: &[PatchSearchResult], + params: &DownloadParams, + server_url: &str, + ) -> (i32, serde_json::Value, HashMap) { + let api_client = test_client(server_url).await; + let (code, json, records, _blobs) = + download_patch_records_with(selected, params, &api_client, HashMap::new()).await; + (code, json, records) + } + #[tokio::test] #[serial_test::serial] async fn download_patch_records_no_applicable_files_is_failed_and_unrecorded() { @@ -4579,7 +4584,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); 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; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "guardrail failure must exit 1; json={json}"); assert_eq!(json["failed"], 1, "json={json}"); @@ -4610,7 +4615,7 @@ mod tests { 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; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "a fetch miss must exit 1; json={json}"); assert_eq!(json["failed"], 1, "json={json}"); @@ -4651,7 +4656,7 @@ mod tests { ]; let (code, json, records) = - download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); assert_eq!(json["found"], 2, "both variants must be kept; json={json}"); @@ -4903,46 +4908,51 @@ mod tests { ); } - /// `download_patch_records` with `persist_blobs`: an uncreatable blobs - /// dir (`.socket` squatted by a regular file) is a hard `error` envelope - /// BEFORE any fetch, with no records handed to the caller. + /// `download_patch_records` with `persist_blobs` on a tree whose + /// `.socket` path is squatted by a regular file: the blobs dir is created + /// lazily, at the first blob actually persisted, so the failure surfaces + /// as the per-patch `Blob decode or write failed` after the view fetch — + /// no record handed to the caller, the squatting file left untouched. #[tokio::test] #[serial_test::serial] - async fn download_patch_records_blobs_dir_create_failure_errors_before_any_fetch() { - use wiremock::MockServer; + async fn download_patch_records_persist_blobs_unwritable_blobs_dir_is_failed_and_unrecorded() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); let server = MockServer::start().await; + let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let purl = "pkg:npm/covgap-blobfail@1.0.0"; + Mock::given(method("GET")) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0".repeat(64), "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + }}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join(".socket"), b"not a dir").unwrap(); - let selected = vec![mk_patch( - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "pkg:npm/covgap-blobfail@1.0.0", - "free", - "2024-01-01", - )]; - let mut params = detached_params(tmp.path(), server.uri()); + let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; + let mut params = detached_params(tmp.path()); params.persist_blobs = true; - let (code, json, records) = download_patch_records(&selected, ¶ms).await; + let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); - assert_eq!(json["status"], "error", "json={json}"); - assert!( - json["error"] - .as_str() - .unwrap_or_default() - .contains("blobs directory"), - "the error must name the blobs dir; json={json}" + assert_eq!(json["failed"], 1, "json={json}"); + assert_eq!( + json["patches"][0]["error"], "Blob decode or write failed", + "json={json}" ); - assert!(records.is_empty()); assert!( - server - .received_requests() - .await - .unwrap_or_default() - .is_empty(), - "the failure must precede any fetch" + records.is_empty(), + "a blob failure must not hand back a record" ); assert_eq!( std::fs::read(tmp.path().join(".socket")).unwrap(), @@ -4983,9 +4993,9 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; - let mut params = detached_params(tmp.path(), server.uri()); + let mut params = detached_params(tmp.path()); params.persist_blobs = true; - let (code, json, records) = download_patch_records(&selected, ¶ms).await; + let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); assert_eq!(json["failed"], 1, "json={json}"); @@ -4997,12 +5007,10 @@ mod tests { records.is_empty(), "a blob failure must not hand back a record" ); - let blobs = tmp.path().join(".socket/blobs"); - assert!(blobs.is_dir(), "the blobs dir itself was created"); - assert_eq!( - std::fs::read_dir(&blobs).unwrap().count(), - 0, - "no blob may materialize from undecodable content" + assert!( + !tmp.path().join(".socket").exists(), + "the blobs dir is created only once a blob decodes, so undecodable \ + content must leave no `.socket/` behind at all" ); } @@ -5065,10 +5073,10 @@ mod tests { mk_patch(nofiles_uuid, nofiles_purl, "free", "2024-01-01"), mk_patch(missing_uuid, missing_purl, "free", "2024-01-01"), ]; - let mut params = detached_params(tmp.path(), server.uri()); + let mut params = detached_params(tmp.path()); params.json = false; params.silent = false; - let (code, json, records) = download_patch_records(&selected, ¶ms).await; + let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 1, "json={json}"); assert_eq!(json["downloaded"], 1, "json={json}"); @@ -5138,10 +5146,10 @@ mod tests { .unwrap(); let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; - let mut params = detached_params(tmp.path(), server.uri()); + let mut params = detached_params(tmp.path()); params.json = false; params.silent = false; - let (code, json, records) = download_patch_records(&selected, ¶ms).await; + let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 0, "json={json}"); assert_eq!(json["skipped"], 1, "json={json}"); @@ -5226,7 +5234,7 @@ mod tests { std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); 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; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); assert_eq!(json["found"], 1, "json={json}"); @@ -5268,7 +5276,7 @@ mod tests { 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; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); assert_eq!(json["failed"], 1, "json={json}"); @@ -5309,7 +5317,7 @@ mod tests { let selected = vec![mk_patch(uuid, purl, "free", "2024-01-01")]; let (code, json, _) = - download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); assert_eq!( @@ -5350,11 +5358,25 @@ mod tests { "wiring": [], "flavor": "bun", }) }; + // The in-sync entry carries the D2 shape every vendored run writes + // (detached + embedded record): exactly what the ledger idempotency + // skip keys on — the refusal must still win over that skip. + let mut in_sync_entry = entry(in_sync, same); + in_sync_entry["detached"] = serde_json::json!(true); + in_sync_entry["record"] = serde_json::json!({ + "uuid": same, + "exportedAt": "2026-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "fixture", + "license": "MIT", + "tier": "free", + }); std::fs::write( vendor.join("state.json"), serde_json::to_vec_pretty(&serde_json::json!({ "version": 1, - "entries": { in_sync: entry(in_sync, same), stale: entry(stale, older) }, + "entries": { in_sync: in_sync_entry, stale: entry(stale, older) }, })) .unwrap(), ) @@ -5365,7 +5387,7 @@ mod tests { mk_patch(newer, stale, "free", "2024-01-01"), ]; let (code, json, _) = - download_patch_records(&selected, &detached_params(tmp.path(), server.uri())).await; + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; assert_eq!(code, 1, "json={json}"); let by_purl = |purl: &str| { @@ -5470,6 +5492,203 @@ mod tests { ); } + /// The nested apply inherits the caller's flags verbatim (`--verbose` + /// and `--strict` were dropped when its args were rebuilt from Default), + /// with `json`/`dry_run` forced off — one JSON document per run, and + /// agent-mode `get` ignores `--dry-run` — `silent` following the caller's + /// quiet gate, and the manifest path absolutized so apply does not + /// re-resolve it against its own `--cwd`. + #[test] + fn nested_apply_args_flow_caller_flags_and_force_a_real_quiet_apply() { + let common = GlobalArgs { + verbose: true, + strict: true, + json: true, + dry_run: true, + ..GlobalArgs::default() + }; + let nested = nested_apply_args(&common, Path::new("proj/.socket/manifest.json"), true); + assert!( + nested.verbose && nested.strict, + "--verbose / --strict must flow through" + ); + assert!( + !nested.json && !nested.dry_run, + "the nested apply is always a real, non-JSON run" + ); + assert!(nested.silent, "silent follows the caller's quiet gate"); + assert!( + Path::new(&nested.manifest_path).is_absolute(), + "got {}", + nested.manifest_path + ); + } + + /// The engine's variant rebuilds the same shape from `DownloadParams` + + /// `DownloadRun`: the run's verbosity flag, the caller's scope/mode + /// flags, and quiet = json || silent. No API fields: the nested apply + /// runs on the run's client, so `--org` need not be re-threaded. + #[test] + fn nested_apply_args_from_params_carry_run_flags() { + let client = ApiClient::new(socket_patch_core::api::client::ApiClientOptions { + api_url: "http://127.0.0.1:1".into(), + api_token: None, + use_public_proxy: false, + org_slug: None, + }); + let run = DownloadRun { + api_client: &client, + lock_timeout: Some(7), + verbose: true, + }; + let params = dl_params(); + let nested = + nested_apply_args_from_params(¶ms, &run, Path::new(".socket/manifest.json")); + assert!(nested.verbose); + assert!( + nested.org.is_none() && nested.api_token.is_none(), + "API fields are never threaded through params: the nested apply runs on the run's client" + ); + assert_eq!(nested.download_mode, "diff"); + assert!(nested.silent, "json || silent params run a quiet apply"); + assert!(!nested.json && !nested.dry_run); + } + + /// The uuid path hands the engine the view it already fetched: the + /// record is served from `prefetched` with ZERO network traffic (a + /// fresh fetch could re-hit the 401 the proxy fallback recovered from), + /// and the ledger-free classification reports it `downloaded`. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_with_prefetched_view_never_fetches() { + use wiremock::MockServer; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; // trap: no mounts + let tmp = tempfile::tempdir().unwrap(); + // Two files: one with served `blobContent` (→ the blob seed), one + // without (→ contributes nothing, and is NOT a failure). + let mut seeded = file_resp(Some(&"0".repeat(64)), Some(&"1".repeat(64))); + seeded.blob_content = Some("cGF0Y2hlZA==".to_string()); // "patched" + let mut patch = patch_with_files(HashMap::from([ + ("package/index.js".to_string(), seeded), + ( + "package/other.js".to_string(), + file_resp(Some(&"2".repeat(64)), Some(&"3".repeat(64))), + ), + ])); + patch.uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into(); + patch.purl = "pkg:npm/covgap-prefetched@1.0.0".into(); + let selected = vec![mk_patch(&patch.uuid, &patch.purl, "free", "2024-01-01")]; + let params = detached_params(tmp.path()); + let client = test_client(&server.uri()).await; + let prefetched = HashMap::from([(patch.uuid.clone(), patch.clone())]); + + let (code, json, records, blobs) = + download_patch_records_with(&selected, ¶ms, &client, prefetched).await; + + assert_eq!(code, 0, "json={json}"); + assert_eq!(json["downloaded"], 1, "json={json}"); + // The blob seed carries every served `blobContent` by after-hash — + // decoded — and only those; the vendor stager starts from it. + assert_eq!( + blobs.get(&"1".repeat(64)).map(Vec::as_slice), + Some(&b"patched"[..]), + "the served blob is seeded under its after-hash" + ); + assert_eq!(blobs.len(), 1, "a file with no blobContent seeds nothing"); + assert_eq!(json["detached"], true, "json={json}"); + assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); + assert!( + json["patches"][0].get("oldUuid").is_none(), + "no ledger entry, no oldUuid; json={json}" + ); + assert_eq!( + records.get(&patch.purl).map(|r| r.uuid.as_str()), + Some(patch.uuid.as_str()), + "the record must be built from the prefetched view" + ); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "a prefetched view must never be fetched again" + ); + assert!( + !tmp.path().join(".socket").exists(), + "the detached download phase writes nothing" + ); + } + + /// A ledger entry at an OLDER uuid: the fetched record is `downloaded` + /// and carries `oldUuid` — the re-vendor the vendor step will perform — + /// derived from the ledger, since the vendored flows have no manifest. + #[tokio::test] + #[serial_test::serial] + async fn download_patch_records_superseding_uuid_carries_old_uuid_from_ledger() { + use wiremock::matchers::{method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let server = MockServer::start().await; + let purl = "pkg:npm/covgap-supersede@1.0.0"; + let old_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let new_uuid = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + Mock::given(method("GET")) + .and(wm_path(format!( + "/v0/orgs/test-org/patches/view/{new_uuid}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": new_uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": "0".repeat(64), "afterHash": "1".repeat(64), + "blobContent": "cGF0Y2hlZAo=", + }}, + "vulnerabilities": {}, "description": "d", "license": "MIT", "tier": "free", + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let vendor = tmp.path().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": old_uuid, + "artifact": { + "path": format!(".socket/vendor/npm/{old_uuid}/covgap-supersede-1.0.0.tgz"), + }, + "wiring": [] + }} + })) + .unwrap(), + ) + .unwrap(); + + let selected = vec![mk_patch(new_uuid, purl, "free", "2024-01-01")]; + let (code, json, records) = + download_patch_records(&selected, &detached_params(tmp.path()), &server.uri()).await; + + assert_eq!(code, 0, "json={json}"); + assert_eq!(json["downloaded"], 1, "json={json}"); + assert_eq!(json["skipped"], 0, "json={json}"); + assert_eq!(json["patches"][0]["action"], "downloaded", "json={json}"); + assert_eq!(json["patches"][0]["oldUuid"], old_uuid, "json={json}"); + assert_eq!( + records.get(purl).map(|r| r.uuid.as_str()), + Some(new_uuid), + "the superseding record is what the vendor step receives" + ); + } + /// The env guard must RESTORE a variable that was set before the scrub — /// the suite depends on it not leaking scrubbed state across tests. #[test] diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 151b1ed0..936efbb9 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,11 +1,9 @@ -use std::path::{Path, PathBuf}; - use clap::Args; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; use socket_patch_core::telemetry::track_patch_listed; -use socket_patch_core::utils::socket_cli_config; +use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ @@ -18,55 +16,96 @@ pub struct ListArgs { pub common: GlobalArgs, } -/// One listable patch record with its provenance: a `.socket/manifest.json` -/// entry (agent/vendored modes) or a hosted redirect-ledger record -/// (`scan --mode hosted` records its patches ONLY in -/// `.socket/vendor/redirect-state.json` and never writes the manifest — -/// without the ledger records, a purely hosted-wired project listed as -/// `manifest_not_found` while its patches were demonstrably live). +/// Where a listed record lives. Declaration order is the tie-break order +/// when one purl appears in several stores: coexistence is real state (e.g. +/// an agent-applied patch alongside live hosted wiring), so every copy is +/// shown, labeled apart. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Source { + /// A `.socket/manifest.json` entry (agent mode). + Manifest, + /// A hosted redirect-ledger record: `scan --mode hosted` records its + /// patches ONLY in `.socket/vendor/redirect-state.json` and never + /// writes the manifest — without these, a purely hosted-wired project + /// listed as `manifest_not_found` while its patches were demonstrably + /// live. + Hosted, + /// A vendor-ledger record: vendored mode is manifest-free, so every + /// `scan`/`get --mode vendored` patch lives ONLY in + /// `.socket/vendor/state.json`, as a `detached` entry's embedded record + /// (the hosted rule again — a vendored-only project lists and exits 0). + Vendored, +} + +/// The `(mode, ledger)` label pair for a ledger-sourced record — the shared +/// constant labels, never a ledger's own opaque `mode` string (see +/// `HOSTED_MODE_LABEL`'s docs) — or `None` for a manifest entry. Shared by +/// the JSON `details` and the human `Mode:` line. +fn ledger_label(source: Source) -> Option<(&'static str, &'static str)> { + match source { + Source::Manifest => None, + Source::Hosted => Some((crate::commands::HOSTED_MODE_LABEL, REDIRECT_STATE_REL)), + Source::Vendored => Some((crate::commands::VENDORED_MODE_LABEL, VENDOR_STATE_REL)), + } +} + +/// One listable patch record with its provenance. struct ListEntry<'a> { purl: &'a str, record: &'a PatchRecord, - /// `true` when the record comes from the hosted redirect ledger. - hosted: bool, + source: Source, } -/// Every listable record from both stores, in a stable order: by PURL, the -/// manifest entry before the hosted-ledger record when one purl appears in -/// BOTH (coexistence is real state — e.g. an agent-applied patch alongside -/// live hosted wiring — so both are shown, labeled apart). The record maps -/// (`HashMap` manifest / `BTreeMap` ledger) never impose an order shared -/// consumers could diff, so the sort here is the contract. +/// Every listable record from all three stores, in a stable order: by +/// PURL, then manifest < hosted < vendored when one purl appears in more +/// than one. The record maps (`HashMap` manifest and vendor ledger / +/// `BTreeMap` redirect ledger) never impose an order shared consumers could +/// diff, so the sort here is the contract. Only vendor entries that carry +/// an embedded record fold in — a legacy manifest-tracked entry has no +/// record of its own (the manifest's IS the record) and would otherwise +/// double-list its purl. fn combined_entries<'a>( manifest: Option<&'a PatchManifest>, redirect: Option<&'a RedirectState>, + vendor: Option<&'a std::collections::HashMap>, ) -> Vec> { let mut entries: Vec> = Vec::new(); if let Some(manifest) = manifest { entries.extend(manifest.patches.iter().map(|(purl, record)| ListEntry { purl, record, - hosted: false, + source: Source::Manifest, })); } if let Some(redirect) = redirect { entries.extend(redirect.records.iter().map(|(purl, record)| ListEntry { purl, record, - hosted: true, + source: Source::Hosted, + })); + } + if let Some(vendor) = vendor { + entries.extend(vendor.iter().filter_map(|(purl, entry)| { + let record = entry.record.as_ref().filter(|_| entry.detached)?; + Some(ListEntry { + purl, + record, + source: Source::Vendored, + }) })); } - entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.hosted.cmp(&b.hosted))); + entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.source.cmp(&b.source))); entries } /// Build the `list --json` envelope: one `Discovered` event per entry, with /// the rich metadata (vulnerabilities, tier, license, description, /// exportedAt) under `details` per the per-command extension convention. -/// Hosted-ledger records additionally carry `details.mode` (the constant -/// [`crate::commands::HOSTED_MODE_LABEL`]) and `details.ledger` naming the -/// redirect ledger (additive keys, absent on manifest entries), so -/// consumers can tell the stores apart. +/// Ledger records additionally carry `details.mode` (the constants +/// [`crate::commands::HOSTED_MODE_LABEL`] / +/// [`crate::commands::VENDORED_MODE_LABEL`]) and `details.ledger` naming +/// the ledger they came from (additive keys, absent on manifest entries), +/// so consumers can tell the stores apart. /// /// Events are emitted in the entries' given order — [`combined_entries`] /// owns the by-PURL event sort; this builder sorts each event's @@ -116,12 +155,9 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { "description": patch.description, "vulnerabilities": vulnerabilities, }); - if entry.hosted { - // The shared constant label, never the ledger's own opaque - // `mode` string — see HOSTED_MODE_LABEL's docs (scan's - // `redirectState` block emits the same label, one owner). - details["mode"] = serde_json::json!(crate::commands::HOSTED_MODE_LABEL); - details["ledger"] = serde_json::json!(REDIRECT_STATE_REL); + if let Some((mode, ledger)) = ledger_label(entry.source) { + details["mode"] = serde_json::json!(mode); + details["ledger"] = serde_json::json!(ledger); } env.record( @@ -135,62 +171,6 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { env } -/// Resolve the credentials the `patch_listed` telemetry event is attributed -/// to: `--api-token` / `--org` (clap already folds in `SOCKET_API_TOKEN` / -/// `SOCKET_ORG_SLUG` and their promoted `SOCKET_CLI_*` aliases), then the -/// socket-cli `config.json` written by `socket login`. -/// -/// The config layer is part of the contract for both settings ("Persisted -/// configuration" in CLI_CONTRACT.md), and -/// `telemetry::resolve_telemetry_endpoint` only uses the org-scoped -/// `/v0/orgs//telemetry` endpoint when BOTH a token and a slug reach -/// it. Passing the raw flag values here skipped the config layer, so a -/// caller authenticated by `socket login` alone had every `list` reported -/// anonymously to the public patch proxy — while `apply`/`repair`/`remove`/ -/// `rollback` (which take theirs from `get_api_client_with_overrides`) -/// reported to that caller's org. With an on-prem `apiBaseUrl` that also -/// broke the "telemetry can never target a different host than the client" -/// property, sending the event off to `patches-api.socket.dev` instead. -/// -/// The API client is deliberately NOT built to get these: `list` is a purely -/// local read, and constructing one would add the org-slug auto-resolve -/// round-trip and the "No SOCKET_API_TOKEN set" advisory to a command that -/// needs neither. Only the two credential lookups are mirrored — including -/// the `SOCKET_NO_API_TOKEN` veto over *ambient* tokens (`main` scrubs the -/// env var for the flag layer; core applies the same veto to the config -/// layer) and the `--debug` echo naming the resolution source. -pub(crate) fn telemetry_credentials(common: &GlobalArgs) -> (Option, Option) { - let api_token = common - .api_token - .clone() - .filter(|t| !t.is_empty()) - .or_else(|| { - if socket_cli_config::no_api_token_veto() { - return None; - } - socket_cli_config::load() - .and_then(|c| c.api_token.clone()) - .inspect(|_| { - if common.debug { - eprintln!( - "[socket-patch debug] api token: from socket-cli config \ - (`socket login`)" - ); - } - }) - }); - let org_slug = common.org.clone().filter(|s| !s.is_empty()).or_else(|| { - socket_cli_config::load() - .and_then(|c| c.default_org.clone()) - .inspect(|slug| { - if common.debug { - eprintln!("[socket-patch debug] org slug: `{slug}` from socket-cli config"); - } - }) - }); - (api_token, org_slug) -} - /// Emit the top-level envelope for `list` in error states. Used for the /// "manifest not found" and "manifest unreadable" paths so they share /// the same JSON shape as a successful list. @@ -204,26 +184,6 @@ fn emit_error(args: &ListArgs, code: &str, message: String) { } } -/// The project root whose redirect ledger accompanies the manifest being -/// listed. Both stores must come from the SAME project, so the root is -/// derived from the RESOLVED manifest path rather than hardcoding cwd: -/// the manifest's directory, stepping out of a standard `.socket/` layout -/// when the manifest lives in one. For the default -/// `/.socket/manifest.json` this is exactly `cwd`; for a -/// `--manifest-path` into another project it is that project's root (its -/// `.socket` parent's parent), or — for a bare file like -/// `--manifest-path /tmp/x/abs.json` — the file's own directory. -fn ledger_root(common: &GlobalArgs, manifest_path: &Path) -> PathBuf { - match manifest_path.parent() { - Some(dir) if dir.file_name() == Some(std::ffi::OsStr::new(".socket")) => dir - .parent() - .map(std::path::Path::to_path_buf) - .unwrap_or_else(|| common.cwd.clone()), - Some(dir) => dir.to_path_buf(), - None => common.cwd.clone(), - } -} - pub async fn run(args: ListArgs) -> i32 { apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); @@ -257,26 +217,32 @@ pub async fn run(args: ListArgs) -> i32 { } }; - // Hosted-mode patches live ONLY in the redirect ledger, so `list` - // consults it alongside the manifest — leniently (a malformed ledger - // degrades to "nothing to consult", surfaced on stderr unless --silent; - // the hosted write path hard-errors on it instead), and always from the - // SAME project as the manifest: with `--manifest-path` pointing at - // another project, reading the LOCAL cwd's ledger would interleave two - // projects' patch state (and a local ledger could suppress the flagged - // project's manifest_not_found). - let redirect_state = crate::commands::load_redirect_state_lenient( - &ledger_root(&args.common, &manifest_path), - args.common.silent, - ) - .await; - - // `combined_entries` folds only ledger RECORDS in (an edits-only ledger - // — post-takeover residue / a degraded record-fetch-failed run — - // asserts no patches), so entry emptiness is the whole exit predicate. - let entries = combined_entries(manifest.as_ref(), redirect_state.as_ref()); + // Hosted-mode patches live ONLY in the redirect ledger and vendored-mode + // patches ONLY in the vendor ledger, so `list` consults both alongside + // the manifest — leniently (a malformed ledger degrades to "nothing to + // consult", surfaced on stderr unless --silent; the write paths + // hard-error on it instead), and always from the SAME project as the + // manifest (`project_root` steps out of the manifest's `.socket/`): + // with `--manifest-path` pointing at another project, reading the LOCAL + // cwd's ledgers would interleave two projects' patch state (and a local + // ledger could suppress the flagged project's manifest_not_found). + let project_root = args.common.project_root(); + let redirect_state = + crate::commands::load_redirect_state_lenient(&project_root, args.common.silent).await; + let vendor_state = + crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; + + // `combined_entries` folds only ledger RECORDS in (an edits-only + // redirect ledger — post-takeover residue / a degraded record-fetch- + // failed run — and a record-less legacy vendor entry assert no + // patches), so entry emptiness is the whole exit predicate. + let entries = combined_entries( + manifest.as_ref(), + redirect_state.as_ref(), + vendor_state.as_ref().map(|s| &s.entries), + ); if manifest.is_none() && entries.is_empty() { - // No manifest AND no hosted records: nothing is listable anywhere — + // No manifest AND no ledger records: nothing is listable anywhere — // the classic missing-manifest error. `read_manifest` returns // `Ok(None)` only when the file does not exist (its documented // contract), so this is `manifest_not_found`, NOT `manifest_invalid` @@ -300,7 +266,7 @@ pub async fn run(args: ListArgs) -> i32 { // purls present in both stores. Hosted visibility, if wanted, belongs // in a new dedicated field. let manifest_patch_count = manifest.as_ref().map_or(0, |m| m.patches.len()); - let (api_token, org_slug) = telemetry_credentials(&args.common); + let (api_token, org_slug) = args.common.telemetry_credentials(); track_patch_listed( manifest_patch_count, api_token.as_deref(), @@ -322,15 +288,13 @@ pub async fn run(args: ListArgs) -> i32 { let patch = entry.record; println!("Package: {}", entry.purl); println!(" UUID: {}", patch.uuid); - if entry.hosted { + if let Some((mode, ledger)) = ledger_label(entry.source) { // Same labeling rule as the JSON details: the record comes - // from the hosted redirect ledger — installs resolve this - // package to the hosted patch server; no manifest entry - // exists or is needed. - println!( - " Mode: {} (recorded in {REDIRECT_STATE_REL})", - crate::commands::HOSTED_MODE_LABEL - ); + // from a ledger, not the manifest — hosted installs resolve + // the package to the hosted patch server, vendored ones to + // the committed `.socket/vendor/` artifact; no manifest + // entry exists or is needed. + println!(" Mode: {mode} (recorded in {ledger})"); } println!(" Tier: {}", patch.tier); println!(" License: {}", patch.license); @@ -386,7 +350,7 @@ mod tests { /// most tests below need; the hosted tests call `combined_entries` /// directly with a `RedirectState`. fn manifest_envelope(manifest: &PatchManifest) -> Envelope { - build_list_envelope(&combined_entries(Some(manifest), None)) + build_list_envelope(&combined_entries(Some(manifest), None, None)) } fn sample_manifest() -> PatchManifest { @@ -594,45 +558,6 @@ mod tests { assert_eq!(paths, vec!["z/a.js", "z/b.js"]); } - // -- Telemetry credential resolution --------------------------------- - // The socket-cli `config.json` layer is exercised end-to-end (it is read - // once per process, so it needs a subprocess) by - // `tests/cli_config_fallback.rs::list_telemetry_follows_socket_cli_login`. - // These pin the two layers above it, which need no fixture. - - /// Explicit values — the flag, or the env var clap folds into the same - /// field — are used verbatim, never overridden by a lower layer. - #[test] - fn telemetry_credentials_prefer_explicit_values() { - let common = GlobalArgs { - api_token: Some("sktsec_flag_api".to_string()), - org: Some("flag-org".to_string()), - ..GlobalArgs::default() - }; - assert_eq!( - telemetry_credentials(&common), - ( - Some("sktsec_flag_api".to_string()), - Some("flag-org".to_string()) - ) - ); - } - - /// Empty means "unset" repo-wide, so an empty value must never be - /// forwarded: `Some("")` would build a malformed `/v0/orgs//telemetry` - /// URL and an empty `Bearer ` header. - #[test] - fn telemetry_credentials_treat_empty_as_unset() { - let common = GlobalArgs { - api_token: Some(String::new()), - org: Some(String::new()), - ..GlobalArgs::default() - }; - let (api_token, org_slug) = telemetry_credentials(&common); - assert_ne!(api_token.as_deref(), Some("")); - assert_ne!(org_slug.as_deref(), Some("")); - } - /// Hosted redirect-ledger records fold into the envelope labeled apart /// from manifest entries: `details.mode` / `details.ledger` ride the /// hosted events ONLY (additive keys), and the global purl sort holds @@ -651,7 +576,7 @@ mod tests { .records .insert("pkg:npm/aaa-hosted@1.0.0".to_string(), hosted_record); - let env = build_list_envelope(&combined_entries(Some(&manifest), Some(&redirect))); + let env = build_list_envelope(&combined_entries(Some(&manifest), Some(&redirect), None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["summary"]["discovered"], 3); let events = v["events"].as_array().unwrap(); @@ -694,13 +619,105 @@ mod tests { "pkg:npm/minimist@1.2.2".to_string(), manifest.patches["pkg:npm/minimist@1.2.2"].clone(), ); - let env = build_list_envelope(&combined_entries(None, Some(&redirect))); + let env = build_list_envelope(&combined_entries(None, Some(&redirect), None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 1); assert_eq!(v["events"][0]["details"]["mode"], "hosted"); } + /// A vendor-ledger entry: `detached` with the embedded record when + /// `record` is given (the manifest-free vendored posture), a legacy + /// manifest-tracked entry (no record of its own) otherwise. Built from + /// the on-disk JSON shape so the fixture follows the ledger schema. + fn vendor_entry(purl: &str, record: Option) -> VendorEntry { + serde_json::from_value(serde_json::json!({ + "ecosystem": "npm", + "basePurl": purl, + "uuid": record + .as_ref() + .map_or("legacy-uuid", |r| r.uuid.as_str()), + "artifact": { "path": ".socket/vendor/npm/x/pkg.tgz" }, + "wiring": [], + "detached": record.is_some(), + "record": record, + })) + .expect("vendor entry fixture deserializes") + } + + /// Vendor-ledger records fold in labeled `vendored` with their ledger, + /// sort after the hosted record on a purl tie, and a legacy + /// manifest-tracked entry (no embedded record) never double-lists its + /// manifest purl. A vendored-only listing is a success envelope — the + /// hosted-only rule applied to the manifest-free vendored mode. + #[test] + fn vendored_ledger_records_are_labeled_and_sorted_last() { + let manifest = sample_manifest(); + let record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); + let mut redirect = RedirectState::new(); + redirect + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), record.clone()); + let mut detached = record.clone(); + detached.uuid = "44444444-4444-4444-8444-444444444444".to_string(); + let mut vendor = HashMap::new(); + vendor.insert( + "pkg:npm/minimist@1.2.2".to_string(), + vendor_entry("pkg:npm/minimist@1.2.2", Some(detached)), + ); + vendor.insert( + "pkg:npm/zzz-vendored@1.0.0".to_string(), + vendor_entry("pkg:npm/zzz-vendored@1.0.0", Some(record)), + ); + // Legacy manifest-tracked vendoring: the manifest holds the record. + vendor.insert( + "pkg:npm/minimist@1.2.2#legacy".to_string(), + vendor_entry("pkg:npm/minimist@1.2.2", None), + ); + + let env = build_list_envelope(&combined_entries( + Some(&manifest), + Some(&redirect), + Some(&vendor), + )); + let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); + let listed: Vec<(&str, &str)> = v["events"] + .as_array() + .unwrap() + .iter() + .map(|e| { + ( + e["purl"].as_str().unwrap(), + e["details"]["mode"].as_str().unwrap_or("manifest"), + ) + }) + .collect(); + assert_eq!( + listed, + vec![ + ("pkg:npm/minimist@1.2.2", "manifest"), + ("pkg:npm/minimist@1.2.2", "hosted"), + ("pkg:npm/minimist@1.2.2", "vendored"), + ("pkg:npm/zzz-vendored@1.0.0", "vendored"), + ], + "purl-sorted, manifest < hosted < vendored on a tie, record-less entries skipped: {v}" + ); + let events = v["events"].as_array().unwrap(); + assert_eq!( + events[2]["details"]["ledger"], ".socket/vendor/state.json", + "{v}" + ); + assert_eq!( + events[2]["uuid"], "44444444-4444-4444-8444-444444444444", + "the ledger's embedded record is the one listed: {v}" + ); + + let only = build_list_envelope(&combined_entries(None, None, Some(&vendor))); + let v: serde_json::Value = serde_json::from_str(&only.to_pretty_json()).unwrap(); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["summary"]["discovered"], 2, "{v}"); + } + #[test] fn ordering_is_deterministic_across_builds() { // Two independent builds of the same manifest must be byte-identical. diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 8354c4e1..33e9d6bd 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -31,15 +31,15 @@ use crate::json_envelope::{Command, Envelope, EnvelopeError}; /// try-once shape. Positive values wait with a 100 ms backoff — /// see `socket_patch_core::patch::apply_lock::acquire`. /// -/// A leftover `apply.lock` from a crashed run never contends: the -/// kernel released the dead holder's advisory lock along with its -/// file handle, so the acquire reclaims the file in place. `Held` -/// therefore always means a *live* process. The file is never -/// unlinked here — an unlink defeats mutual exclusion, because a -/// competitor (live holder or mid-acquire racer) can keep or take an -/// advisory lock on the orphaned inode while a fresh acquire locks -/// its replacement. The only sanctioned deletion is `repair`'s final -/// cleanup, which runs after its own guard is released. +/// The lock's whole lifecycle lives in the core guard: `acquire` +/// creates a missing `.socket/` itself, and the returned guard's drop +/// unlinks `apply.lock` while still holding the lock, releases it, and +/// prunes an otherwise-empty `.socket/` — so no command leaves a lock +/// file (or a bare `.socket/`) behind, and this wrapper never has to +/// touch the file. A leftover from a crashed run never contends: the +/// kernel released the dead holder's advisory lock along with its file +/// handle, so the acquire reclaims the file in place and removes it on +/// exit. `Held` therefore always means a *live* process. pub(crate) fn acquire_or_emit( socket_dir: &Path, command: Command, @@ -49,25 +49,34 @@ pub(crate) fn acquire_or_emit( ) -> Result { match acquire(socket_dir, timeout) { Ok(guard) => Ok(guard), - Err(LockError::Held) => { - emit( - command, - json, - dry_run, - "lock_held", - &held_message(timeout), - Hint::Wait, - ); - Err(1) - } - Err(LockError::Io { path, source }) => { - let msg = format!("failed to open lock file at {}: {}", path.display(), source); - emit(command, json, dry_run, "lock_io", &msg, Hint::None); + Err(err) => { + let hint = match err { + LockError::Held => Hint::Wait, + LockError::Io { .. } => Hint::None, + }; + let (code, message) = lock_failure(&err, timeout); + emit(command, json, dry_run, code, &message, hint); Err(1) } } } +/// The one `LockError` → (`errorCode`, message) mapping every lock +/// site renders: `Held` → `lock_held` with the wait budget spelled out +/// by [`held_message`], `Io` → `lock_io` naming the path and the OS +/// error. Callers that build their own envelope (the scan/vendor step, +/// GC, hosted) use this rather than re-deriving the strings, so the +/// contention text and the waited clause cannot drift between commands. +pub(crate) fn lock_failure(err: &LockError, timeout: Duration) -> (&'static str, String) { + match err { + LockError::Held => ("lock_held", held_message(timeout)), + LockError::Io { path, source } => ( + "lock_io", + format!("failed to open lock file at {}: {}", path.display(), source), + ), + } +} + /// Human-readable description of a `lock_held` contention for the given /// wait budget. A zero budget means the historical non-blocking /// try-once, so we omit the "(waited …)" clause entirely. @@ -165,17 +174,33 @@ mod tests { assert_eq!(code, 1); } + /// A missing `.socket/` is not an error: `acquire` creates it, and + /// the guard's drop removes the lock file and the now-empty + /// directory again, so a lock-only run leaves no trace. #[test] - fn acquire_or_emit_returns_one_when_socket_dir_missing() { + fn acquire_or_emit_creates_missing_socket_dir_and_prunes_it_on_drop() { let dir = tempfile::tempdir().unwrap(); - let code = acquire_or_emit( - &dir.path().join("nope"), - Command::Apply, - false, - false, - Duration::ZERO, - ) - .unwrap_err(); + let socket = dir.path().join(".socket"); + assert!(!socket.exists()); + + let guard = acquire_or_emit(&socket, Command::Apply, false, false, Duration::ZERO).unwrap(); + assert!(socket.join("apply.lock").is_file()); + + drop(guard); + assert!(!socket.join("apply.lock").exists()); + assert!(!socket.exists(), "empty .socket/ must be pruned on release"); + } + + /// A file squatting where `.socket/` should be still surfaces as + /// `lock_io` / exit 1 — the acquire-mkdirs change did not turn + /// genuine faults into silent successes. + #[test] + fn acquire_or_emit_returns_one_when_socket_dir_is_a_file() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join(".socket"); + std::fs::write(&socket, b"squatter").unwrap(); + let code = + acquire_or_emit(&socket, Command::Apply, false, false, Duration::ZERO).unwrap_err(); assert_eq!(code, 1); } @@ -208,27 +233,32 @@ mod tests { /// A leftover lock file from a crashed run never contends — the /// kernel released the dead holder's advisory lock along with its - /// file handle, so a plain acquire reclaims the file in place. - /// This is the fact that made `--break-lock` redundant (and, with - /// it, the `unlock` subcommand): there is no stale-lock state a - /// user ever needs to clear before running a mutating command. + /// file handle, so a plain acquire reclaims the file in place and + /// the guard's drop removes it. This is the fact that made + /// `--break-lock` redundant (and, with it, the `unlock` + /// subcommand): there is no stale-lock state a user ever needs to + /// clear before running a mutating command. #[test] fn acquire_or_emit_reclaims_stale_leftover_file() { let dir = tempfile::tempdir().unwrap(); // Pre-stage a lock file with no holder — simulates the // post-crash leftover scenario. - std::fs::write(dir.path().join("apply.lock"), b"").unwrap(); + std::fs::write(dir.path().join("apply.lock"), b"leftover").unwrap(); let guard = acquire_or_emit(dir.path(), Command::Apply, false, false, Duration::ZERO).unwrap(); - // The file persists (never unlinked here) and we hold the lock: - // a competitor's acquire is contended while the guard is live. + // The reclaimed file is the live lock while the guard is held: a + // competitor's acquire is contended. assert!(dir.path().join("apply.lock").is_file()); assert!(matches!( acquire(dir.path(), Duration::ZERO), Err(LockError::Held) )); drop(guard); + assert!( + !dir.path().join("apply.lock").exists(), + "the reclaimed leftover is removed on release" + ); } /// Regression guard carried over from the `--break-lock` era: the @@ -238,22 +268,26 @@ mod tests { /// re-acquired: a competitor that flocked (or had merely *opened*) /// the file before the unlink kept a valid lock on the orphaned /// inode while the re-acquire locked a fresh one — two live holders - /// at once. `acquire_or_emit` never unlinks: the acquire's guard is - /// the lock. + /// at once. Today every guard drop unlinks the file, so this is the + /// live stress test of the core protocol that makes that safe: + /// unlink WHILE holding the lock, and re-check the locked handle's + /// identity against the path after every successful lock. /// /// The competitor thread increments a shared holder count only /// while it genuinely holds the OS lock, as does the main thread /// for the guard `acquire_or_emit` hands back. With real mutual /// exclusion the count can never exceed 1, so the test is /// deterministic-green on correct code; under a buggy unlink window - /// the hammer lands in the gap within a handful of iterations. + /// the hammer lands in the gap within a handful of iterations. The + /// lock dir is a `.socket/` so every release also prunes the + /// directory and every acquire recreates it. #[test] fn acquire_or_emit_preserves_mutual_exclusion() { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; let dir = tempfile::tempdir().unwrap(); - let lock_dir = dir.path().to_path_buf(); + let lock_dir = dir.path().join(".socket"); let holders = Arc::new(AtomicUsize::new(0)); let violated = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false)); @@ -301,8 +335,41 @@ mod tests { assert!( !violated.load(Ordering::SeqCst), - "two processes held the apply lock at once: \ - the lock file must never be unlinked by the acquire path" + "two processes held the apply lock at once: the acquire path must never \ + unlink, and a release must not orphan a competitor's open handle \ + (unlink under the lock + post-lock identity check)" + ); + assert!( + !lock_dir.join("apply.lock").exists() && !lock_dir.exists(), + "no lock residue may outlive the last holder" + ); + } + + /// `lock_failure` is the single rendering every lock site shares: + /// `Held` carries the waited clause, `Io` names the path and the OS + /// error under `lock_io`. + #[test] + fn lock_failure_maps_both_variants() { + assert_eq!( + lock_failure(&LockError::Held, Duration::from_secs(2)), + ( + "lock_held", + "another socket-patch process is operating in this directory (waited 2s)" + .to_string() + ) + ); + let io = LockError::Io { + path: std::path::PathBuf::from(".socket/apply.lock"), + source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), + }; + let (code, message) = lock_failure(&io, Duration::ZERO); + assert_eq!(code, "lock_io"); + assert_eq!( + message, + format!( + "failed to open lock file at {}: denied", + std::path::Path::new(".socket/apply.lock").display() + ) ); } diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index b969dba1..33786a54 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -25,6 +25,13 @@ use std::path::Path; /// these keys must not have to know that history. pub(crate) const HOSTED_MODE_LABEL: &str = "hosted"; +/// The documented name of the mode whose ledger is +/// `.socket/vendor/state.json` — `list`'s label for a vendored patch +/// record. Vendored mode is manifest-free: every `scan`/`get --mode +/// vendored` entry is written `detached: true` with its embedded patch +/// `record`, so the ledger is the only place those records live. +pub(crate) const VENDORED_MODE_LABEL: &str = "vendored"; + /// Read-only lenient load of the hosted redirect ledger: missing → `None` /// (a fresh start); malformed → `None` with the corruption surfaced on /// stderr unless `silent`. This is the "read-only consumers may degrade a @@ -47,3 +54,53 @@ pub(crate) async fn load_redirect_state_lenient( } } } + +/// Read-only lenient load of the vendor ledger (`.socket/vendor/state.json`): +/// missing → an empty ledger; malformed/unreadable → `None` with the +/// problem surfaced on stderr unless `silent`. The vendor twin of +/// [`load_redirect_state_lenient`], with the same posture: a read-only +/// consumer (`list`) degrades a broken ledger to nothing-to-consult but +/// must say so, while every path that writes or attests from it fails +/// closed instead. +pub(crate) async fn load_vendor_state_lenient( + root: &Path, + silent: bool, +) -> Option { + match socket_patch_core::vendor::load_state(root).await { + Ok(state) => Some(state), + Err(e) => { + if !silent { + eprintln!( + "Warning: unreadable vendor ledger ({e}); its vendored patches are not listed" + ); + } + None + } + } +} + +/// Fold the vendor ledger's DETACHED entries into a manifest view. Vendored +/// mode is manifest-free (every `scan`/`get --mode vendored` entry carries +/// `detached: true` plus its embedded patch `record`), so the ledger is the +/// only copy of those records: verification (`setup --check`, property 4) +/// and attestation (`vex`) must see them exactly like manifest entries. +/// Keyed by the ledger key; an existing manifest entry wins a collision +/// (that purl is manifest-owned and verifies against the manifest's +/// record). Entries without an embedded record (legacy manifest-tracked +/// vendoring) contribute nothing — their record IS the manifest's. +pub(crate) fn fold_detached_records( + manifest: &mut socket_patch_core::manifest::schema::PatchManifest, + entries: &std::collections::HashMap, +) { + for (key, entry) in entries { + if !entry.detached { + continue; + } + let Some(record) = &entry.record else { + continue; + }; + if !manifest.patches.contains_key(key) { + manifest.patches.insert(key.clone(), record.clone()); + } + } +} diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index e6c4916b..8d90e924 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,59 +1,83 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::cleanup_blobs::{ - cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, -}; +use socket_patch_core::manifest::cleanup_blobs::format_cleanup_result; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::patch::redirect::{ + load_redirect_state, persist_redirect_state, RedirectState, REDIRECT_STATE_REL, +}; use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; -use socket_patch_core::utils::purl::{purl_matches_identifier, strip_purl_qualifiers}; -use socket_patch_core::vendor::{load_state, save_state, VendorEntry, VendorState}; -use std::path::Path; +use socket_patch_core::utils::purl::patch_matches; +use socket_patch_core::vendor::{ + load_state, RevertOpts, VendorEntry, VendorState, VENDOR_STATE_REL, +}; +use std::collections::HashSet; use std::time::Duration; use super::get::short_uuid; -use super::rollback::{all_files_already_original, pin_before_hash_blobs, rollback_patches}; -use super::vendor::{dispatch_revert_one, dispatch_revert_one_opts}; +use super::rollback::{ + all_files_already_original, pin_before_hash_blobs, revert_vendor_entry, rollback_patches_inner, + run_hosted_leg, sweep_failure, sweep_unused_artifacts, HostedLegOutcome, InnerSelection, + VendorRevertStep, +}; use crate::args::{apply_env_toggles, GlobalArgs}; -use socket_patch_core::vendor::RevertOpts; use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::output::confirm; -/// A remove/rollback identifier matches a patch by PURL for `pkg:` -/// identifiers (a base PURL matches every release variant of that -/// package@version; a qualified PURL targets a single patch), or by patch -/// uuid otherwise. -pub(crate) fn patch_matches(purl: &str, uuid: &str, identifier: &str) -> bool { - if identifier.starts_with("pkg:") { - purl_matches_identifier(purl, identifier) - } else { - uuid == identifier - } -} - -/// Vendor-ledger entries matching a remove identifier: by ledger key or -/// base purl (mirroring the manifest matching). Sorted by key for -/// deterministic event order. +/// Vendor-ledger entries matching a remove identifier (by ledger key, +/// base purl or uuid — `VendorEntry::matches_identifier`), sorted by key +/// for deterministic event order. fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { let mut matches: Vec<(String, VendorEntry)> = state .entries .iter() - .filter(|(key, entry)| { - patch_matches(key, &entry.uuid, identifier) - || patch_matches(&entry.base_purl, &entry.uuid, identifier) - }) + .filter(|(key, entry)| entry.matches_identifier(key, identifier)) .map(|(k, e)| (k.clone(), e.clone())) .collect(); matches.sort_by(|a, b| a.0.cmp(&b.0)); matches } +/// Hosted redirect records matching a remove identifier, sorted. +fn hosted_records_matching(state: &RedirectState, identifier: &str) -> Vec { + let mut matches: Vec = state + .records + .iter() + .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, identifier)) + .map(|(purl, _)| purl.clone()) + .collect(); + matches.sort(); + matches +} + +/// Drop every manifest entry matching `identifier` except `exclusions` +/// (drift-kept vendored purls, whose record must survive with their +/// vendored state). Returns the removed purls, sorted. +fn remove_matching( + manifest: &mut PatchManifest, + identifier: &str, + exclusions: &HashSet, +) -> Vec { + let mut removed: Vec = manifest + .patches + .iter() + .filter(|(purl, patch)| { + patch_matches(purl, &patch.uuid, identifier) && !exclusions.contains(*purl) + }) + .map(|(purl, _)| purl.clone()) + .collect(); + removed.sort(); + for purl in &removed { + manifest.patches.remove(purl); + } + removed +} + /// Emit the `not_found` envelope (or stderr line) for an identifier that -/// matched nothing, tracking the failure. Both the pre-flight match and -/// the post-rollback manifest mutation share this exit path. `dry_run` -/// rides the envelope so a preview's failures still report `dryRun: true` -/// (matching apply's error envelopes and remove's own success envelope). +/// matched nothing in any store, tracking the failure. `dry_run` rides the +/// envelope so a preview's failures still report `dryRun: true` (matching +/// apply's error envelopes and remove's own success envelope). async fn emit_not_found( json: bool, dry_run: bool, @@ -146,40 +170,28 @@ pub async fn run(args: RemoveArgs) -> i32 { get_api_client_with_overrides(args.common.api_client_overrides()).await; let api_token = telemetry_client.api_token().cloned(); let org_slug = telemetry_client.org_slug().cloned(); + let loud = !args.common.json && !args.common.silent; let manifest_path = args.common.resolved_manifest_path(); - + let cwd = &args.common.cwd; + + // ── state discovery ───────────────────────────────────────────────── + // A ledger-only project (vendored mode keeps its records in the vendor + // ledger, hosted mode in the redirect ledger — neither writes a + // manifest) proceeds manifest-less: `remove` is the per-purl exit path + // for those entries. Only cheap EXISTENCE probes run before the lock — + // they decide the truly-empty error path, which never locks (a bare + // project must not see `.socket/` created and pruned again). The + // stores themselves are loaded under the lock below. let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); if manifest_missing { - // A pure-detached project (`scan --vendor --detached`) has a - // vendor ledger but deliberately no manifest, and `remove` is the - // per-purl exit path for its entries — so a missing manifest is - // only fatal when the ledger has no detached match either. An - // unreadable ledger falls through to the error: nothing is - // mutated on that path. - let has_detached_match = load_state(&args.common.cwd) + let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) .await - .map(|s| { - vendor_entries_matching(&s, &args.identifier) - .iter() - .any(|(_, e)| e.detached) - }) - .unwrap_or(false); - // Hosted redirects likewise live outside the manifest (the - // redirect ledger is the only persistence), so a hosted-only - // project's `remove` proceeds manifest-less too. - let has_hosted_match = socket_patch_core::patch::redirect::load_redirect_state( - &args.common.cwd, - ) - .await - .ok() - .flatten() - .is_some_and(|st| { - st.records - .iter() - .any(|(purl, rec)| patch_matches(purl, &rec.uuid, &args.identifier)) - }); - if !has_detached_match && !has_hosted_match { + .is_ok(); + let redirect_ledger_exists = tokio::fs::metadata(cwd.join(REDIRECT_STATE_REL)) + .await + .is_ok(); + if !vendor_ledger_exists && !redirect_ledger_exists { emit_error_envelope( args.common.json, args.common.dry_run, @@ -191,13 +203,14 @@ pub async fn run(args: RemoveArgs) -> i32 { } // Serialize against concurrent socket-patch runs targeting the - // same `.socket/` directory. Note: `rollback_patches` (which - // `remove` calls into) does NOT acquire the lock — that would - // self-deadlock — so the outer remove invocation holds it for - // both the rollback and the manifest mutation. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); + // same `.socket/` directory. The nested in-place rollback does NOT + // acquire the lock (that would self-deadlock): this one guard covers + // the rollback, the ledger reverts and the manifest mutation, and its + // drop removes `apply.lock` (and an emptied `.socket/`) on every exit + // path. + let socket_dir = crate::args::socket_dir_of(&manifest_path, &args.common.cwd); let _lock = match acquire_or_emit( - socket_dir, + &socket_dir, Command::Remove, args.common.json, args.common.dry_run, @@ -207,9 +220,9 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(code) => return code, }; - // Read manifest to show what will be removed and confirm. On the - // pure-detached path there is no manifest to read or mutate; an empty - // view routes the flow to the detached-only removal below. + // Read the manifest to show what will be removed and confirm. On the + // ledger-only path there is no manifest to read or mutate; an empty + // view routes the flow to the ledger-only removals below. let manifest = if manifest_missing { PatchManifest::new() } else { @@ -248,26 +261,30 @@ pub async fn run(args: RemoveArgs) -> i32 { .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier)) .collect(); + // The vendor ledger, loaded ONCE under the lock: it scopes the nested + // rollback (vendor-owned purls are not restored in place) and drives + // the vendored leg. An unreadable ledger degrades to "nothing vendored" + // for the rollback and fails closed at the vendored leg — exactly where + // the run is about to mutate vendored state. + let vendor_state_result = load_state(cwd).await; + if matching.is_empty() { - // Detached vendored patches (`scan --vendor --detached`) have no - // manifest entry — `remove` is their per-purl exit path (alongside - // `vendor --revert`'s all-at-once). An unreadable ledger falls - // through to `not_found`: nothing is mutated on that path. - let detached_state = load_state(&args.common.cwd).await.unwrap_or_default(); - let detached: Vec<(String, VendorEntry)> = - vendor_entries_matching(&detached_state, &args.identifier) - .into_iter() - .filter(|(_, e)| e.detached) - .collect(); - if !detached.is_empty() { - return remove_detached_only( - &args, - detached, - detached_state, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; + // Ledger-only entries (vendored mode keeps no manifest record) — + // `remove` is their per-purl exit path (alongside `vendor + // --revert`'s all-at-once). An unreadable ledger falls through to + // `not_found`: nothing is mutated on that path. + if let Ok(state) = vendor_state_result { + let ledger_matches = vendor_entries_matching(&state, &args.identifier); + if !ledger_matches.is_empty() { + return remove_ledger_only( + &args, + ledger_matches, + state, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + } } // Hosted-only patches likewise have no manifest entry — the @@ -275,16 +292,8 @@ pub async fn run(args: RemoveArgs) -> i32 { // their per-purl exit path (the unwind IS the removal). An // unreadable ledger falls through to `not_found`: nothing is // mutated on that path. - if let Ok(Some(redirect_state)) = - socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await - { - let mut hosted_matches: Vec = redirect_state - .records - .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, &args.identifier)) - .map(|(purl, _)| purl.clone()) - .collect(); - hosted_matches.sort(); + if let Ok(Some(redirect_state)) = load_redirect_state(cwd).await { + let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); if !hosted_matches.is_empty() { return remove_hosted_only( &args, @@ -312,7 +321,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // to multiple manifest entries (PyPI release variants), make the // blast radius explicit so the user understands why a single // `remove pkg:pypi/foo@1.0` is removing several variants. - if !args.common.json && !args.common.silent { + if loud { if args.identifier.starts_with("pkg:") && !args.identifier.contains('?') && matching.len() > 1 @@ -348,13 +357,20 @@ pub async fn run(args: RemoveArgs) -> i32 { format!("Remove {} patch(es) and rollback files?", matching.len()) }; if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.json && !args.common.silent { + if loud { println!("Removal cancelled."); } return 0; } - // First, rollback the patch if not skipped + // ── nested in-place rollback ──────────────────────────────────────── + // Vendor-owned purls are excluded from the in-place restore (the + // vendored leg below reverts them); an unreadable ledger degrades to + // "nothing vendored" here and fails closed at that leg. + let vendored_keys: HashSet = vendor_state_result + .as_ref() + .map(socket_patch_core::vendor::VendorState::purl_keys) + .unwrap_or_default(); let mut rollback_count = 0; // In-scope manifest entries the nested rollback SKIPPED because the // crawler found no installed package (`RollbackOutcome::not_installed`, @@ -367,22 +383,30 @@ pub async fn run(args: RemoveArgs) -> i32 { // (no rollback ran, so nothing is known — semantics unchanged). let mut rollback_not_installed: Vec = Vec::new(); if !args.skip_rollback { - if !args.common.json && !args.common.silent { + if loud { println!("Rolling back patch before removal..."); } - match rollback_patches( - &args.common, - &manifest_path, - Some(&args.identifier), - args.common.dry_run, - args.common.json || args.common.silent, - None, + // The delegation runs muted under --json/--silent (the envelope, + // or the silence, is ours) and unscoped by --ecosystems (the + // identifier IS the scope). + let delegated = GlobalArgs { + silent: args.common.json || args.common.silent, + ecosystems: None, + ..args.common.clone() + }; + match rollback_patches_inner( + &delegated, + &socket_dir, + &manifest, + &vendored_keys, + InnerSelection::Identifier(Some(&args.identifier)), + Some(&telemetry_client), ) .await { - Ok((success, results, _vendored_skipped, not_installed)) => { - rollback_not_installed = not_installed; - if !success { + Ok(outcome) => { + rollback_not_installed = outcome.not_installed; + if !outcome.success { track_patch_remove_failed( "Rollback failed during patch removal", api_token.as_deref(), @@ -390,15 +414,16 @@ pub async fn run(args: RemoveArgs) -> i32 { ) .await; emit_error_envelope( - args.common.json, - args.common.dry_run, + args.common.json, + args.common.dry_run, "rollback_failed", "Rollback failed during patch removal. Use --skip-rollback to remove from manifest without restoring files.".to_string(), ); return 1; } - rollback_count = results + rollback_count = outcome + .results .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) .count(); @@ -408,19 +433,22 @@ pub async fn run(args: RemoveArgs) -> i32 { // `Iterator::all` over an empty slice is vacuously `true`, // so a zero-file (or not-installed) result would otherwise // be miscounted as "already in original state". - let already_original = results + let already_original = outcome + .results .iter() .filter(|r| r.success && all_files_already_original(r)) .count(); - if !args.common.json && !args.common.silent { + if loud { if rollback_count > 0 { println!("Rolled back {rollback_count} package(s)"); } if already_original > 0 { println!("{already_original} package(s) already in original state"); } - if results.is_empty() { + // Vendor-owned targets say nothing here: the vendored + // leg below reports each key's own disposition. + if !rollback_not_installed.is_empty() { println!("No packages found to rollback (not installed)"); } println!(); @@ -429,8 +457,8 @@ pub async fn run(args: RemoveArgs) -> i32 { Err(e) => { track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; emit_error_envelope( - args.common.json, - args.common.dry_run, + args.common.json, + args.common.dry_run, "rollback_failed", format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."), ); @@ -439,6 +467,7 @@ pub async fn run(args: RemoveArgs) -> i32 { } } + // ── vendored leg ──────────────────────────────────────────────────── // Vendor-owned purls: removing the patch means reverting the vendoring // (restore the recorded lockfile fragments, delete the artifact, drop // the ledger entry) — otherwise the lockfile keeps consuming the @@ -452,7 +481,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // would leave wired. `--skip-rollback` ("don't touch my tree") skips // the revert too — the wiring stays until the next `vendor` run // reconciles the then-dropped entry. - let mut vendor_state = match load_state(&args.common.cwd).await { + let mut vendor_state = match vendor_state_result { Ok(s) => s, Err(e) => { emit_error_envelope( @@ -465,28 +494,17 @@ pub async fn run(args: RemoveArgs) -> i32 { } }; let vendored_matches = vendor_entries_matching(&vendor_state, &args.identifier); - // Reverted entries ride the final envelope as Removed/vendor_reverted - // events WITHOUT bumping summary.removed (that count stays "manifest - // entries deleted", same as the blob-sweep carrier). Retained/warning - // events are Skipped and bump normally. - let mut vendor_reverted_events: Vec = Vec::new(); - let mut vendor_skipped_events: Vec = Vec::new(); - // Ledger keys whose revert drift-kept: their manifest entries are - // EXCLUDED from the removal below (dropping a record whose vendored - // state survives would hand `vendor`'s reconcile a revert with no - // backing record). - let mut vendor_kept_purls: std::collections::HashSet = - std::collections::HashSet::new(); + let mut vendor_leg = RemoveVendorLeg::default(); if !vendored_matches.is_empty() { if args.skip_rollback { for (key, _) in &vendored_matches { - if !args.common.json && !args.common.silent { + if loud { eprintln!( "Note: {key} is vendored; --skip-rollback leaves the vendor wiring and \ artifact in place (the next `vendor` run will reconcile-revert it)." ); } - vendor_skipped_events.push( + vendor_leg.skipped.push( PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( "vendor_state_retained", "vendor wiring and artifact left in place (--skip-rollback)", @@ -494,134 +512,38 @@ pub async fn run(args: RemoveArgs) -> i32 { ); } } else { - for (key, entry) in &vendored_matches { - let outcome = dispatch_revert_one_opts( - entry, - &args.common.cwd, - RevertOpts { - dry_run: args.common.dry_run, - keep_artifact: args.preserve_state, - }, - ) - .await; - for w in &outcome.warnings { - if !args.common.json && !args.common.silent { - eprintln!("Warning ({}): {}", w.code, w.detail); - } - vendor_skipped_events.push( - PatchEvent::new(PatchAction::Skipped, key.clone()) - .with_reason(w.code, w.detail.clone()), - ); - } - if !outcome.success { - track_patch_remove_failed( - "vendor revert failed during patch removal", - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_revert_failed", - format!( - "could not revert vendoring for {key}: {}. The manifest was not \ - modified.", - outcome.error.as_deref().unwrap_or("unknown error") - ), - ); - return 1; - } - if outcome.kept_artifact { - // Drift-keep: the lock changed under us and the backend - // left both the wiring and the artifact alone. Per the - // RevertOutcome contract the ledger entry stays — and so - // must the manifest entry, or `vendor`'s reconcile would - // re-revert an entry whose backing record is gone. - if !args.common.json && !args.common.silent { - eprintln!( - "Kept vendored state for {key}: lockfile wiring drifted; \ - its manifest entry was kept too" - ); - } - vendor_kept_purls.insert(key.clone()); - vendor_skipped_events.push( - PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( - "vendor_revert_kept", - "lockfile wiring drifted; vendored state and manifest entry kept", - ), - ); - continue; - } - if args.common.dry_run { - if !args.common.json && !args.common.silent { - if args.preserve_state { - println!("Would unwire vendoring for {key} (artifact preserved)"); - } else { - println!("Would revert vendoring for {key}"); - } - } - // Dry-run flips the would-be Removed to a Verified - // preview, same convention as apply/vendor/repair. - vendor_reverted_events.push( - PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( - "vendor_would_revert", - "vendoring would be reverted on remove", - ), - ); - continue; - } - if args.preserve_state { - // Entry kept byte-identical: its already-reverted wiring - // records replay as silent no-ops later (the liveness - // contract) and a re-vendor re-wires from the live lock. - if !args.common.json && !args.common.silent { - println!("Unwired vendoring for {key} (artifact preserved)"); - } - vendor_skipped_events.push( - PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( - "vendor_state_preserved", - "lockfile unwired; artifact and ledger entry preserved \ - (--preserve-state)", - ), - ); - continue; - } - vendor_state.entries.remove(key); - if let Err(e) = save_state(&args.common.cwd, &vendor_state).await { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_state_write_failed", - e.to_string(), - ); - return 1; - } - if !args.common.json && !args.common.silent { - println!("Reverted vendoring for {key}"); - } - vendor_reverted_events.push( - PatchEvent::new(PatchAction::Removed, key.clone()) - .with_reason("vendor_reverted", "vendoring reverted on remove"), - ); - } + let keys: Vec = vendored_matches.iter().map(|(k, _)| k.clone()).collect(); + vendor_leg = match revert_vendored_matches( + &args, + &keys, + &mut vendor_state, + api_token.as_deref(), + org_slug.as_deref(), + true, + ) + .await + { + Ok(leg) => leg, + Err(code) => return code, + }; } } - // Hosted-redirect leg: an identifier can also (or only) match hosted - // records in the redirect ledger. Supported ecosystems (cargo, - // npm-family) unwind per-purl; when the identifier covers EVERY record - // the whole-ledger replay serves the rest; otherwise unsupported - // targets fail closed BEFORE the manifest mutation. A corrupt ledger - // skips the leg with a warning (the identifier may still match other - // stores). `--skip-rollback` leaves hosted wiring untouched, like the - // vendor wiring above; `--preserve-state` still unwinds — hosted has - // no preservable local state. + // ── hosted leg ────────────────────────────────────────────────────── + // An identifier can also (or only) match hosted records in the + // redirect ledger. Supported ecosystems (cargo, npm-family) unwind + // per-purl; when the identifier covers EVERY record the whole-ledger + // replay serves the rest; otherwise unsupported targets fail closed + // BEFORE the manifest mutation. A corrupt ledger skips the leg with a + // warning (the identifier may still match other stores). + // `--skip-rollback` leaves hosted wiring untouched, like the vendor + // wiring above; `--preserve-state` still unwinds — hosted has no + // preservable local state. let mut hosted_reverted_events: Vec = Vec::new(); if !args.skip_rollback { - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { + match load_redirect_state(cwd).await { Err(e) => { - if !args.common.silent && !args.common.json { + if loud { eprintln!( "Warning: cannot read the hosted redirect ledger ({e}); hosted \ redirects were not examined" @@ -630,83 +552,25 @@ pub async fn run(args: RemoveArgs) -> i32 { } Ok(None) => {} Ok(Some(mut redirect_state)) => { - let mut hosted_matches: Vec = redirect_state - .records - .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, &args.identifier)) - .map(|(purl, _)| purl.clone()) - .collect(); - hosted_matches.sort(); + let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); if !hosted_matches.is_empty() { - let replay_eligible = redirect_state - .records - .keys() - .all(|p| hosted_matches.contains(p)); - let before = - (redirect_state.edits.len(), redirect_state.records.len()); - let leg = super::rollback::run_hosted_leg( - &args.common, - &hosted_matches, - &mut redirect_state, - replay_eligible, - ) - .await; - // Persist FIRST, failure or not: per-purl reverts flush - // lockfile writes as they go, so an early error return - // without persisting would strand already-reverted - // purls' records in the on-disk ledger (lockfiles and - // ledger desynced; `list`/VEX attest dead wiring). - if !args.common.dry_run - && (redirect_state.edits.len(), redirect_state.records.len()) != before - { - if let Err(e) = - socket_patch_core::patch::redirect::persist_redirect_state( - &args.common.cwd, - &redirect_state, - ) + let leg = + match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state) .await { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), - ); - return 1; - } - } - if !leg.unsupported.is_empty() { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_unsupported", - format!( - "no per-purl hosted-redirect revert exists for: {}. Run an \ - unscoped `socket-patch rollback` to unwind ALL hosted \ - redirects, or re-run `scan --mode hosted` to normalize. \ - The manifest was not modified.", - leg.unsupported.join(", ") - ), - ); - return 1; - } - if let Some((what, why)) = leg.failed.first() { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!( - "could not unwind hosted redirect for {what}: {why}. The \ - manifest was not modified." - ), - ); - return 1; - } - if args.preserve_state - && !leg.reverted.is_empty() - && !args.common.silent - && !args.common.json - { + Ok(leg) => leg, + Err(err) => { + let (code, msg) = hosted_unwind_error(err, true); + emit_error_envelope( + args.common.json, + args.common.dry_run, + code, + msg, + ); + return 1; + } + }; + if args.preserve_state && !leg.reverted.is_empty() && loud { eprintln!( "Note: hosted redirects have no preservable local state; \ their ledger records were dropped with the unwound wiring." @@ -730,327 +594,517 @@ pub async fn run(args: RemoveArgs) -> i32 { } } - // Manifest entries excluded from the removal: drift-kept vendored - // purls (kept ledger key / base-purl / qualifier-stripped matching). - let excluded_kept: std::collections::HashSet = matching + // ── manifest mutation ─────────────────────────────────────────────── + // Drift-kept vendored purls are EXCLUDED from the removal (dropping a + // record whose vendored state survives would hand `vendor`'s reconcile + // a revert with no backing record); the matching mirrors the + // ledger-key / base-purl / qualifier-stripped triple. + let excluded_kept: HashSet = matching .iter() .map(|(purl, _)| (*purl).clone()) .filter(|purl| { - vendor_kept_purls.iter().any(|key| { - key == purl - || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) - || vendored_matches - .iter() - .find(|(k, _)| k == key) - .is_some_and(|(_, e)| e.base_purl == strip_purl_qualifiers(purl)) + vendor_leg.kept.iter().any(|key| { + vendored_matches + .iter() + .find(|(k, _)| k == key) + .is_some_and(|(k, e)| e.covers_purl(k, purl)) }) }) .collect(); - // Now remove from manifest. On --dry-run the removal is simulated in - // memory (manifest untouched) so the blob sweep below can still - // preview against the post-removal reference set. `--preserve-state` - // deliberately touches neither the manifest nor the blobs. - let removal = if args.preserve_state { - Ok((Vec::new(), manifest.clone())) - } else if args.common.dry_run { - let removed: Vec = matching - .iter() - .map(|(purl, _)| (*purl).clone()) - .filter(|p| !excluded_kept.contains(p)) - .collect(); - let mut simulated = manifest.clone(); - simulated.patches.retain(|purl, _| !removed.contains(purl)); - Ok((removed, simulated)) + // The removal is computed ONCE, from the manifest read under the lock + // (nothing rewrites it in between); on --dry-run it stays in memory so + // the blob sweep below can still preview against the post-removal + // reference set. `--preserve-state` deliberately touches neither the + // manifest nor the blobs. An emptied manifest stays on disk as + // `{"patches": {}}` — it carries the setup block and the + // empty-vs-missing exit codes of `list`/`apply`/`repair`. + let mut updated_manifest = manifest.clone(); + let removed = if args.preserve_state { + Vec::new() } else { - remove_patch_from_manifest(&args.identifier, &manifest_path, &excluded_kept).await + remove_matching(&mut updated_manifest, &args.identifier, &excluded_kept) }; - match removal { - Ok((removed, updated_manifest)) => { - if removed.is_empty() && !args.preserve_state { - if !excluded_kept.is_empty() { - // Every matching entry was drift-kept: the remove did - // not happen. NOT not_found — the identifier matched; - // partialFailure keeps `summary.removed` honest at 0. - let msg = format!( - "{}: every matching entry's vendored state drift-kept; nothing was \ - removed (re-run `scan --mode vendored` to normalize, then remove)", - args.identifier - ); - track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()) - .await; - if args.common.json { - let mut env = Envelope::new(Command::Remove); - env.dry_run = args.common.dry_run; - for ev in vendor_skipped_events { - env.record(ev); - } - env.status = Status::PartialFailure; - env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); - println!("{}", env.to_pretty_json()); - } else { - eprintln!("Error: {msg}"); - } - return 1; - } - emit_not_found( - args.common.json, - args.common.dry_run, - &args.identifier, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - return 1; + if removed.is_empty() && !args.preserve_state { + // Every matching entry was drift-kept (the identifier matched, so + // this is the only way the removal can be empty): the remove did + // not happen. NOT not_found; partialFailure keeps `summary.removed` + // honest at 0. + let msg = format!( + "{}: every matching entry's vendored state drift-kept; nothing was \ + removed (re-run `scan --mode vendored` to normalize, then remove)", + args.identifier + ); + track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + if args.common.json { + let mut env = Envelope::new(Command::Remove); + env.dry_run = args.common.dry_run; + for ev in vendor_leg.skipped { + env.record(ev); } + env.status = Status::PartialFailure; + env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error: {msg}"); + } + return 1; + } + if !args.common.dry_run && !removed.is_empty() { + if let Err(e) = write_manifest(&manifest_path, &updated_manifest).await { + let msg = e.to_string(); + track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + emit_error_envelope(args.common.json, args.common.dry_run, "remove_failed", msg); + return 1; + } + } - if !args.common.json && !args.common.silent { - if args.preserve_state { - println!( - "Manifest entries and vendored artifacts preserved \ - (--preserve-state); re-apply with `socket-patch apply` or \ - `socket-patch vendor`." - ); - } else if args.common.dry_run { - println!("Would remove {} patch(es) from manifest:", removed.len()); - } else { - println!("Removed {} patch(es) from manifest:", removed.len()); - } - for purl in &removed { - println!(" - {purl}"); - } - if args.common.dry_run { - println!("\nDry run — nothing was changed."); - } else if !args.preserve_state { - println!("\nManifest updated at {}", manifest_path.display()); - } - } + if loud { + if args.preserve_state { + println!( + "Manifest entries and vendored artifacts preserved \ + (--preserve-state); re-apply with `socket-patch apply` or \ + `socket-patch vendor`." + ); + } else if args.common.dry_run { + println!("Would remove {} patch(es) from manifest:", removed.len()); + } else { + println!("Removed {} patch(es) from manifest:", removed.len()); + } + for purl in &removed { + println!(" - {purl}"); + } + if args.common.dry_run { + println!("\nDry run — nothing was changed."); + } else if !args.preserve_state { + println!("\nManifest updated at {}", manifest_path.display()); + } + } - // FAIL-CLOSED (crawler-miss guard): dropped entries whose nested - // rollback was skipped as not-installed were never actually - // reverted, and the miss may be a crawler layout gap with the - // patched bytes still on disk. Sweeping their beforeHash blobs - // would permanently destroy the only local revert data, so they - // are pinned into the sweep's keep set; a warning event + stderr - // line surface each one. Entries genuinely rolled back (or - // already original) appear in `results`, never here. - let retained_not_installed: Vec<&str> = rollback_not_installed - .iter() - .map(String::as_str) - .filter(|p| removed.iter().any(|r| r == p)) - .collect(); - if !args.common.json && !args.common.silent && !retained_not_installed.is_empty() { - eprintln!( - "\nWarning: {} removed patch(es) had no matching installed package, so \ - their rollback was skipped (a crawler miss would look the same); their \ - revert data (beforeHash blobs) was kept in .socket/blobs:", - retained_not_installed.len() - ); - for purl in &retained_not_installed { - eprintln!(" - {purl}"); + // FAIL-CLOSED (crawler-miss guard): dropped entries whose nested + // rollback was skipped as not-installed were never actually reverted, + // and the miss may be a crawler layout gap with the patched bytes + // still on disk. Sweeping their beforeHash blobs would permanently + // destroy the only local revert data, so they are pinned into the + // sweep's keep set; a warning event + stderr line surface each one. + // Entries genuinely rolled back (or already original) appear in the + // rollback's results, never here. + let retained_not_installed: Vec<&str> = rollback_not_installed + .iter() + .map(String::as_str) + .filter(|p| removed.iter().any(|r| r == p)) + .collect(); + if loud && !retained_not_installed.is_empty() { + eprintln!( + "\nWarning: {} removed patch(es) had no matching installed package, so \ + their rollback was skipped (a crawler miss would look the same); their \ + revert data (beforeHash blobs) was kept in .socket/blobs:", + retained_not_installed.len() + ); + for purl in &retained_not_installed { + eprintln!(" - {purl}"); + } + } + + // ── GC ────────────────────────────────────────────────────────────── + // Clean up unused blobs (previewed, not deleted, on --dry-run). The + // reference manifest is the post-removal manifest PLUS one synthetic + // keep record per retained entry above: `cleanup_unused_blobs` keeps + // only afterHash blobs (beforeHash blobs are normally re-downloadable + // on demand), so each pinned before-hash is listed in an afterHash + // slot. Scoped to REVERT data only — the retained entries' real + // afterHash blobs stay sweepable like any other orphan. + let mut cleanup_reference = updated_manifest; + let pinned_purls: Vec = retained_not_installed + .iter() + .map(|p| (*p).to_string()) + .collect(); + pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls.iter()); + let mut blobs_removed = 0; + let mut archives_removed = 0; + if !args.preserve_state { + let sweep = + sweep_unused_artifacts(&cleanup_reference, &socket_dir, args.common.dry_run).await; + // repair's posture: a failed pass (or a pass that could not unlink + // every orphan) warns and continues, never fatal; its partial + // counts still stand. + if let Some(detail) = sweep_failure("blob", &sweep.blobs) { + if loud { + eprintln!("Warning: {detail}"); + } + } + if let Ok(r) = sweep.blobs { + blobs_removed = r.blobs_removed; + if loud && r.blobs_removed > 0 { + println!("\n{}", format_cleanup_result(&r, args.common.dry_run)); + } + } + // Diff/package archives use the same manifest-uuid keep rule + // (parity with repair and scan --prune). + for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] { + if let Some(detail) = sweep_failure(dir, &result) { + if loud { + eprintln!("Warning: {detail}"); } } + if let Ok(r) = result { + archives_removed += r.blobs_removed; + } + } + } - // Clean up unused blobs (previewed, not deleted, on --dry-run). - // The reference manifest is the post-removal manifest PLUS one - // synthetic keep record per retained entry above: - // `cleanup_unused_blobs` keeps only afterHash blobs (beforeHash - // blobs are normally re-downloadable on demand), so each pinned - // before-hash is listed in an afterHash slot. Scoped to REVERT - // data only — the retained entries' real afterHash blobs stay - // sweepable like any other orphan. - let mut cleanup_reference = updated_manifest.clone(); - let pinned_purls: Vec = retained_not_installed - .iter() - .map(|p| (*p).to_string()) - .collect(); - pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls.iter()); - let blobs_path = socket_dir.join("blobs"); - let mut blobs_removed = 0; - let mut archives_removed = 0; - if !args.preserve_state { - match cleanup_unused_blobs(&cleanup_reference, &blobs_path, args.common.dry_run) - .await - { - Ok(cleanup_result) => { - blobs_removed = cleanup_result.blobs_removed; - if !args.common.json - && !args.common.silent - && cleanup_result.blobs_removed > 0 - { - println!( - "\n{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - ); - } - } - Err(e) => { - // repair's posture: warn and continue, never fatal. - if !args.common.silent && !args.common.json { - eprintln!("Warning: blob cleanup failed: {e}"); - } - } - } - // Diff/package archives use the same manifest-uuid keep rule - // (parity with repair and scan --prune). - for dir in ["diffs", "packages"] { - match cleanup_unused_archives( - &cleanup_reference, - &socket_dir.join(dir), - args.common.dry_run, + if args.common.json { + let mut env = Envelope::new(Command::Remove); + env.dry_run = args.common.dry_run; + // Dry-run flips would-be Removed events to Verified previews (the + // apply/vendor/repair convention), so `summary.removed` stays + // "manifest entries actually deleted" — zero on a preview. + let removal_action = if args.common.dry_run { + PatchAction::Verified + } else { + PatchAction::Removed + }; + // The crawler-miss warnings first (the rollback skip is the + // earliest outcome chronologically). Recorded — they bump + // `summary.skipped` like the vendor retained/warning events — and + // additive: runs with every target genuinely rolled back (or + // already original) emit none, leaving existing consumers + // byte-identical output. + for purl in &retained_not_installed { + let mut kept: Vec = manifest + .patches + .get(*purl) + .map(|record| { + record + .files + .values() + .filter(|info| !info.before_hash.is_empty()) + .map(|info| info.before_hash.clone()) + .collect() + }) + .unwrap_or_default(); + kept.sort(); + kept.dedup(); + env.record( + PatchEvent::new(PatchAction::Skipped, (*purl).to_string()) + .with_reason( + "rollback_not_installed", + "rollback skipped: no installed package found (a crawler \ + miss would look the same); beforeHash blobs kept in \ + .socket/blobs so a later rollback/repair can still restore", ) - .await - { - Ok(r) => archives_removed += r.blobs_removed, - Err(e) => { - if !args.common.silent && !args.common.json { - eprintln!("Warning: {dir} cleanup failed: {e}"); - } - } - } + .with_details(serde_json::json!({ "beforeBlobsRetained": kept })), + ); + } + // Chronological: the vendor revert ran before the manifest + // mutation. Reverted events bypass `record` so `summary.removed` + // stays equal to the number of manifest entries deleted (same rule + // as the blob-sweep carrier below); retained/warning Skipped + // events bump `summary.skipped` normally. + for ev in vendor_leg.reverted { + env.events.push(ev); + } + // Hosted unwinds likewise bypass `record` — summary.removed stays + // "manifest entries deleted". + for ev in hosted_reverted_events { + env.events.push(ev); + } + for ev in vendor_leg.skipped { + env.record(ev); + } + // One Removed event per purl whose manifest entry was deleted + // (Verified on --dry-run). + for purl in &removed { + env.record(PatchEvent::new(removal_action, purl.clone())); + } + // One artifact-level Removed event carrying the blob-sweep and + // rollback counts. Emitted whenever either is non-zero so the + // `rolledBack` count is still reported even when no blobs happened + // to be swept (e.g. the removed patch's afterHash blobs are still + // referenced elsewhere). + // + // Pushed directly rather than via `env.record`: this is a + // purl-less metadata carrier, not a removed manifest entry. The + // per-purl events above are the authoritative patch-removal + // count, so `summary.removed` must equal the number of entries + // deleted (`removed.len()`) — letting this carrier bump `removed` + // too would double-count, reporting e.g. `removed: 2` for a + // single-patch removal that happened to sweep an orphan blob. + // Consumers read the blob/rollback totals from `details`, never + // from `summary.removed`. + if blobs_removed > 0 || rollback_count > 0 || archives_removed > 0 { + env.events.push( + PatchEvent::artifact(removal_action).with_details(serde_json::json!({ + "blobsRemoved": blobs_removed, + "rolledBack": rollback_count, + "archivesRemoved": archives_removed, + })), + ); + } + // Any drift-kept entry means part of the requested removal did + // NOT happen: the run is a partialFailure (exit 1) even when + // sibling entries were removed. + if !vendor_leg.kept.is_empty() { + env.status = Status::PartialFailure; + } + println!("{}", env.to_pretty_json()); + } + + if !args.common.dry_run { + track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + } + if vendor_leg.kept.is_empty() { + 0 + } else { + // Errors print even under --silent; the per-key drift-keep lines + // above are gated, so name the outcome once here. + if !args.common.json { + eprintln!( + "Error: {} matching entr{} drift-kept (vendored state and manifest \ + record retained); re-run `scan --mode vendored` to normalize, then \ + remove again", + vendor_leg.kept.len(), + if vendor_leg.kept.len() == 1 { + "y was" + } else { + "ies were" } - } + ); + } + 1 + } +} - if args.common.json { - let mut env = Envelope::new(Command::Remove); - env.dry_run = args.common.dry_run; - // Dry-run flips would-be Removed events to Verified - // previews (the apply/vendor/repair convention), so - // `summary.removed` stays "manifest entries actually - // deleted" — zero on a preview. - let removal_action = if args.common.dry_run { - PatchAction::Verified +/// The vendored leg's envelope material, collected by +/// [`revert_vendored_matches`]. +#[derive(Default)] +struct RemoveVendorLeg { + /// `Removed`/`vendor_reverted` events (`Verified`/`vendor_would_revert` + /// on --dry-run), one per reverted key. + reverted: Vec, + /// Backend warnings, drift-keeps and preserved entries — `Skipped` + /// events. + skipped: Vec, + /// Ledger keys whose revert drift-kept: entry, artifact and any + /// manifest record stay. + kept: Vec, + /// Entries actually reverted and dropped from the ledger (wet runs). + reverted_count: usize, +} + +/// The vendored-revert loop shared by the manifest-backed and ledger-only +/// remove paths: revert each key (see `revert_vendor_entry` for the +/// drift-keep / `--preserve-state` / dry-run classification), print the +/// human lines, collect the envelope events. The first hard failure — a +/// backend refusal or a ledger write failure — emits its error envelope +/// and returns `Err(1)`; `manifest_backed` callers' messages add that the +/// manifest was not touched. +async fn revert_vendored_matches( + args: &RemoveArgs, + keys: &[String], + state: &mut VendorState, + api_token: Option<&str>, + org_slug: Option<&str>, + manifest_backed: bool, +) -> Result { + let loud = !args.common.json && !args.common.silent; + let opts = RevertOpts { + dry_run: args.common.dry_run, + keep_artifact: args.preserve_state, + }; + let mut leg = RemoveVendorLeg::default(); + for key in keys { + let result = revert_vendor_entry(&args.common.cwd, key, state, opts).await; + for w in &result.warnings { + if loud { + eprintln!("Warning ({}): {}", w.code, w.detail); + } + leg.skipped.push( + PatchEvent::new(PatchAction::Skipped, key.clone()) + .with_reason(w.code, w.detail.clone()), + ); + } + match result.step { + VendorRevertStep::Missing => {} + VendorRevertStep::Failed(why) => { + track_patch_remove_failed( + "vendor revert failed during patch removal", + api_token, + org_slug, + ) + .await; + emit_error_envelope( + args.common.json, + args.common.dry_run, + "vendor_revert_failed", + format!( + "could not revert vendoring for {key}: {why}{}", + if manifest_backed { + ". The manifest was not modified." + } else { + "" + } + ), + ); + return Err(1); + } + VendorRevertStep::Kept => { + // Drift-keep: the lock changed under us and the backend + // left both the wiring and the artifact alone. Per the + // RevertOutcome contract the ledger entry stays — and so + // must any manifest entry, or `vendor`'s reconcile would + // re-revert an entry whose backing record is gone. + let (note, detail) = if manifest_backed { + ( + "; its manifest entry was kept too", + "lockfile wiring drifted; vendored state and manifest entry kept", + ) } else { - PatchAction::Removed + ( + "", + "lockfile wiring drifted; vendored state and ledger entry kept", + ) }; - // The crawler-miss warnings first (the rollback skip is the - // earliest outcome chronologically). Recorded — they bump - // `summary.skipped` like the vendor retained/warning events - // — and additive: runs with every target genuinely rolled - // back (or already original) emit none, leaving existing - // consumers byte-identical output. - for purl in &retained_not_installed { - let mut kept: Vec = manifest - .patches - .get(*purl) - .map(|record| { - record - .files - .values() - .filter(|info| !info.before_hash.is_empty()) - .map(|info| info.before_hash.clone()) - .collect() - }) - .unwrap_or_default(); - kept.sort(); - kept.dedup(); - env.record( - PatchEvent::new(PatchAction::Skipped, (*purl).to_string()) - .with_reason( - "rollback_not_installed", - "rollback skipped: no installed package found (a crawler \ - miss would look the same); beforeHash blobs kept in \ - .socket/blobs so a later rollback/repair can still restore", - ) - .with_details(serde_json::json!({ "beforeBlobsRetained": kept })), - ); - } - // Chronological: the vendor revert ran before the manifest - // mutation. Reverted events bypass - // `record` so `summary.removed` stays equal to the number - // of manifest entries deleted (same rule as the blob-sweep - // carrier below); retained/warning Skipped events bump - // `summary.skipped` normally. - for ev in vendor_reverted_events { - env.events.push(ev); + if loud { + eprintln!("Kept vendored state for {key}: lockfile wiring drifted{note}"); } - // Hosted unwinds likewise bypass `record` — summary.removed - // stays "manifest entries deleted". - for ev in hosted_reverted_events { - env.events.push(ev); - } - for ev in vendor_skipped_events { - env.record(ev); - } - // One Removed event per purl whose manifest entry was - // deleted (Verified on --dry-run). - for purl in &removed { - env.record(PatchEvent::new(removal_action, purl.clone())); - } - // One artifact-level Removed event carrying the - // blob-sweep and rollback counts. Emitted whenever either - // is non-zero so the `rolledBack` count is still reported - // even when no blobs happened to be swept (e.g. the removed - // patch's afterHash blobs are still referenced elsewhere). - // - // Pushed directly rather than via `env.record`: this is a - // purl-less metadata carrier, not a removed manifest entry. - // The per-purl events above are the authoritative - // patch-removal count, so `summary.removed` must equal the - // number of entries deleted (`removed.len()`) — letting this - // carrier bump `removed` too would double-count, reporting - // e.g. `removed: 2` for a single-patch removal that happened - // to sweep an orphan blob. Consumers read the blob/rollback - // totals from `details`, never from `summary.removed`. - if blobs_removed > 0 || rollback_count > 0 || archives_removed > 0 { - env.events - .push(PatchEvent::artifact(removal_action).with_details( - serde_json::json!({ - "blobsRemoved": blobs_removed, - "rolledBack": rollback_count, - "archivesRemoved": archives_removed, - }), - )); - } - // Any drift-kept entry means part of the requested removal - // did NOT happen: the run is a partialFailure (exit 1) even - // when sibling entries were removed. - if !vendor_kept_purls.is_empty() { - env.status = Status::PartialFailure; + leg.kept.push(key.clone()); + leg.skipped.push( + PatchEvent::new(PatchAction::Skipped, key.clone()) + .with_reason("vendor_revert_kept", detail), + ); + } + VendorRevertStep::WouldRevert => { + if loud { + if args.preserve_state { + println!("Would unwire vendoring for {key} (artifact preserved)"); + } else { + println!("Would revert vendoring for {key}"); + } } - println!("{}", env.to_pretty_json()); + // Dry-run flips the would-be Removed to a Verified preview, + // same convention as apply/vendor/repair. + leg.reverted.push( + PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( + "vendor_would_revert", + "vendoring would be reverted on remove", + ), + ); } - - if !args.common.dry_run { - track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; + VendorRevertStep::Preserved => { + if loud { + println!("Unwired vendoring for {key} (artifact preserved)"); + } + leg.skipped.push( + PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( + "vendor_state_preserved", + "lockfile unwired; artifact and ledger entry preserved \ + (--preserve-state)", + ), + ); } - if vendor_kept_purls.is_empty() { - 0 - } else { - // Errors print even under --silent; the per-key drift-keep - // lines above are gated, so name the outcome once here. - if !args.common.json { - eprintln!( - "Error: {} matching entr{} drift-kept (vendored state and manifest \ - record retained); re-run `scan --mode vendored` to normalize, then \ - remove again", - vendor_kept_purls.len(), - if vendor_kept_purls.len() == 1 { "y was" } else { "ies were" } - ); + VendorRevertStep::Reverted => { + if loud { + println!("Reverted vendoring for {key}"); } - 1 + leg.reverted_count += 1; + leg.reverted.push( + PatchEvent::new(PatchAction::Removed, key.clone()) + .with_reason("vendor_reverted", "vendoring reverted on remove"), + ); + } + VendorRevertStep::LedgerWriteFailed(e) => { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "vendor_state_write_failed", + e, + ); + return Err(1); } } - Err(e) => { - track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; - emit_error_envelope(args.common.json, args.common.dry_run, "remove_failed", e); - 1 + } + Ok(leg) +} + +/// Why a hosted unwind stopped. Each caller renders its own message (the +/// manifest-backed path adds that the manifest was not touched). +enum HostedUnwindError { + /// The ledger could not be persisted after the reverts flushed. + Persist(String), + /// Scoped targets whose ecosystem has no per-purl hosted revert. + Unsupported(Vec), + /// A per-purl revert (or the whole-ledger replay) refused. + Failed { what: String, why: String }, +} + +/// Unwind the hosted redirect records in `hosted_matches` and persist the +/// ledger — FIRST, failure or not: the per-purl reverts flush lockfile +/// writes as they go, so an early error return without persisting would +/// strand already-reverted purls' records in the on-disk ledger (lockfiles +/// and ledger desynced; `list`/VEX attest dead wiring). When the matches +/// cover EVERY record the whole-ledger replay serves the ecosystems without +/// a per-purl revert. Shared by the manifest-backed and hosted-only remove +/// paths. +async fn unwind_hosted( + common: &GlobalArgs, + hosted_matches: &[String], + state: &mut RedirectState, +) -> Result { + let replay_eligible = state.records.keys().all(|p| hosted_matches.contains(p)); + let before = (state.edits.len(), state.records.len()); + let leg = run_hosted_leg(common, hosted_matches, state, replay_eligible).await; + if !common.dry_run && (state.edits.len(), state.records.len()) != before { + if let Err(e) = persist_redirect_state(&common.cwd, state).await { + return Err(HostedUnwindError::Persist(e.to_string())); } } + if !leg.unsupported.is_empty() { + return Err(HostedUnwindError::Unsupported(leg.unsupported)); + } + if let Some((what, why)) = leg.failed.first().cloned() { + return Err(HostedUnwindError::Failed { what, why }); + } + Ok(leg) +} + +/// Error code + message for a stopped hosted unwind. +fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'static str, String) { + let note = if manifest_backed { + " The manifest was not modified." + } else { + "" + }; + match err { + HostedUnwindError::Persist(e) => ( + "hosted_revert_failed", + format!("failed to persist the hosted redirect ledger: {e}"), + ), + HostedUnwindError::Unsupported(purls) => ( + "hosted_revert_unsupported", + format!( + "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ + `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ + `scan --mode hosted` to normalize.{note}", + purls.join(", ") + ), + ), + HostedUnwindError::Failed { what, why } => ( + "hosted_revert_failed", + if manifest_backed { + format!("could not unwind hosted redirect for {what}: {why}.{note}") + } else { + format!("could not unwind hosted redirect for {what}: {why}") + }, + ), + } } -/// Remove path for identifiers that match ONLY detached vendored entries -/// (no manifest record): confirm, revert each entry's wiring + artifact, -/// drop it from the ledger, and report `Removed`/`vendor_reverted` events. -/// Unlike the manifest path, the reverts here ARE the removal, so they go -/// through `env.record` and bump `summary.removed`. `--skip-rollback` is -/// refused: with no manifest entry to delete, removing a detached patch -/// can only mean reverting its vendoring. /// Remove path for identifiers that match ONLY hosted redirect records -/// (no manifest entry, no detached vendor entry): confirm, unwind each +/// (no manifest entry, no vendor-ledger entry): confirm, unwind each /// record's lockfile wiring, drop it from the redirect ledger, and report -/// `Removed`/`hosted_reverted` events. Like the detached path, the unwind -/// IS the removal, so events go through `env.record` and bump +/// `Removed`/`hosted_reverted` events. Like the ledger-only vendored path, +/// the unwind IS the removal, so events go through `env.record` and bump /// `summary.removed`. `--skip-rollback` is refused (with no manifest /// entry to delete, removing a hosted patch can only mean unwinding its /// redirect); `--preserve-state` still unwinds — hosted has no @@ -1058,10 +1112,11 @@ pub async fn run(args: RemoveArgs) -> i32 { async fn remove_hosted_only( args: &RemoveArgs, hosted_matches: Vec, - mut redirect_state: socket_patch_core::patch::redirect::RedirectState, + mut redirect_state: RedirectState, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { + let loud = !args.common.json && !args.common.silent; if args.skip_rollback { emit_error_envelope( args.common.json, @@ -1076,7 +1131,7 @@ async fn remove_hosted_only( return 1; } - if !args.common.json && !args.common.silent { + if loud { eprintln!("The following hosted redirect(s) will be unwound and removed:"); for purl in &hosted_matches { eprintln!(" - {purl}"); @@ -1089,75 +1144,35 @@ async fn remove_hosted_only( hosted_matches.len() ); if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.json && !args.common.silent { + if loud { println!("Removal cancelled."); } return 0; } - let replay_eligible = redirect_state - .records - .keys() - .all(|p| hosted_matches.contains(p)); - let before = (redirect_state.edits.len(), redirect_state.records.len()); - let leg = super::rollback::run_hosted_leg( - &args.common, - &hosted_matches, - &mut redirect_state, - replay_eligible, - ) - .await; - // Persist FIRST, failure or not (see the main-flow hosted leg): the - // per-purl reverts already flushed lockfile writes, so the on-disk - // ledger must reflect them even when a later match failed. - if !args.common.dry_run - && (redirect_state.edits.len(), redirect_state.records.len()) != before - { - if let Err(e) = socket_patch_core::patch::redirect::persist_redirect_state( - &args.common.cwd, - &redirect_state, - ) - .await - { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), - ); + let leg = match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state).await { + Ok(leg) => leg, + Err(err) => { + match &err { + HostedUnwindError::Unsupported(_) => { + track_patch_remove_failed( + "hosted redirect revert unsupported", + api_token, + org_slug, + ) + .await; + } + HostedUnwindError::Failed { .. } => { + track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug) + .await; + } + HostedUnwindError::Persist(_) => {} + } + let (code, msg) = hosted_unwind_error(err, false); + emit_error_envelope(args.common.json, args.common.dry_run, code, msg); return 1; } - } - if !leg.unsupported.is_empty() { - track_patch_remove_failed( - "hosted redirect revert unsupported", - api_token, - org_slug, - ) - .await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_unsupported", - format!( - "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ - `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ - `scan --mode hosted` to normalize.", - leg.unsupported.join(", ") - ), - ); - return 1; - } - if let Some((what, why)) = leg.failed.first() { - track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug).await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "hosted_revert_failed", - format!("could not unwind hosted redirect for {what}: {why}"), - ); - return 1; - } + }; let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; let action = if args.common.dry_run { @@ -1167,12 +1182,10 @@ async fn remove_hosted_only( }; // Human per-purl lines already printed inside `run_hosted_leg`. for purl in &leg.reverted { - env.record( - PatchEvent::new(action, purl.clone()).with_reason( - "hosted_reverted", - "hosted lockfile redirect unwound on remove", - ), - ); + env.record(PatchEvent::new(action, purl.clone()).with_reason( + "hosted_reverted", + "hosted lockfile redirect unwound on remove", + )); } if args.common.json { println!("{}", env.to_pretty_json()); @@ -1183,150 +1196,131 @@ async fn remove_hosted_only( 0 } -async fn remove_detached_only( +/// Remove path for identifiers that match ONLY vendor-ledger entries (no +/// manifest record — the shape every `scan/get --mode vendored` entry +/// has): confirm, revert each entry's wiring + artifact, drop it from the +/// ledger, and report `Removed`/`vendor_reverted` events. Unlike the +/// manifest path, the reverts here ARE the removal, so they go through +/// `env.record` and bump `summary.removed`. Drift-keeps and +/// `--preserve-state` follow the manifest path's rules exactly (the loop is +/// shared): a kept entry stays in the ledger and fails the run, +/// `--preserve-state` unwires and keeps everything. `--skip-rollback` is +/// refused: with no manifest entry to delete, removing a ledger-only +/// patch can only mean reverting its vendoring. +async fn remove_ledger_only( args: &RemoveArgs, - detached: Vec<(String, VendorEntry)>, + matches: Vec<(String, VendorEntry)>, mut state: VendorState, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { + let loud = !args.common.json && !args.common.silent; if args.skip_rollback { emit_error_envelope( args.common.json, args.common.dry_run, "vendor_state_retained", format!( - "{} matches only detached vendored patch(es); removing one means reverting \ - its vendoring, which --skip-rollback prevents", + "{} matches only vendored patch(es) with no manifest record; removing one \ + means reverting its vendoring, which --skip-rollback prevents", args.identifier ), ); return 1; } - if !args.common.json && !args.common.silent { - eprintln!("The following detached vendored patch(es) will be reverted and removed:"); - for (key, entry) in &detached { + if loud { + if args.preserve_state { + eprintln!( + "The following vendored patch(es) will be unwired (artifacts and ledger \ + entries preserved):" + ); + } else { + eprintln!("The following vendored patch(es) will be reverted and removed:"); + } + for (key, entry) in &matches { eprintln!(" - {key} (UUID: {})", short_uuid(&entry.uuid)); } eprintln!(); } // `--dry-run` previews without mutating — nothing to confirm. - let prompt = format!( - "Remove {} vendored patch(es) and revert their vendoring?", - detached.len() - ); + let prompt = if args.preserve_state { + format!( + "Unwire vendoring for {} vendored patch(es)? (artifacts and ledger entries will \ + be preserved)", + matches.len() + ) + } else { + format!( + "Remove {} vendored patch(es) and revert their vendoring?", + matches.len() + ) + }; if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if !args.common.json && !args.common.silent { + if loud { println!("Removal cancelled."); } return 0; } + let keys: Vec = matches.iter().map(|(k, _)| k.clone()).collect(); + let leg = + match revert_vendored_matches(args, &keys, &mut state, api_token, org_slug, false).await { + Ok(leg) => leg, + Err(code) => return code, + }; + let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; - for (key, entry) in &detached { - let outcome = dispatch_revert_one(entry, &args.common.cwd, args.common.dry_run).await; - for w in &outcome.warnings { - if !args.common.json && !args.common.silent { - eprintln!("Warning ({}): {}", w.code, w.detail); - } - env.record( - PatchEvent::new(PatchAction::Skipped, key.clone()) - .with_reason(w.code, w.detail.clone()), - ); - } - if !outcome.success { - track_patch_remove_failed( - "vendor revert failed during patch removal", - api_token, - org_slug, - ) - .await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_revert_failed", - format!( - "could not revert vendoring for {key}: {}", - outcome.error.as_deref().unwrap_or("unknown error") - ), - ); - return 1; - } - if args.common.dry_run { - if !args.common.json && !args.common.silent { - println!("Would revert vendoring for {key}"); - } - // Verified preview (the dry-run convention); still recorded - // so `summary.verified` counts the would-be removals. - env.record( - PatchEvent::new(PatchAction::Verified, key.clone()).with_reason( - "vendor_would_revert", - "vendoring would be reverted on remove", - ), - ); - continue; - } - state.entries.remove(key); - if let Err(e) = save_state(&args.common.cwd, &state).await { - emit_error_envelope( - args.common.json, - args.common.dry_run, - "vendor_state_write_failed", - e.to_string(), + // The reverts ARE the removal: every event is recorded, so + // `summary.removed` counts the reverted entries (`summary.verified` + // the would-be removals on --dry-run). + for ev in leg.reverted { + env.record(ev); + } + for ev in leg.skipped { + env.record(ev); + } + if !leg.kept.is_empty() { + // Any drift-kept entry means part of the requested removal did + // NOT happen: partialFailure (exit 1). When EVERY match kept, the + // top-level error names the outcome — nothing was removed. + env.mark_partial_failure(); + if leg.kept.len() == keys.len() { + let msg = format!( + "{}: every matching entry's vendored state drift-kept; nothing was \ + removed (re-run `scan --mode vendored` to normalize, then remove)", + args.identifier ); - return 1; + track_patch_remove_failed(&msg, api_token, org_slug).await; + env.error = Some(EnvelopeError::new("vendor_revert_kept", msg)); } - if !args.common.json && !args.common.silent { - println!("Reverted vendoring for {key}"); - } - env.record( - PatchEvent::new(PatchAction::Removed, key.clone()) - .with_reason("vendor_reverted", "vendoring reverted on remove"), - ); } if args.common.json { println!("{}", env.to_pretty_json()); } if !args.common.dry_run { - track_patch_removed(detached.len(), api_token, org_slug).await; - } - 0 -} - -async fn remove_patch_from_manifest( - identifier: &str, - manifest_path: &Path, - // Matching entries to KEEP anyway — drift-kept vendored purls whose - // vendored state survived the revert (the record must survive with it). - exclusions: &std::collections::HashSet, -) -> Result<(Vec, PatchManifest), String> { - let mut manifest = read_manifest(manifest_path) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Invalid manifest".to_string())?; - - let removed: Vec = manifest - .patches - .iter() - .filter(|(purl, patch)| { - patch_matches(purl, &patch.uuid, identifier) && !exclusions.contains(*purl) - }) - .map(|(purl, _)| purl.clone()) - .collect(); - - for purl in &removed { - manifest.patches.remove(purl); + track_patch_removed(leg.reverted_count, api_token, org_slug).await; } - - if !removed.is_empty() { - write_manifest(manifest_path, &manifest) - .await - .map_err(|e| e.to_string())?; + if leg.kept.is_empty() { + 0 + } else { + // Errors print even under --silent; the per-key drift-keep lines + // are gated, so name the outcome once here. + if !args.common.json { + eprintln!( + "Error: {} matching entr{} drift-kept (vendored state and ledger record \ + retained); re-run `scan --mode vendored` to normalize, then remove again", + leg.kept.len(), + if leg.kept.len() == 1 { + "y was" + } else { + "ies were" + } + ); + } + 1 } - - Ok((removed, manifest)) } #[cfg(test)] @@ -1347,10 +1341,9 @@ mod tests { } } - /// Write a manifest with three PyPI release variants of one - /// package@version plus an unrelated npm package, returning the - /// temp dir (kept alive) and the manifest path. - async fn write_multi_variant(dir: &Path) { + /// A manifest with three PyPI release variants of one package@version + /// plus an unrelated npm package. + fn multi_variant_manifest() -> PatchManifest { let mut patches = HashMap::new(); patches.insert( "pkg:pypi/six@1.16.0?artifact_id=wheel-cp311".to_string(), @@ -1365,42 +1358,38 @@ mod tests { make_record("uuid-cp312"), ); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); - let manifest = PatchManifest { + PatchManifest { patches, setup: None, - }; - write_manifest(&dir.join("manifest.json"), &manifest) - .await - .expect("write manifest"); + } } - #[tokio::test] - async fn remove_base_purl_removes_all_variants() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); + #[test] + fn remove_base_purl_removes_all_variants() { + let mut manifest = multi_variant_manifest(); - let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &Default::default()); - // All three release variants removed; the npm package untouched. + // All three release variants removed (sorted); the npm package untouched. assert_eq!(removed.len(), 3); assert!(removed.iter().all(|p| p.contains("six@1.16.0"))); + assert!( + removed.windows(2).all(|w| w[0] < w[1]), + "sorted: {removed:?}" + ); assert_eq!(manifest.patches.len(), 1); assert!(manifest.patches.contains_key("pkg:npm/foo@1.0")); } - #[tokio::test] - async fn remove_qualified_purl_removes_single_variant() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); + #[test] + fn remove_qualified_purl_removes_single_variant() { + let mut manifest = multi_variant_manifest(); - let (removed, manifest) = - remove_patch_from_manifest("pkg:pypi/six@1.16.0?artifact_id=sdist", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching( + &mut manifest, + "pkg:pypi/six@1.16.0?artifact_id=sdist", + &Default::default(), + ); // Only the sdist variant removed; the two wheels + npm remain. assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]); @@ -1410,15 +1399,11 @@ mod tests { .contains_key("pkg:pypi/six@1.16.0?artifact_id=sdist")); } - #[tokio::test] - async fn remove_by_uuid_removes_single_variant() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); + #[test] + fn remove_by_uuid_removes_single_variant() { + let mut manifest = multi_variant_manifest(); - let (removed, manifest) = remove_patch_from_manifest("uuid-cp312", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "uuid-cp312", &Default::default()); assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=wheel-cp312"]); assert_eq!(manifest.patches.len(), 3); @@ -1428,60 +1413,43 @@ mod tests { /// must not accidentally match same-prefix neighbours like /// `foobar@1.0`. Guards the `strip_purl_qualifiers == identifier` /// exact-equality path for non-PyPI keys. - #[tokio::test] - async fn remove_npm_purl_is_exact_and_does_not_prefix_match() { - let tmp = tempfile::tempdir().expect("tempdir"); + #[test] + fn remove_npm_purl_is_exact_and_does_not_prefix_match() { let mut patches = HashMap::new(); patches.insert("pkg:npm/foo@1.0".to_string(), make_record("uuid-foo")); patches.insert("pkg:npm/foobar@1.0".to_string(), make_record("uuid-foobar")); - let manifest = PatchManifest { + let mut manifest = PatchManifest { patches, setup: None, }; - let manifest_path = tmp.path().join("manifest.json"); - write_manifest(&manifest_path, &manifest) - .await - .expect("write manifest"); - let (removed, manifest) = remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "pkg:npm/foo@1.0", &Default::default()); assert_eq!(removed, vec!["pkg:npm/foo@1.0"]); assert_eq!(manifest.patches.len(), 1); assert!(manifest.patches.contains_key("pkg:npm/foobar@1.0")); } - /// An identifier that matches nothing removes nothing and — crucially - /// — must NOT rewrite the manifest file. We assert byte-identity of - /// the on-disk manifest before/after so a future change that always - /// re-serializes (churning mtime / formatting) is caught. - #[tokio::test] - async fn remove_no_match_leaves_manifest_file_untouched() { - let tmp = tempfile::tempdir().expect("tempdir"); - write_multi_variant(tmp.path()).await; - let manifest_path = tmp.path().join("manifest.json"); - let before_bytes = tokio::fs::read(&manifest_path).await.expect("read before"); - - let (removed, manifest) = - remove_patch_from_manifest("pkg:npm/not-here@9.9.9", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + /// An identifier that matches nothing removes nothing and leaves the + /// manifest intact. `run` gates the manifest write on a non-empty + /// removal, so a no-op remove never rewrites the file (the on-disk + /// byte-identity is pinned end-to-end by `cli_parse_remove`'s no-match + /// test). + #[test] + fn remove_no_match_leaves_manifest_untouched() { + let mut manifest = multi_variant_manifest(); + let before = manifest.clone(); + + let removed = remove_matching(&mut manifest, "pkg:npm/not-here@9.9.9", &Default::default()); assert!(removed.is_empty(), "nothing should match"); - assert_eq!(manifest.patches.len(), 4, "manifest left intact"); - let after_bytes = tokio::fs::read(&manifest_path).await.expect("read after"); - assert_eq!( - before_bytes, after_bytes, - "a no-op remove must not rewrite the manifest file" - ); + assert_eq!(manifest, before, "manifest left intact"); } /// A base PURL must not bleed across versions: removing `six@1.16.0` /// leaves `six@1.17.0` (and its variants) in place. - #[tokio::test] - async fn remove_base_purl_does_not_touch_other_versions() { - let tmp = tempfile::tempdir().expect("tempdir"); + #[test] + fn remove_base_purl_does_not_touch_other_versions() { let mut patches = HashMap::new(); patches.insert( "pkg:pypi/six@1.16.0?artifact_id=sdist".to_string(), @@ -1491,18 +1459,12 @@ mod tests { "pkg:pypi/six@1.17.0?artifact_id=sdist".to_string(), make_record("uuid-17-sdist"), ); - let manifest = PatchManifest { + let mut manifest = PatchManifest { patches, setup: None, }; - let manifest_path = tmp.path().join("manifest.json"); - write_manifest(&manifest_path, &manifest) - .await - .expect("write manifest"); - let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path, &Default::default()) - .await - .expect("remove ok"); + let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &Default::default()); assert_eq!(removed, vec!["pkg:pypi/six@1.16.0?artifact_id=sdist"]); assert_eq!(manifest.patches.len(), 1); @@ -1510,4 +1472,26 @@ mod tests { .patches .contains_key("pkg:pypi/six@1.17.0?artifact_id=sdist")); } + + /// Drift-kept exclusions survive the removal of their matching + /// siblings: the record whose vendored state was kept stays. + #[test] + fn remove_matching_honors_exclusions() { + let mut manifest = multi_variant_manifest(); + let exclusions: HashSet = + ["pkg:pypi/six@1.16.0?artifact_id=sdist".to_string()].into(); + + let removed = remove_matching(&mut manifest, "pkg:pypi/six@1.16.0", &exclusions); + + assert_eq!( + removed.len(), + 2, + "the two wheels go, the excluded sdist stays" + ); + assert!(manifest + .patches + .contains_key("pkg:pypi/six@1.16.0?artifact_id=sdist")); + assert!(manifest.patches.contains_key("pkg:npm/foo@1.0")); + assert_eq!(manifest.patches.len(), 2); + } } diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4448677c..e1693b40 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -3,10 +3,8 @@ use socket_patch_core::api::blob_fetcher::{ fetch_missing_sources, format_fetch_result, get_missing_archives, get_missing_blobs, DownloadMode, FetchMissingBlobsResult, }; -use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::cleanup_blobs::{ - cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, -}; +use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; +use socket_patch_core::manifest::cleanup_blobs::format_cleanup_result; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::telemetry::{track_patch_repair_failed, track_patch_repaired}; @@ -15,6 +13,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::lock_cli::{acquire_or_emit, error_envelope}; +use crate::commands::rollback::{sweep_failure, sweep_unused_artifacts}; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, Status}; #[derive(Args)] @@ -69,13 +68,20 @@ pub async fn run(args: RepairArgs) -> i32 { let manifest_path = args.common.resolved_manifest_path(); + // The lockfile scan (`scan_vendor_references` opens every wiring file) + // runs at most once per repair: the existence gate below needs it only + // for a ledger-less project, and that result is reused under the lock. + let mut vendor_references: Option> = None; + if tokio::fs::metadata(&manifest_path).await.is_err() { // Hosted (redirect) mode leaves no local artifacts to repair: the // lockfiles point at patch.socket.dev URLs, not `.socket/vendor/...`, // and there is no manifest or vendor ledger. A project whose only // trace is `redirect-state.json` is therefore a no-op for repair — // exit success with an informational skip rather than the - // `manifest_not_found` error a bare directory would get. + // `manifest_not_found` error a bare directory would get. Only cheap + // existence probes (and the read-only lockfile scan) run before the + // lock, so a project with nothing to repair never grows `.socket/`. let redirect_state = args .common .cwd @@ -84,10 +90,13 @@ pub async fn run(args: RepairArgs) -> i32 { .common .cwd .join(socket_patch_core::vendor::VENDOR_STATE_REL); - let has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok() - || !crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd) - .await - .is_empty(); + let mut has_vendor_traces = tokio::fs::metadata(&state_file).await.is_ok(); + if !has_vendor_traces { + let refs = + crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await; + has_vendor_traces = !refs.is_empty(); + vendor_references = Some(refs); + } if !has_vendor_traces { if tokio::fs::metadata(&redirect_state).await.is_ok() { let msg = "hosted redirects need no local repair; re-run \ @@ -120,19 +129,18 @@ pub async fn run(args: RepairArgs) -> i32 { } return 1; } - // The vendor-only repair still serializes on the .socket lock; the - // lock layer deliberately refuses to mkdir. - if let Some(dir) = manifest_path.parent() { - let _ = tokio::fs::create_dir_all(dir).await; - } } // Serialize against concurrent socket-patch runs targeting the - // same `.socket/` directory. See `apply_lock`. A live holder makes - // repair refuse with `lock_held` — it never steals the lock. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); - let lock = match acquire_or_emit( - socket_dir, + // same `.socket/` directory. See `apply_lock`: acquire creates the + // directory when needed (the vendor-only repair of a ledger-less + // project), and the guard's drop removes `apply.lock` — and an + // otherwise-empty `.socket/` — on every exit path, dry-run included. + // A live holder makes repair refuse with `lock_held`; it never steals + // the lock. + let socket_dir = crate::args::socket_dir_of(&manifest_path, &args.common.cwd); + let _lock = match acquire_or_emit( + &socket_dir, Command::Repair, args.common.json, args.common.dry_run, @@ -142,7 +150,22 @@ pub async fn run(args: RepairArgs) -> i32 { Err(code) => return code, }; - let exit_code = match repair_inner(&args, &manifest_path).await { + // Lockfile references are read under the lock (a concurrent vendor run + // rewrites them under the same lock) unless the gate above already + // scanned this ledger-less project. + let vendor_references = match vendor_references { + Some(refs) => refs, + None => crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await, + }; + + match repair_inner( + &args, + &manifest_path, + Some(&telemetry_client), + vendor_references, + ) + .await + { Ok((env, counts)) => { // A repair where some artifacts failed to download is marked a // partial failure inside `repair_inner` (a `Failed` event plus @@ -186,36 +209,7 @@ pub async fn run(args: RepairArgs) -> i32 { } 1 } - }; - - // Clean slate: repair owns the lock-file cleanup (the mutating - // commands deliberately leave `apply.lock` behind between runs). - // Drop our guard FIRST so the unlink races nothing we hold, then - // best-effort delete. A live holder never reaches here — contention - // already returned above. The residual window (a competitor that - // acquires between the drop and the unlink gets its file orphaned) - // is microseconds at the tail of a finished repair and worth the - // trade; see `apply_lock`'s module doc. - drop(lock); - if !args.common.dry_run { - let lock_file = socket_dir.join("apply.lock"); - match std::fs::remove_file(&lock_file) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - // Housekeeping only: a leftover lock file is harmless, so - // a failed delete warns (human mode) without flipping the - // exit code of an otherwise-finished repair. - if !args.common.silent && !args.common.json { - eprintln!( - "Warning: could not remove lock file {}: {e}", - lock_file.display() - ); - } - } - } } - exit_code } /// Aggregate counts surfaced by `repair_inner` for telemetry use. @@ -228,6 +222,13 @@ struct RepairCounts { async fn repair_inner( args: &RepairArgs, manifest_path: &Path, + // The client `run()` already built: constructing another one for the + // download printed the core client's "No SOCKET_API_TOKEN set" notice + // twice per repair. `None` (unit tests) builds one on demand, only when + // the download below actually fires. + api_client: Option<&ApiClient>, + // `(eco, uuid, rel)` lockfile vendor references, scanned once by `run`. + vendor_references: Vec<(String, String, String)>, ) -> Result<(Envelope, RepairCounts), String> { // `Ok(None)` = no manifest (vendor-only repair); present-but-invalid // stays a hard error. @@ -235,9 +236,7 @@ async fn repair_inner( .await .map_err(|e| e.to_string())?; - let socket_dir = manifest_path - .parent() - .expect("manifest path names a file, so it has a parent"); + let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); let diffs_path = socket_dir.join("diffs"); let packages_path = socket_dir.join("packages"); @@ -275,25 +274,27 @@ async fn repair_inner( // packages` — repair must not re-litter them (or fail trying). The // cleanup phase below still uses the FULL manifest, so it never sweeps // sources an in-place apply may need for rollback. - let vendor_state = socket_patch_core::vendor::load_state(&args.common.cwd) - .await - .unwrap_or_default(); + // Loaded ONCE under the lock; the vendored phase below takes the raw + // result (an unreadable ledger is ITS loud failure), while this scoping + // degrades to "nothing vendored" — a corrupt ledger must not hide the + // manifest's own missing sources. + let ledger = socket_patch_core::vendor::load_state(&args.common.cwd).await; + let no_entries = std::collections::HashMap::new(); + let vendor_entries = ledger.as_ref().map(|s| &s.entries).unwrap_or(&no_entries); // Lockfile vendor references count as vendored even before the ledger // is reconstructed, so a no-ledger repair doesn't download sources for // entries the vendored phase is about to own. - let referenced_uuids: std::collections::HashSet = - crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd) - .await - .into_iter() - .map(|(_, uuid, _)| uuid) - .collect(); + let referenced_uuids: std::collections::HashSet = vendor_references + .iter() + .map(|(_, uuid, _)| uuid.clone()) + .collect(); let scoped_manifest = manifest.as_ref().map(|m| { let patches = m .patches .iter() .filter(|(purl, rec)| { !referenced_uuids.contains(&rec.uuid) - && socket_patch_core::vendor::lookup_entry(&vendor_state.entries, purl) + && socket_patch_core::vendor::lookup_entry(vendor_entries, purl) .is_none_or(|e| e.uuid != rec.uuid) }) .map(|(k, v)| (k.clone(), v.clone())) @@ -366,8 +367,17 @@ async fn repair_inner( if !quiet { println!("\nDownloading missing {}s...", download_mode.as_tag()); } - let (client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; + let built_client; + let client = match api_client { + Some(c) => c, + None => { + built_client = + get_api_client_with_overrides(args.common.api_client_overrides()) + .await + .0; + &built_client + } + }; let sources = PatchSources { blobs_path: &blobs_path, packages_path: Some(&packages_path), @@ -380,7 +390,7 @@ async fn repair_inner( .as_ref() .expect("step 1 requires a manifest"); let fetch_result = - fetch_missing_sources(m, &sources, download_mode, &client, None).await; + fetch_missing_sources(m, &sources, download_mode, client, None).await; downloaded_count = fetch_result.downloaded; download_failed_count = fetch_result.failed; if !quiet { @@ -403,12 +413,16 @@ async fn repair_inner( // Step 1.5: vendored artifacts — health-check the ledger (and any // lockfile vendor references with no ledger coverage) and rebuild // missing/corrupt artifacts. Runs under `--download-only` too: - // restoring artifacts IS repair's download half. - let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts( + // restoring artifacts IS repair's download half. The reference scan + // and ledger load above are handed over, not repeated. + let vendor_rebuilt = crate::commands::repair_vendor::repair_vendored_artifacts_with_references( &args.common, manifest.as_ref(), - socket_dir, + &socket_dir, &mut env, + &vendor_references, + ledger, + api_client, ) .await; if !quiet && vendor_rebuilt > 0 { @@ -420,70 +434,49 @@ async fn repair_inner( if !quiet { println!(); } - match cleanup_unused_blobs(manifest, &blobs_path, args.common.dry_run).await { - Ok(cleanup_result) => { - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if !quiet { - if cleanup_result.blobs_checked == 0 { - println!("No blobs directory found, nothing to clean up."); - } else if cleanup_result.blobs_removed == 0 { - println!( - "Checked {} blob(s), all are in use.", - cleanup_result.blobs_checked - ); - } else { - println!( - "{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - ); - } - } - } - Err(e) => { - // A failed cleanup is error output: `--silent` (suppress - // NON-error output) must not mute it, and the JSON envelope - // must carry it — a bare `status: success` with no events is - // indistinguishable from "nothing to clean". Recorded as an - // informational skip (not `Failed`) to preserve the human - // path's warn-and-continue contract: status stays success, - // exit stays 0. + let sweep = sweep_unused_artifacts(manifest, &socket_dir, args.common.dry_run).await; + // The blob pass prints its status unconditionally ("all are in + // use" included — the core helper owns that wording); the archive + // passes print only when they removed something, relabeled. + let passes = [ + ("blob", None, sweep.blobs), + ("diff", Some("diff archive(s)"), sweep.diffs), + ("package", Some("package archive(s)"), sweep.packages), + ]; + for (label, relabel, result) in passes { + // A failed cleanup — the pass aborted, or it could not unlink + // every orphan — is error output: `--silent` (suppress + // NON-error output) must not mute it, and the JSON envelope + // must carry it — a bare `status: success` with no events is + // indistinguishable from "nothing to clean". Recorded as an + // informational skip (not `Failed`) to preserve the human + // path's warn-and-continue contract: status stays success, + // exit stays 0, and the loop goes on to the next directory. + if let Some(detail) = sweep_failure(label, &result) { if !args.common.json { - eprintln!("Warning: blob cleanup failed: {e}"); + eprintln!("Warning: {detail}"); } env.record( PatchEvent::artifact(PatchAction::Skipped) - .with_reason("cleanup_failed", format!("blob cleanup failed: {e}")), + .with_reason("cleanup_failed", detail), ); } - } - - // Diff and package archives. - for (path, label) in [(&diffs_path, "diff"), (&packages_path, "package")] { - match cleanup_unused_archives(manifest, path, args.common.dry_run).await { - Ok(cleanup_result) => { - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if !quiet && cleanup_result.blobs_removed > 0 { - println!( - "{}", - format_cleanup_result(&cleanup_result, args.common.dry_run) - .replace("blob(s)", &format!("{label} archive(s)")) - ); - } - } - Err(e) => { - // Same contract as the blob-cleanup arm above. - if !args.common.json { - eprintln!("Warning: {label} cleanup failed: {e}"); - } - env.record( - PatchEvent::artifact(PatchAction::Skipped) - .with_reason("cleanup_failed", format!("{label} cleanup failed: {e}")), - ); + let Ok(cleanup_result) = result else { + continue; + }; + blobs_checked += cleanup_result.blobs_checked; + blobs_cleaned += cleanup_result.blobs_removed; + bytes_freed += cleanup_result.bytes_freed; + if quiet { + continue; + } + let text = format_cleanup_result(&cleanup_result, args.common.dry_run); + match relabel { + None => println!("{text}"), + Some(relabel) if cleanup_result.blobs_removed > 0 => { + println!("{}", text.replace("blob(s)", relabel)); } + Some(_) => {} } } } @@ -634,7 +627,7 @@ mod tests { let mut args = offline_args(tmp.path()); args.common.dry_run = true; - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -659,7 +652,7 @@ mod tests { args.common.offline = false; args.common.dry_run = true; - let (env, _counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -683,7 +676,7 @@ mod tests { write_blob(&socket, &orphan_hash, orphan_bytes); let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -716,7 +709,7 @@ mod tests { args.common.offline = false; args.download_only = true; - let (_env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (_env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -758,7 +751,7 @@ mod tests { ); let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -838,7 +831,7 @@ mod tests { let mut args = offline_args(tmp.path()); args.common.json = false; - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -857,7 +850,7 @@ mod tests { args.common.dry_run = true; args.common.json = false; - let (env, _counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); @@ -879,7 +872,7 @@ mod tests { // No blob on disk → manifest afterHash is "missing". Not dry-run. let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json")) + let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) .await .expect("repair_inner"); diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index f8938a68..afac5869 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -52,6 +52,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; +use socket_patch_core::constants::SOCKET_DIR; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; @@ -62,8 +63,8 @@ use socket_patch_core::utils::purl::{ use socket_patch_core::vendor::state::{VendorArtifact, WiringRecord}; use socket_patch_core::vendor::{ self, artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, file_sha256_hex, - load_state, lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, - VendorOutcome, VendorWarning, + lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, + VendorState, VendorWarning, }; use socket_patch_core::vex::time::now_rfc3339; @@ -73,7 +74,7 @@ use crate::commands::vendor::{ dispatch_vendor_one, ecosystem_in_scope, fetch_pristine_package, persist_vendor_entry, record_warning, PristineFetch, }; -use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls}; +use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; /// One broken vendored unit queued for rebuild. @@ -168,8 +169,7 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, // would delete the include-referenced wheel). An unreadable include // tree degrades to the root file, matching the per-file tolerance // below. - if let Ok(includes) = - socket_patch_core::vendor::requirements_include_names(project_root).await + if let Ok(includes) = socket_patch_core::vendor::requirements_include_names(project_root).await { files.extend(includes); } @@ -282,9 +282,7 @@ async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> } let needle = format!(".socket/vendor/npm/{uuid}/"); let read = |name: &'static str| async move { - read_regular_to_string(&project_root.join(name)) - .await - .ok() + read_regular_to_string(&project_root.join(name)).await.ok() }; if read("bun.lock").await.is_some_and(|t| t.contains(&needle)) { return Some("bun".to_string()); @@ -425,12 +423,17 @@ fn warn_wiring_unknown(env: &mut Envelope, common: &GlobalArgs, detail: String) }); } -/// Best-effort removal of a vendored uuid dir — ahead of a rebuild (corrupt -/// bytes must never blend into one) or after a failed post-verify (never -/// leave unverifiable bytes behind). +/// Best-effort removal of a vendored uuid dir after a failed post-verify +/// (never leave unverifiable bytes behind). Prunes the emptied +/// `.socket/vendor//` (and `vendor/`) husks like every other artifact +/// removal, stopping at `.socket/`; a sibling unit or the ledger keeps them. async fn remove_vendor_dir(cwd: &Path, eco: &str, uuid: &str) { if let Some(rel) = vendor::path::vendor_uuid_dir_rel(eco, uuid) { - let _ = remove_tree(&cwd.join(rel)).await; + let _ = socket_patch_core::utils::socket_dir::remove_tree_and_prune( + &cwd.join(rel), + &cwd.join(SOCKET_DIR), + ) + .await; } } @@ -466,22 +469,79 @@ async fn restore_aside_vendor_dir(live: &Path, kept: &Path) { let _ = tokio::fs::rename(kept, live).await; } +/// Crash recovery for [`set_aside_vendor_dir`]'s transient: a run killed +/// between the move-aside and the backend's replacement leaves +/// `.socket/vendor//.pre-rebuild` as the ONLY copy of bytes the +/// rewired lockfiles still point at, with the live path a bare ENOENT. Put +/// every such leftover back where the wiring expects it before pass 1 +/// classifies the unit (it then re-derives corrupt/soft/healthy from the +/// restored bytes exactly as the crashed run did). A leftover whose live +/// sibling EXISTS is left alone: the live dir may be the completed +/// replacement or a partial husk, and only the health pass can tell — a +/// unit it condemns is set aside again, which clears the leftover. Wet +/// runs only; scope-gated like every other unit; best-effort throughout. +async fn restore_orphaned_pre_rebuild_dirs(common: &GlobalArgs) { + const SUFFIX: &str = ".pre-rebuild"; + let vendor_root = common.cwd.join(".socket/vendor"); + let Ok(mut ecos) = tokio::fs::read_dir(&vendor_root).await else { + return; + }; + while let Ok(Some(eco_dir)) = ecos.next_entry().await { + let eco = eco_dir.file_name().to_string_lossy().into_owned(); + if !ecosystem_in_scope(common, &eco) || !eco_dir.path().is_dir() { + continue; + } + let Ok(mut units) = tokio::fs::read_dir(eco_dir.path()).await else { + continue; + }; + while let Ok(Some(unit)) = units.next_entry().await { + let name = unit.file_name().to_string_lossy().into_owned(); + let Some(uuid) = name.strip_suffix(SUFFIX) else { + continue; + }; + let live = eco_dir.path().join(uuid); + if unit.path().is_dir() && tokio::fs::symlink_metadata(&live).await.is_err() { + let _ = tokio::fs::rename(unit.path(), &live).await; + } + } + } +} + /// The vendored-artifact phase of `repair`. Runs between the download and /// cleanup phases (and under `--download-only` — restoring artifacts IS /// repair's job). `manifest` is `None` when the project has no /// `.socket/manifest.json` (detached/reconstruction-only repairs). /// Returns the number of artifacts rebuilt (for the human summary line); /// failures are carried by `env` (`Failed` events + partial-failure status). -pub(crate) async fn repair_vendored_artifacts( +/// +/// `references` is [`scan_vendor_references`]'s `(ecosystem, uuid, +/// artifact relpath)` output for `common.cwd` and `ledger` the caller's +/// `load_state` outcome — both taken by repair.rs under the apply lock +/// this phase runs under (the lockfiles and ledger they describe are the +/// ones the reconstruction below rewires), so neither is re-read here. An +/// unreadable ledger fails this phase loudly (`vendor_state_unreadable`); +/// the caller's own degrade-to-empty policy for its download scoping is +/// its own. `run_client` is the run's API client when the caller already +/// built one (repair.rs's `telemetry_client`): the uuid lookups and the +/// staging fetch reuse it instead of constructing a second (or third) one +/// and re-printing its token advisory; `None` builds lazily on first need. +pub(crate) async fn repair_vendored_artifacts_with_references( common: &GlobalArgs, manifest: Option<&PatchManifest>, socket_dir: &Path, env: &mut Envelope, + references: &[(String, String, String)], + ledger: std::io::Result, + run_client: Option<&ApiClient>, ) -> usize { let quiet = common.json || common.silent; let mut rebuilt = 0usize; - let mut state = match load_state(&common.cwd).await { + if !common.dry_run { + restore_orphaned_pre_rebuild_dirs(common).await; + } + + let mut state = match ledger { Ok(s) => s, Err(e) => { env.record( @@ -495,8 +555,9 @@ pub(crate) async fn repair_vendored_artifacts( // ── Pass 1: ledger-driven health check ─────────────────────────────── // Shared across both passes so the API client (and its one-time - // token-shape stderr advisory) is constructed at most once per run. - let mut api_client: Option = None; + // token-shape stderr advisory) is constructed at most once per run — + // seeded from the run's client when the caller has one. + let mut api_client: Option = run_client.cloned(); let mut candidates: Vec = Vec::new(); let mut ledger_purls: Vec = state.entries.keys().cloned().collect(); ledger_purls.sort(); @@ -729,7 +790,7 @@ pub(crate) async fn repair_vendored_artifacts( .values() .map(|e| (e.ecosystem.clone(), e.uuid.clone())) .collect(); - for (eco, uuid, relpath) in scan_vendor_references(&common.cwd).await { + for (eco, uuid, relpath) in references.iter().cloned() { if covered.contains(&(eco.clone(), uuid.clone())) || !ecosystem_in_scope(common, &eco) { continue; } @@ -998,7 +1059,18 @@ pub(crate) async fn repair_vendored_artifacts( patches: records_map, setup: None, }; - let staged = match stage_vendor_sources_in_memory(common, &synth, socket_dir, &common.cwd).await + // The ledger this pass already holds feeds the staging harvest; repair + // has no download phase, so no seed. + let staged = match stage_vendor_sources_in_memory( + common, + &synth, + socket_dir, + &common.cwd, + Ok(&state.entries), + HashMap::new(), + api_client.as_ref(), + ) + .await { MemStageOutcome::Ready(s) => s, MemStageOutcome::Unavailable => { @@ -1047,7 +1119,16 @@ pub(crate) async fn repair_vendored_artifacts( global: common.global, global_prefix: common.global_prefix.clone(), }; - let mut all_packages = find_packages_for_purls(&partitioned, &crawler_options, quiet).await; + // Ledger keys are the manifest spelling — QUALIFIED for release-variant + // ecosystems (gem `?platform=`, pypi `?artifact_id=`, maven + // `?classifier=&ext=`) — while the crawler knows only base purls. A + // base-keyed result map would make the `contains_key(&c.purl)` checks + // below miss every installed + // qualified-key package and fall through to a needless registry fetch + // (or, offline, a spurious unrepairable / fingerprint-less restore). + // The rollback variant fans each base path back out to every qualified + // caller purl — the same fix `vendor_records` carries. + let mut all_packages = find_packages_for_rollback(&partitioned, &crawler_options, quiet).await; let inventory = lock_inventory::inventory_project(&common.cwd).await; let client = registry_fetch::build_registry_client(); let mut holders: Vec = Vec::new(); @@ -1574,10 +1655,7 @@ async fn fetch_record_by_uuid( let client = client_cache .as_ref() .expect("client_cache was just initialized above"); - let patch = client - .fetch_patch(common.org.as_deref(), uuid) - .await - .ok()??; + let patch = client.fetch_patch(uuid).await.ok()??; Some(crate::commands::get::record_from_patch_response(&patch)) } @@ -1709,7 +1787,11 @@ mod tests { let fifos = ["tool.py", "bun.lock"]; for name in fifos { let c = std::ffi::CString::new(root.join(name).to_str().unwrap()).unwrap(); - assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, 0, "mkfifo {name}"); + assert_eq!( + unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, + 0, + "mkfifo {name}" + ); } // Release valve: if a read DID wedge in open(2), connecting a // writer lets the blocking thread finish so the runtime can shut @@ -1759,7 +1841,9 @@ mod tests { tokio::fs::write(root.join("requirements.txt"), "-r requirements/base.txt\n") .await .unwrap(); - tokio::fs::create_dir(root.join("requirements")).await.unwrap(); + tokio::fs::create_dir(root.join("requirements")) + .await + .unwrap(); tokio::fs::write( root.join("requirements/base.txt"), format!( @@ -1923,6 +2007,14 @@ mod tests { assert!(dir.is_dir(), "a non-canonical uuid must remove nothing"); remove_vendor_dir(tmp.path(), "npm", uuid).await; assert!(!dir.exists(), "the canonical pair removes its uuid dir"); + assert!( + !tmp.path().join(".socket/vendor").exists(), + "the emptied / and vendor/ husks are pruned" + ); + assert!( + tmp.path().join(".socket").is_dir(), + ".socket/ is never removed" + ); } /// The empty-component rejects: a purl with no name or no version can diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 12986e52..555ce670 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2,7 +2,9 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::{fetch_blobs_by_hash, format_fetch_result}; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; -use socket_patch_core::manifest::cleanup_blobs::{cleanup_unused_archives, cleanup_unused_blobs}; +use socket_patch_core::manifest::cleanup_blobs::{ + cleanup_unused_archives, cleanup_unused_blobs, CleanupResult, +}; use socket_patch_core::manifest::operations::{ get_before_hash_blobs, read_manifest, write_manifest, }; @@ -13,7 +15,8 @@ use socket_patch_core::patch::rollback::{ VerifyRollbackResult, VerifyRollbackStatus, }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; -use socket_patch_core::utils::purl::strip_purl_qualifiers; +use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; +use socket_patch_core::vendor::{save_state, RevertOpts, VendorState, VendorWarning}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -21,7 +24,7 @@ use std::time::Duration; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::apply::is_local_go; use crate::commands::lock_cli::acquire_or_emit; -use crate::commands::remove::patch_matches; +use crate::commands::vendor::dispatch_revert_one_opts; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; use crate::looks_like_uuid; @@ -174,31 +177,31 @@ struct PatchToRollback { /// out not-installed exits 0 / `success`. Do not "fix" this into symmetry: /// `remove` also rides on it (it drops long-uninstalled entries from the /// manifest via its "No packages found to rollback" path). -struct RollbackOutcome { +pub(crate) struct RollbackOutcome { /// No attempted rollback failed (per-package; see above). - success: bool, - results: Vec, + pub(crate) success: bool, + pub(crate) results: Vec, /// Vendor-owned purls excluded from in-place rollback (benign). - vendored_skipped: Vec, + pub(crate) vendored_skipped: Vec, /// In-scope manifest entries with no installed package on disk — /// apply's `unmatched` twin (`package_not_installed`). Never in the /// before-blob plan, never a failed result. Sorted for determinism. - not_installed: Vec, + pub(crate) not_installed: Vec, /// Release-variant manifest entries narrowed away by /// `select_installed_variants` (their distribution is not on disk; /// an attempted sibling covered the group). The manifest-cleanup /// default drops them with their group. Empty on early returns. - narrowed_out: Vec, + pub(crate) narrowed_out: Vec, /// The run aborted at the before-blob gate BEFORE any restore ran /// (offline with missing blobs, or a failed download). The CLI /// boundary's manifest-cleanup default must skip entirely: nothing /// was restored, so nothing is removable and the GC must not sweep /// the revert data the retry needs. - aborted: bool, + pub(crate) aborted: bool, } /// How `rollback_patches_inner` selects manifest entries. -enum InnerSelection<'a> { +pub(crate) enum InnerSelection<'a> { /// The legacy single-identifier filter (`remove`'s delegation): a /// no-match identifier is an error, a missing manifest is an error, /// and `None` selects the whole manifest. @@ -206,8 +209,10 @@ enum InnerSelection<'a> { /// A pre-resolved purl set from the CLI boundary's target resolver /// (identifiers ∪ path globs ∪ everything). No-match and /// missing-manifest handling already happened upstream, so an empty - /// selection is a quiet success; `announce_empty` keeps the unscoped - /// run's "No patches found in manifest" line. + /// selection is a quiet success; `announce_empty` keeps the "No + /// patches found in manifest" line for an unscoped run with no work + /// in ANY leg (a hosted-/vendored-only project has work, so it is + /// not "no patches"). Scope { purls: &'a HashSet, announce_empty: bool, @@ -218,33 +223,27 @@ enum InnerSelection<'a> { // Local go rolls back by dropping the project-local redirect (go's `replace` // directive) + the patched copy — no in-place restore, no before-blob. Cargo // patches in place (vendored or registry cache), so it rolls back in place from -// before-blobs like npm/pypi. The helper is an inert stub without `golang`. -// `is_local_go` is shared with `apply`, which creates the same redirects. - -/// True when `purl` rolls back by dropping a project-local redirect (local-mode -/// go) rather than restoring bytes from a before-blob. The before-blob gate uses -/// this to skip those PURLs — they read no blobs, so a missing before-blob must -/// not block (or trigger a needless download for) an offline redirect rollback. -fn is_local_redirect(purl: &str, common: &GlobalArgs) -> bool { - if is_local_go(purl, common) { - return true; - } - let _ = (purl, common); - false -} - -/// Copy of `manifest` with local-redirect PURLs (local-mode go) removed — used -/// for the before-blob gate, which those PURLs never need. Avoids blocking an -/// offline redirect rollback on absent blobs. -fn exclude_local_redirects(manifest: &PatchManifest, common: &GlobalArgs) -> PatchManifest { +// before-blobs like npm/pypi. `is_local_go` is shared with `apply`, which +// creates the same redirects. + +/// The before-blob gate's manifest: the ATTEMPTED (crawler-discovered) +/// entries of `scoped`, minus local-redirect PURLs (local-mode go). Those +/// roll back by dropping a project-local redirect and read no blobs, so a +/// missing before-blob must not block (or trigger a needless download for) +/// an offline redirect rollback. +fn before_blob_gate_manifest( + scoped: &PatchManifest, + attempted: &HashSet<&str>, + common: &GlobalArgs, +) -> PatchManifest { PatchManifest { - patches: manifest + patches: scoped .patches .iter() - .filter(|(purl, _)| !is_local_redirect(purl, common)) + .filter(|(purl, _)| attempted.contains(purl.as_str()) && !is_local_go(purl, common)) .map(|(k, v)| (k.clone(), v.clone())) .collect(), - setup: manifest.setup.clone(), + setup: None, } } @@ -534,96 +533,180 @@ pub(crate) struct HostedLegOutcome { pub(crate) edited_files: std::collections::BTreeSet, } +/// What one vendored ledger entry's revert did. Silent by design — the +/// caller owns the print and envelope vocabulary. Shared by `rollback`'s +/// vendored leg and both of `remove`'s vendored paths, so the drift-keep +/// and `--preserve-state` rules are identical by construction. +pub(crate) enum VendorRevertStep { + /// `key` has no ledger entry (a divergent ledger, or an earlier leg + /// already reverted it): a silent no-op. + Missing, + /// The backend refused; nothing changed for this entry. + Failed(String), + /// Drift-keep: the lock changed under us and the backend left both the + /// wiring and the artifact alone. Per `RevertOutcome`'s contract the + /// ledger entry — and any manifest record — must survive. + Kept, + /// Dry run: the revert (or, with `keep_artifact`, the unwire) would + /// succeed. Nothing changed. + WouldRevert, + /// `keep_artifact`: wiring restored; artifact and ledger entry kept + /// byte-identical. Its wiring records now describe already-reverted + /// fragments, which later reverts replay as silent no-ops (the + /// liveness contract), and a re-vendor re-wires from the live lock. + Preserved, + /// Reverted on disk, dropped from the ledger, ledger saved (per entry, + /// so the run is crash-consistent like `vendor --revert`). + Reverted, + /// Reverted on disk and dropped from the in-memory ledger, but the + /// ledger write failed. + LedgerWriteFailed(String), +} + +pub(crate) struct VendorRevertResult { + pub(crate) warnings: Vec, + pub(crate) step: VendorRevertStep, +} + +/// Revert the vendored ledger entry `key` (see [`VendorRevertStep`]). +pub(crate) async fn revert_vendor_entry( + cwd: &Path, + key: &str, + state: &mut VendorState, + opts: RevertOpts, +) -> VendorRevertResult { + let Some(entry) = state.entries.get(key).cloned() else { + return VendorRevertResult { + warnings: Vec::new(), + step: VendorRevertStep::Missing, + }; + }; + let outcome = dispatch_revert_one_opts(&entry, cwd, opts).await; + let step = if !outcome.success { + VendorRevertStep::Failed(outcome.error.unwrap_or_else(|| "unknown error".into())) + } else if outcome.kept_artifact { + VendorRevertStep::Kept + } else if opts.dry_run { + VendorRevertStep::WouldRevert + } else if opts.keep_artifact { + VendorRevertStep::Preserved + } else { + state.entries.remove(key); + match save_state(cwd, state).await { + Ok(()) => VendorRevertStep::Reverted, + Err(e) => VendorRevertStep::LedgerWriteFailed(e.to_string()), + } + }; + VendorRevertResult { + warnings: outcome.warnings, + step, + } +} + +/// One GC pass over `.socket/blobs`, `diffs` and `packages` against +/// `reference` (the post-removal manifest with the revert blobs a later +/// rollback needs pinned in). Each directory reports separately: callers +/// own the warn-and-continue posture and the wording. The core sweep +/// removes an emptied directory, so a fully reverted project keeps none +/// of the three. Shared by rollback's GC, remove's post-removal sweep and +/// repair's cleanup phase. +pub(crate) struct ArtifactSweep { + pub(crate) blobs: std::io::Result, + pub(crate) diffs: std::io::Result, + pub(crate) packages: std::io::Result, +} + +pub(crate) async fn sweep_unused_artifacts( + reference: &PatchManifest, + socket_dir: &Path, + dry_run: bool, +) -> ArtifactSweep { + ArtifactSweep { + blobs: cleanup_unused_blobs(reference, &socket_dir.join("blobs"), dry_run).await, + diffs: cleanup_unused_archives(reference, &socket_dir.join("diffs"), dry_run).await, + packages: cleanup_unused_archives(reference, &socket_dir.join("packages"), dry_run).await, + } +} + +/// The `cleanup_failed` detail for one sweep pass labelled `label`: the +/// directory-level error that stopped the pass, or — after a pass that +/// kept sweeping past unlink failures — the files it could not remove +/// (their counts of what WAS reclaimed still stand). `None` for a clean +/// pass. Every consumer renders it as `