diff --git a/CHANGELOG.md b/CHANGELOG.md index b1aea764..5fd8d2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,96 @@ 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 +> per CLI_CONTRACT.md's semver policy — so it ships as the next major +> release (v5.0). + +### Changed (BREAKING) + +- **`rollback` is now the full-state dual of `scan`.** `scan` and `rollback` + are the batch primaries (`get`↔`remove` stay the single-patch duals): a + bare `rollback` restores the SYSTEM to unpatched across all three modes — + in-place file restore (agent), vendored unwire + artifact deletion + + ledger-entry drop, hosted lockfile-redirect unwind + redirect-record drop + — then removes the rolled-back entries from `.socket/manifest.json` and + 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 + 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 + M vendored artifact(s)?" — auto-accepted under `--yes`/`--json`/non-TTY; + declining prints "Rollback cancelled." and exits 0). Drift-keeps, hosted + refusals/unsupported targets, corrupt ledgers, and a failed manifest + write exit 1 `partial_failure`; not-installed entries still exit 0. +- **`rollback --json`'s `vendored: []` array narrows** to vendor-owned purls + the run did NOT act on (today: the corrupt-vendor-ledger skip). Acted-on + entries move to the new always-present `vendoredReverted` / + `vendoredPreserved` / `vendoredKept` arrays; the envelope also gains + always-present `warnings[]` (`{code, detail}`, now populated), `hosted` + (`{reverted, failed, unsupported, editedFiles}`), `manifest` + (`{removedEntries, preserved}`), `gc`, and `paths` keys. + +### Added + +- **Path targeting on `scan` and `rollback`.** `scan [PATHS]...` scopes + discovery to packages with an installed copy under a matching glob + (ancestor rule: `scan packages/foo` covers the subtree; `*` never crosses + `/`; absolute patterns are the only way to reach `--global` stores); the + prune universe is never narrowed (`scan PATHS --prune` prunes exactly + what an unscoped run would), lockfile-only/vendor-ledger supplements are + excluded with a `path_scope_excluded_supplements` warning, an empty match + is a normal empty scan (exit 0, no GC), and PATHS is rejected with + `--mode hosted|vendored` (exit 2). `rollback [TARGET]...` accepts + PURLs, UUIDs, and path globs (variadic, unioned); only path-SHAPED tokens + (separator, glob metachar, `./` prefix, absolute) become globs, so a + mistyped identifier stays a safe exit-1 error. A path target selecting + nothing is an error on rollback (exit 1) and an empty scan on scan + (exit 0); path targets select installed copies, and rollback restores + EVERY installed copy of a selected patch (`out_of_scope_copies_restored` + warning when copies live outside the patterns). +- **`--preserve-state` on `rollback` and `remove`** (env + `SOCKET_PRESERVE_STATE`): fully unpatch the system but keep the local + state for a later re-apply — manifest entries, vendored artifacts + + ledger entries (kept byte-identical; re-vendor re-wires from the live + lock) — and skip all GC. Hosted redirects have no preservable state: + they are unwound and their records dropped either way + (`hosted_state_not_preservable` warning). On `remove`, combining it with + `--skip-rollback` is a usage error (exit 2, flag- or env-sourced): the + combination would be a no-op — one flag keeps the tree and drops the + state, the other restores the tree and keeps the state. +- **Hosted redirect unwind.** Per-purl reverts for cargo + the npm family, + plus a whole-ledger reverse replay (core `patch/redirect/replay.rs`) that + runs whenever the scope covers every redirect record: a per-kind inverse + table, staged all-or-nothing per ecosystem group, covering gem, golang, + pypi, composer, bun, and the non-package rideshare edits (pnpm + `trustLockfile` auto-config — pristine scaffold deleted, modified + scaffold keeps the file and loses only the owned line). The bun.lockb + migration is unrestorable by design (warning names git history); + maven and nuget fail closed with `hosted_revert_unsupported` guidance + (their structured-metadata edits keep their ledger records; re-run + `scan --mode hosted` or restore from VCS). Refused groups keep their + edits AND records — the coherent ledger a retry needs. +- **`remove` gains the hosted leg and full archive GC**: an identifier + matching hosted redirect-ledger records unwinds those redirects (per-purl + or via the replay when it covers the full record set; works manifest-less + on hosted-only projects; unsupported ecosystems fail closed with + `hosted_revert_unsupported` before the manifest mutation), and remove's + default GC extends from blobs-only to blobs + diff + package archives + (parity with rollback/repair/`scan --prune`). + +### Fixed + +- **`remove` no longer drops the manifest entry of a drift-kept vendored + purl.** When the vendored revert keeps the artifact (`kept_artifact` — + the lockfile drifted), the manifest entry is now kept too + (`skipped`/`vendor_revert_kept`), matching the core RevertOutcome + contract; previously the entry was deleted, stranding a live ledger + entry with no backing record. An all-kept run exits 1 `partialFailure` + with `summary.removed: 0` (never `not_found` — the identifier matched). + ## [4.0.0] — 2026-08-20 v4.0 is the three-modes release. What began as an agent-style tool that diff --git a/Cargo.lock b/Cargo.lock index 35b87c9d..e790bce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "h2" version = "0.4.14" @@ -1682,6 +1688,7 @@ dependencies = [ "dialoguer", "flate2", "fs2", + "glob", "hex", "indicatif", "libc", diff --git a/Cargo.toml b/Cargo.toml index 45e955ca..bae07947 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ dialoguer = "=0.11.0" indicatif = "=0.17.11" tempfile = "=3.26.0" regex = "=1.12.3" +glob = "=0.3.4" toml_edit = "=0.25.12" once_cell = "=1.21.3" qbsdiff = "=1.4.4" diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index c5ce2830..f6f5d63f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -13,7 +13,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | | `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | -| `rollback` | — | Restore original files; takes optional positional `identifier` | +| `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) | | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | @@ -72,6 +72,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest | | `apply`, `scan`, `vendor` | `--vex` | `SOCKET_VEX` | Generate an OpenVEX 0.2.0 document at this path on a successful run; see "embedded VEX" below | | `apply`, `scan`, `vendor` | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | Passthrough to the embedded VEX builder; mirror the standalone `vex` knobs. Inert unless `--vex` is set | +| `scan` | positional `[PATHS]...` | — | (v5.0) Optional path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). Purl-level: a package is in scope when ANY of its installed copies sits under a matching path. Rejected with `--mode hosted`/`--mode vendored` (exit 2, `resolve_mode_flags` — their lockfile rewiring is whole-project by construction); combines with `--apply`/`--sync`/`--prune`. See "Path-scoped scans" below | | `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` | @@ -79,8 +80,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `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) | -| `remove` | positional `identifier`; `--skip-rollback` | `SOCKET_SKIP_ROLLBACK` | Manifest entry removal | -| `rollback` | optional positional `identifier`; `--one-off` | `SOCKET_ONE_OFF` | Rollback target | +| `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | +| `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--one-off`; `--preserve-state` (v5.0) | `SOCKET_ONE_OFF`, `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | | `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | | `setup` | `--check`, `--remove` (mutually exclusive); `--exclude` (CSV member paths); honors global `--ecosystems` | `SOCKET_SETUP_EXCLUDE`, `SOCKET_ECOSYSTEMS` | Wire / verify / revert the automatic-patching install hooks. `--exclude` skips + persists workspace members (property 9). See [Setup command contract](#setup-command-contract) | @@ -91,7 +92,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments **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; no npm/yarn hosted revert exists); 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. `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` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview). @@ -103,9 +104,11 @@ Beyond the globals above, each subcommand defines a small set of local arguments `scan --sync` is sugar for `--apply --prune` — the canonical single-flag bot invocation. `scan --json --sync --yes` discovers, applies, and reconciles state in one pass. +**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` without network downloads or disk writes. 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/rollback/repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove ` (which reverts them) or `vendor --revert`. Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely. +`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. `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. @@ -125,7 +128,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * **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). -`--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) 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. +`--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) 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. 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. @@ -629,13 +632,43 @@ worse, lets a warm cache silently serve unpatched bytes): entries deleted") before deleting the manifest entry; a revert failure (`vendor_revert_failed`) aborts with the manifest intact. `--skip-rollback` ("don't touch my tree") skips the revert too (`skipped`/`vendor_state_retained`) — the wiring then stays until the next `vendor` run - reconciles the dropped entry. 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). -* **rollback excludes vendored purls**: their patch lives in the committed artifact, not the - installed tree, so in-place restore is meaningless. The benign skip is surfaced in rollback's - JSON as the additive `vendored: [purls]` array (exit 0; an identifier matching only vendored - purls is a success, not `not_found`). + reconciles the dropped entry. `--preserve-state` (v5.0) unwires the lockfile but keeps the + 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, + 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 + entry with no backing record. A run where EVERY matching entry drift-kept exits 1 with + `status: partialFailure` and top-level error `vendor_revert_kept` (`summary.removed` honest at + 0) — NOT `not_found`, which stays reserved for identifier-matches-nothing. `remove`'s default + GC also extends (v5.0, additive) from blobs-only to blobs + diff archives + package archives + (parity with rollback/repair/`scan --prune`; GC errors warn and continue, repair's posture). +* **remove unwinds hosted redirects (v5.0)**: an identifier matching hosted records in the + 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 + 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 + envelope as `removed`/`hosted_reverted` events (bypassing `summary.removed`, like + `vendor_reverted`). `--skip-rollback` leaves hosted wiring untouched; `--preserve-state` still + unwinds — hosted has no preservable local state (a stderr note says the records were dropped). +* **rollback reverts vendored and hosted state by default (v5.0, MAJOR — was: excluded)**: the + agent leg still excludes vendor-owned purls from IN-PLACE restore (their patch lives in the + committed artifact, not the installed tree, so before-blob restoration is meaningless), but a + v5.0 `rollback` then unwires those purls through its vendored leg and unwinds hosted redirects + through its hosted leg — `remove ` and `vendor --revert` are no longer the only exits + from vendored/hosted state. The JSON `vendored: []` array's meaning NARROWS accordingly (MAJOR): + it now lists only vendor-owned purls the run did NOT act on (today: the corrupt-vendor-ledger + skip — reserved-empty in v5.0, since naming skipped purls needs the very ledger that failed + to load); acted-on entries land in the new `vendoredReverted`/`vendoredPreserved`/`vendoredKept` + arrays. An identifier matching only vendored purls is still a success, not `not_found`. See + [Rollback command contract](#rollback-command-contract-v50). * **apply yields to vendor — every ecosystem**: a purl recorded in the ledger is skipped by `apply` with reason `vendored`, even when the installed tree is absent entirely (never `package_not_installed`; a vendored variant also accounts for its qualified release-variant @@ -673,6 +706,68 @@ worse, lets a warm cache silently serve unpatched bytes): * `vendor` exits like `apply`: 0 on success (benign skips included), 1 on any refusal/failure (`partialFailure`), 2 on usage errors. `--dry-run` verifies and writes nothing. +## Rollback command contract (v5.0) + +> **Semver note.** v5.0 changes `rollback`'s DEFAULT behavior (a default-value/behavior change → **MAJOR** per the [semver policy](#semver-policy)) and narrows the meaning of the existing `vendored: []` JSON key (**MAJOR**). Every new envelope key, flag, and warning code below is additive on top of that. + +`rollback` and `scan` are now the batch-level duals — `scan` moves the project toward "fully patched", `rollback` toward "fully unpatched" — the way `get` and `remove` are the single-patch duals. `rollback` needs no `--mode`: it infers what to undo from the three state stores (`.socket/manifest.json` = agent/in-place, `.socket/vendor/state.json` = vendored, `.socket/vendor/redirect-state.json` = hosted). + +### Targets + +`rollback [TARGET]...` — zero or more targets, unioned. `pkg:` tokens are PURLs (base purl matches every release variant; qualified purl exact), other identifier-shaped tokens are UUIDs, and only **path-shaped** tokens (separator, glob metachar `*?[`, `./` prefix, or absolute) are path globs — see the per-subcommand args table for the safety rationale. Identifier matching runs across ALL THREE stores; an identifier matching nothing anywhere is the familiar exit-1 error. Path globs use the same matcher as `scan [PATHS]` (ancestor rule, `require_literal_separator`, absolute-only outside `--cwd`, Windows case-insensitive): installed copies of every candidate purl are discovered and purls with ≥ 1 matching copy are selected. Scoping sentences (shared with scan): + +* **A target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0).** Each rollback path pattern must select at least one patched package; the error names the pattern and the reachability rule. +* **Path targets select installed copies; entries with no installed copy are reachable only by identifier or unscoped runs.** +* **Rollback restores every installed copy of a selected patch** — patches are tracked per-package, not per-path; copies restored outside the given patterns are surfaced as an `out_of_scope_copies_restored` warning, never skipped. + +`--ecosystems` narrows every leg. `--one-off` still requires ≥ 1 identifier-shaped target and still fails "not yet implemented" before any network or disk activity. + +### Default behavior: full-state rollback (MAJOR) + +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. +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). + +### `--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. + +### Hosted unwind coverage + +* **Per-purl reverts** exist for **cargo and the npm family** (`redirect_revert_supported`): staged, fail-closed on drift, and honoring `dry_run` (every inverse and drift check resolves like a wet run; nothing flushes and the ledger is untouched). npm purls on projects with bun-lock edits DEFER to the replay (below) instead of failing, whenever the replay will run. +* **Whole-ledger reverse replay** (`revert_remaining_redirect_edits`, core `patch/redirect/replay.rs`) runs whenever the in-scope hosted record set equals the FULL ledger record set — however the scope was spelled (bare `rollback`, `rollback '**'`, an identifier set covering every record; `remove` reuses the same eligibility rule). It walks every remaining ledger edit in reverse write order through a **per-kind inverse table**, staged and committed **per ecosystem group, all-or-nothing**: one drifted, ambiguous (a fragment appearing more than once), or unhandled edit refuses the whole group byte-untouched while other groups proceed. This covers **gem, golang, pypi, composer, bun**, the yarn/pnpm text kinds (normally claimed by the per-purl npm revert first), and the **non-package rideshare edits** — the pnpm `trustLockfile` auto-config (a pristine created scaffold is deleted; a user-modified one keeps the file and loses only the `trustLockfile: true` line, warned as `redirect_pnpm_trust_scaffold_modified`) — plus a "last one out turns off the lights" pass: when the record map empties but non-package edits remain, they are replayed in the same persist, so the trust edit never strands. The **bun.lockb migration marker is unrestorable by design** (the binary original was never captured): it warns `redirect_bun_lockb_unrestorable` naming git history as the restore path and never blocks its group. +* **maven and nuget fail closed**: their structured-metadata kinds (`redirect_maven_repository` / `redirect_maven_dep_management` / `redirect_maven_config` / `redirect_maven_trusted_checksums`, `redirect_nuget_source` / `redirect_nuget_lock`) have no revert implementation, so any such edit refuses its whole group (the maven `` suffix rewrite alone IS invertible, but it rides the same all-or-nothing group). The refusal keeps their records + edits in the ledger and names the remedy: re-run `scan --mode hosted` to normalize, or restore the lockfiles from version control. Unknown future kinds refuse the same way (forward-compat). +* **Scoped runs** (paths / identifiers / `--ecosystems`) that do NOT cover the full record set get per-purl reverts only; in-scope hosted purls of ecosystems without one fail closed — `rollback` reports them in `hosted.unsupported` (exit 1), `remove` as the top-level `hosted_revert_unsupported` error — with the remedy "run an unscoped `socket-patch rollback` to unwind ALL hosted redirects, or re-run `scan --mode hosted`". +* **Ledger accounting**: exactly the replayed (or already-at-original) edits are dropped; a record is dropped only when every group its ecosystem writes ended clean, so refused groups keep both edits and records — the intermediate-but-coherent ledger a retry needs. The mutated ledger is persisted (delete-when-empty); a failed persist rides `hosted.failed` / `hosted_revert_failed`. + +### JSON envelope (legacy shape + additive always-present keys) + +`rollback --json` keeps its legacy top-level shape (`status` — `"success"` \| `"partial_failure"` — `rolledBack`, `alreadyOriginal`, `failed`, `dryRun`, `results[]`) and adds these keys, ALL always present so consumers never null-check: + +| 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_bun_lockb_unrestorable`, `redirect_pnpm_trust_scaffold_modified`, 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 | +| `vendoredKept` | `[{purl, reason}]` | Drift-keeps — wiring drifted, vendored state (and the manifest entry) left untouched; drives exit 1 | +| `vendoredFailed` | `[{purl, error}]` | Vendored reverts that errored — entry, artifact, and manifest record all survive for a retry; drives exit 1 | +| `hosted` | `{reverted: [purl], failed: [{purl, error}], unsupported: [purl], editedFiles: N}` | The hosted leg. `failed` entries may carry a `group:` pseudo-purl for whole-group replay refusals; `unsupported` lists scoped purls with no per-purl revert; `editedFiles` counts distinct files rewritten | +| `manifest` | `{removedEntries: [purl], preserved: bool}` | Entries removed from the manifest (would-be removals on dry-run); `preserved` mirrors `--preserve-state` | +| `gc` | `{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}` | Skipped under `--preserve-state`, after a blob-gate abort, and under a corrupt vendor ledger | +| `paths` | `[string]` | The path-glob targets verbatim (empty when none) | + +**Exit rules**: not-installed entries never flip the exit (the documented apply/rollback asymmetry — even an all-not-installed run exits 0 `success`). Everything that leaves the system still patched DOES flip it to `partial_failure` exit 1: agent-leg failures, vendored drift-keeps and revert failures, hosted refusals and scoped-unsupported targets, corrupt ledgers, and a failed manifest write. GC failures never affect the exit. + ## Self-update contract (`socket-patch --update`) `socket-patch --update [VERSION]` replaces the running binary with a release from `https://github.com/SocketDev/socket-patch/releases` — the same artifacts, `SHA256SUMS` verification, and asset naming `install.sh` uses. It is for **standalone installs** (install.sh, manual tarball copy); every other channel is refused with that channel's own upgrade command. @@ -761,9 +856,10 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `100` | Local to `scan`. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | -| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity. | +| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | | `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | -| `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. | +| `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. Conflicts with `--preserve-state`/`SOCKET_PRESERVE_STATE` (exit 2 — see below). | +| `SOCKET_PRESERVE_STATE` | `rollback --preserve-state` / `remove --preserve-state` | `false` | (v5.0) Shared by `rollback`/`remove` (boolish, empty-tolerant parse like the other bool flags): restore the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — and skip all GC. On `remove`, combining it with `--skip-rollback` is a usage error (exit 2) **whether either side is flag- or env-sourced** (`SOCKET_PRESERVE_STATE=true remove --skip-rollback` exits 2 too). | | `SOCKET_DOWNLOAD_ONLY` | `repair --download-only` | `false` | Local to `repair`. | | `SOCKET_SETUP_EXCLUDE` | `setup --exclude` | (none) | Local to `setup`; comma-separated workspace-member paths, persisted to `setup.exclude`. | | `SOCKET_VEX` | `apply --vex` / `scan --vex` / `vendor --vex` | (none) | Embedded OpenVEX output path. The `SOCKET_VEX_*` knobs (`_PRODUCT`, `_NO_VERIFY`, `_DOC_ID`, `_COMPACT`) are shared with the standalone `vex` command; on the host commands they bind to `--vex-product` etc. | @@ -921,12 +1017,26 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `no_local_source` | `skipped`/`failed` | `--offline` and the patch is missing from `.socket/`. | | `paid_required` | `failed` / status=`paidRequired` | get/scan: patch needs a paid plan and the caller's token isn't entitled. | | `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | -| `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). | +| `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). v5.0: `rollback`'s default GC surfaces the same condition in its run-level `warnings[]` (and `remove`'s extended archive GC on stderr) — same posture, never affects the exit. | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | -| `vendored` | `skipped` | apply (every ecosystem) + scan `--apply`: the package is managed by `socket-patch vendor`; the command yields ownership (scan also skips the download). Rollback surfaces the same skip via its `vendored: []` array. Scan `--apply --json` additionally surfaces one run-level `vendored_ownership_retained` warning naming the skipped purls (additive; exit/status unchanged). | +| `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_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`. | +| `hosted_revert_unsupported` | top-level error | remove (v5.0): the identifier matches hosted records of an ecosystem with no per-purl revert (and the identifier does not cover the full record set, so the whole-ledger replay cannot serve it — maven/nuget always land here scoped, as do npm purls a refused replay left behind). The manifest was not modified; exit 1. Remedy: unscoped `socket-patch rollback`, or re-run `scan --mode hosted`. Rollback reports the same condition in its `hosted.unsupported` array (exit 1). | +| `hosted_revert_failed` | top-level error | remove (v5.0): a per-purl hosted unwind, group replay, or redirect-ledger persist failed; the manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). | +| `reinstall_required` | rollback `warnings[]` | rollback (v5.0): vendored/hosted wiring was unwound, but installed trees keep their patched bytes until the next package-manager install — the stale-install advisory. | +| `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). | +| `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_bun_lockb_unrestorable` | rollback/remove `warnings[]` | hosted replay (v5.0): the ledger records a bun.lockb→bun.lock migration whose binary original was never captured; restore bun.lockb from git history if the binary format is required. Never blocks its group. | +| `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. | | `vendor_unsupported_ecosystem` | `skipped` | vendor: no vendor backend for this purl's ecosystem (jsr). | | `already_vendored` | `skipped` | vendor: artifact + wiring already in sync for this patch uuid. | @@ -961,7 +1071,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). | +| `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_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. | @@ -993,7 +1103,7 @@ The remaining commands still emit their pre-v3.0 ad-hoc JSON shapes and will mig - ⏳ `scan` — still emits the discovery + `apply.patches[*]` + `gc.*` shape documented in earlier drafts of this file. - ⏳ `get` — still emits per-patch action arrays. -- ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1-on-unmatched). +- ⏳ `rollback` — still emits per-package result records. Additive (v3.5): a manifest entry with no matching installed package appears in `results[]` as a marker record `{ "purl", "path": null, "skipped": "package_not_installed" }` — no `success`/`error` keys, never counted in `rolledBack`/`failed`, never flips the status or exit code (rollback's job is "make the tree unpatched"; a not-installed package already satisfies that end state, deliberately asymmetric with apply's exit-1-on-unmatched). v5.0 keeps that legacy shape and adds the ALWAYS-PRESENT keys `warnings[]` (`{code, detail}` objects, now populated), `vendored` (meaning narrowed — MAJOR), `vendoredReverted`, `vendoredPreserved`, `vendoredKept` (`{purl, reason}`), `hosted` (`{reverted, failed: [{purl, error}], unsupported, editedFiles}`), `manifest` (`{removedEntries, preserved}`), `gc` (`{skipped: true}` \| `{removedBlobs, removedDiffArchives, removedPackageArchives, bytesFreed}`), and `paths` — full key semantics and exit rules in the [Rollback command contract](#rollback-command-contract-v50). - ⏳ `setup` — still emits its own `{ status, updated, alreadyConfigured, errors, files }` shape (and the `--check` / `--remove` variants), now documented in full under [Setup command contract](#setup-command-contract). One command is **intentionally not** plain-envelope and will stay that way (not migration debt): @@ -1198,7 +1308,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `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`), `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 | +| `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`. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 06abdd21..9075a09d 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -26,6 +26,7 @@ dialoguer = { workspace = true } indicatif = { workspace = true } uuid = { workspace = true } regex = { workspace = true } +glob = { workspace = true } tempfile = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 33cf0ade..3953225b 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -409,6 +409,7 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", "SOCKET_SKIP_ROLLBACK", + "SOCKET_PRESERVE_STATE", "SOCKET_DOWNLOAD_ONLY", "SOCKET_SETUP_EXCLUDE", "SOCKET_VENDOR_REVERT", @@ -1202,6 +1203,9 @@ mod tests { ("SOCKET_ALL_RELEASES", &["socket-patch", "get", "x"]), ("SOCKET_ALL_RELEASES", &["socket-patch", "scan"]), ("SOCKET_SKIP_ROLLBACK", &["socket-patch", "remove", "x"]), + // Shared by rollback and remove, like SOCKET_ONE_OFF above. + ("SOCKET_PRESERVE_STATE", &["socket-patch", "rollback"]), + ("SOCKET_PRESERVE_STATE", &["socket-patch", "remove", "x"]), ("SOCKET_DOWNLOAD_ONLY", &["socket-patch", "repair"]), ("SOCKET_VENDOR_REVERT", &["socket-patch", "vendor"]), ("SOCKET_VEX_NO_VERIFY", &["socket-patch", "vex"]), diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 9cfd2235..e6c4916b 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,18 +1,21 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result}; +use socket_patch_core::manifest::cleanup_blobs::{ + cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, +}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest}; +use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; -use socket_patch_core::utils::purl::purl_matches_identifier; +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 std::time::Duration; use super::get::short_uuid; -use super::rollback::{all_files_already_original, rollback_patches}; -use super::vendor::dispatch_revert_one; +use super::rollback::{all_files_already_original, pin_before_hash_blobs, rollback_patches}; +use super::vendor::{dispatch_revert_one, dispatch_revert_one_opts}; 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; @@ -107,10 +110,38 @@ pub struct RemoveArgs { value_parser = crate::args::parse_bool_flag, )] pub skip_rollback: bool, + + /// Restore the system (files and lockfiles) but PRESERVE the local + /// patch state for a later re-apply: the manifest entry is kept, + /// vendored artifacts and their ledger entries are kept (only the + /// lockfile wiring is reverted), and no blob/archive cleanup runs — + /// the single-patch twin of `rollback --preserve-state`. Conflicts + /// with `--skip-rollback` (keeping the tree AND the state would be a + /// no-op). + #[arg( + long = "preserve-state", + env = "SOCKET_PRESERVE_STATE", + default_value_t = false, + value_parser = crate::args::parse_bool_flag, + )] + pub preserve_state: bool, } pub async fn run(args: RemoveArgs) -> i32 { apply_env_toggles(&args.common); + + // Self-enforced usage error (exit 2, like scan's mode conflicts): + // `--skip-rollback` keeps the tree and drops the state, + // `--preserve-state` restores the tree and keeps the state — together + // they select the do-nothing quadrant. + if args.preserve_state && args.skip_rollback { + eprintln!( + "error: --preserve-state cannot be used with --skip-rollback: the \ + combination would be a no-op (nothing would change)" + ); + return 2; + } + let (telemetry_client, _) = get_api_client_with_overrides(args.common.api_client_overrides()).await; let api_token = telemetry_client.api_token().cloned(); @@ -134,7 +165,21 @@ pub async fn run(args: RemoveArgs) -> i32 { .any(|(_, e)| e.detached) }) .unwrap_or(false); - if !has_detached_match { + // 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 { emit_error_envelope( args.common.json, args.common.dry_run, @@ -225,6 +270,33 @@ pub async fn run(args: RemoveArgs) -> i32 { .await; } + // Hosted-only patches likewise have no manifest entry — the + // redirect ledger is their only persistence, and `remove` is + // 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 !hosted_matches.is_empty() { + return remove_hosted_only( + &args, + hosted_matches, + redirect_state, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + } + } + emit_not_found( args.common.json, args.common.dry_run, @@ -267,7 +339,14 @@ pub async fn run(args: RemoveArgs) -> i32 { // `--dry-run` previews without mutating, so there is nothing to // confirm — skip the prompt (matching the global contract row: // "Preview, no mutations"). - let prompt = format!("Remove {} patch(es) and rollback files?", matching.len()); + let prompt = if args.preserve_state { + format!( + "Rollback files for {} patch(es)? (patch records will be preserved)", + matching.len() + ) + } else { + 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 { println!("Removal cancelled."); @@ -392,6 +471,12 @@ pub async fn run(args: RemoveArgs) -> i32 { // 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(); if !vendored_matches.is_empty() { if args.skip_rollback { for (key, _) in &vendored_matches { @@ -410,8 +495,15 @@ pub async fn run(args: RemoveArgs) -> i32 { } } else { for (key, entry) in &vendored_matches { - let outcome = - dispatch_revert_one(entry, &args.common.cwd, args.common.dry_run).await; + 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); @@ -440,9 +532,34 @@ pub async fn run(args: RemoveArgs) -> i32 { ); 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 { - println!("Would revert vendoring for {key}"); + 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. @@ -454,6 +571,22 @@ pub async fn run(args: RemoveArgs) -> i32 { ); 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( @@ -475,20 +608,191 @@ pub async fn run(args: RemoveArgs) -> i32 { } } + // 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. + 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 { + Err(e) => { + if !args.common.silent && !args.common.json { + eprintln!( + "Warning: cannot read the hosted redirect ledger ({e}); hosted \ + redirects were not examined" + ); + } + } + 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(); + 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, + ) + .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 + { + eprintln!( + "Note: hosted redirects have no preservable local state; \ + their ledger records were dropped with the unwound wiring." + ); + } + let hosted_action = if args.common.dry_run { + PatchAction::Verified + } else { + PatchAction::Removed + }; + for purl in &leg.reverted { + hosted_reverted_events.push( + PatchEvent::new(hosted_action, purl.clone()).with_reason( + "hosted_reverted", + "hosted lockfile redirect unwound on remove", + ), + ); + } + } + } + } + } + + // 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 + .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)) + }) + }) + .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. - let removal = if args.common.dry_run { - let removed: Vec = matching.iter().map(|(purl, _)| (*purl).clone()).collect(); + // 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)) } else { - remove_patch_from_manifest(&args.identifier, &manifest_path).await + remove_patch_from_manifest(&args.identifier, &manifest_path, &excluded_kept).await }; match removal { Ok((removed, updated_manifest)) => { - if removed.is_empty() { + 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, @@ -501,7 +805,13 @@ pub async fn run(args: RemoveArgs) -> i32 { } if !args.common.json && !args.common.silent { - if args.common.dry_run { + 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()); @@ -511,7 +821,7 @@ pub async fn run(args: RemoveArgs) -> i32 { } if args.common.dry_run { println!("\nDry run — nothing was changed."); - } else { + } else if !args.preserve_state { println!("\nManifest updated at {}", manifest_path.display()); } } @@ -550,44 +860,54 @@ pub async fn run(args: RemoveArgs) -> i32 { // data only — the retained entries' real afterHash blobs stay // sweepable like any other orphan. let mut cleanup_reference = updated_manifest.clone(); - for purl in &retained_not_installed { - let Some(record) = manifest.patches.get(*purl) else { - continue; - }; - let pinned: std::collections::HashMap = record - .files - .iter() - .filter(|(_, info)| !info.before_hash.is_empty()) - .map(|(file, info)| { - ( - file.clone(), - PatchFileInfo { - before_hash: String::new(), - after_hash: info.before_hash.clone(), - }, - ) - }) - .collect(); - if pinned.is_empty() { - continue; // every file was created-by-patch: no revert blobs - } - let mut keep_record = record.clone(); - keep_record.files = pinned; - cleanup_reference - .patches - .insert((*purl).to_string(), keep_record); - } + 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; - if let Ok(cleanup_result) = - cleanup_unused_blobs(&cleanup_reference, &blobs_path, args.common.dry_run).await - { - 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) - ); + 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, + ) + .await + { + Ok(r) => archives_removed += r.blobs_removed, + Err(e) => { + if !args.common.silent && !args.common.json { + eprintln!("Warning: {dir} cleanup failed: {e}"); + } + } + } } } @@ -644,6 +964,11 @@ pub async fn run(args: RemoveArgs) -> i32 { for ev in vendor_reverted_events { 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_skipped_events { env.record(ev); } @@ -667,22 +992,44 @@ pub async fn run(args: RemoveArgs) -> i32 { // 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 { + 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; + } println!("{}", env.to_pretty_json()); } if !args.common.dry_run { track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await; } - 0 + 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" } + ); + } + 1 + } } Err(e) => { track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; @@ -699,6 +1046,143 @@ pub async fn run(args: RemoveArgs) -> i32 { /// 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 +/// 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 +/// `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 +/// preservable local state. +async fn remove_hosted_only( + args: &RemoveArgs, + hosted_matches: Vec, + mut redirect_state: socket_patch_core::patch::redirect::RedirectState, + api_token: Option<&str>, + org_slug: Option<&str>, +) -> i32 { + if args.skip_rollback { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "hosted_state_retained", + format!( + "{} matches only hosted redirect record(s); removing one means unwinding \ + its lockfile redirect, which --skip-rollback prevents", + args.identifier + ), + ); + return 1; + } + + if !args.common.json && !args.common.silent { + eprintln!("The following hosted redirect(s) will be unwound and removed:"); + for purl in &hosted_matches { + eprintln!(" - {purl}"); + } + eprintln!(); + } + // `--dry-run` previews without mutating — nothing to confirm. + let prompt = format!( + "Remove {} hosted redirect(s) and unwind their lockfile wiring?", + hosted_matches.len() + ); + if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.json && !args.common.silent { + 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}"), + ); + 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 { + PatchAction::Verified + } else { + PatchAction::Removed + }; + // 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", + ), + ); + } + if args.common.json { + println!("{}", env.to_pretty_json()); + } + if !args.common.dry_run { + track_patch_removed(leg.reverted.len(), api_token, org_slug).await; + } + 0 +} + async fn remove_detached_only( args: &RemoveArgs, detached: Vec<(String, VendorEntry)>, @@ -814,6 +1298,9 @@ async fn remove_detached_only( 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 @@ -823,7 +1310,9 @@ async fn remove_patch_from_manifest( let removed: Vec = manifest .patches .iter() - .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, identifier)) + .filter(|(purl, patch)| { + patch_matches(purl, &patch.uuid, identifier) && !exclusions.contains(*purl) + }) .map(|(purl, _)| purl.clone()) .collect(); @@ -891,7 +1380,7 @@ mod tests { write_multi_variant(tmp.path()).await; let manifest_path = tmp.path().join("manifest.json"); - let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path) + let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path, &Default::default()) .await .expect("remove ok"); @@ -909,7 +1398,7 @@ mod tests { let manifest_path = tmp.path().join("manifest.json"); let (removed, manifest) = - remove_patch_from_manifest("pkg:pypi/six@1.16.0?artifact_id=sdist", &manifest_path) + remove_patch_from_manifest("pkg:pypi/six@1.16.0?artifact_id=sdist", &manifest_path, &Default::default()) .await .expect("remove ok"); @@ -927,7 +1416,7 @@ mod tests { write_multi_variant(tmp.path()).await; let manifest_path = tmp.path().join("manifest.json"); - let (removed, manifest) = remove_patch_from_manifest("uuid-cp312", &manifest_path) + let (removed, manifest) = remove_patch_from_manifest("uuid-cp312", &manifest_path, &Default::default()) .await .expect("remove ok"); @@ -954,7 +1443,7 @@ mod tests { .await .expect("write manifest"); - let (removed, manifest) = remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path) + let (removed, manifest) = remove_patch_from_manifest("pkg:npm/foo@1.0", &manifest_path, &Default::default()) .await .expect("remove ok"); @@ -975,7 +1464,7 @@ mod tests { 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) + remove_patch_from_manifest("pkg:npm/not-here@9.9.9", &manifest_path, &Default::default()) .await .expect("remove ok"); @@ -1011,7 +1500,7 @@ mod tests { .await .expect("write manifest"); - let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path) + let (removed, manifest) = remove_patch_from_manifest("pkg:pypi/six@1.16.0", &manifest_path, &Default::default()) .await .expect("remove ok"); diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index c571531c..c9f211d8 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2,7 +2,10 @@ 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::operations::{get_before_hash_blobs, read_manifest}; +use socket_patch_core::manifest::cleanup_blobs::{cleanup_unused_archives, cleanup_unused_blobs}; +use socket_patch_core::manifest::operations::{ + get_before_hash_blobs, read_manifest, write_manifest, +}; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::select_installed_variants; use socket_patch_core::patch::rollback::{ @@ -21,11 +24,73 @@ use crate::commands::lock_cli::acquire_or_emit; use crate::commands::remove::patch_matches; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; +use crate::looks_like_uuid; + +/// Pin the beforeHash blobs of `purls` into `reference` as synthetic keep +/// records: `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 pinned entries' real afterHash blobs stay sweepable like any +/// other orphan. Shared by rollback's default GC and `remove`'s +/// crawler-miss guard. +pub(crate) fn pin_before_hash_blobs<'a>( + reference: &mut PatchManifest, + source: &PatchManifest, + purls: impl IntoIterator, +) { + for purl in purls { + let Some(record) = source.patches.get(purl) else { + continue; + }; + let pinned: HashMap = record + .files + .iter() + .filter(|(_, info)| !info.before_hash.is_empty()) + .map(|(file, info)| { + // A synthetic key so pins never clobber the real file rows + // of an entry that REMAINS in the reference (whose afterHash + // blobs must stay kept). The sweep reads only the hash + // VALUES, never the keys, and this reference manifest is + // in-memory only. + ( + format!("{file}#beforeHash-pin"), + PatchFileInfo { + before_hash: String::new(), + after_hash: info.before_hash.clone(), + }, + ) + }) + .collect(); + if pinned.is_empty() { + continue; // every file was created-by-patch: no revert blobs + } + if let Some(existing) = reference.patches.get_mut(purl) { + existing.files.extend(pinned); + } else { + let mut keep_record = record.clone(); + keep_record.files = pinned; + reference.patches.insert(purl.clone(), keep_record); + } + } +} #[derive(Args)] pub struct RollbackArgs { - /// Package PURL or patch UUID to rollback. Omit to rollback all patches. - pub identifier: Option, + /// What to roll back: a package PURL, a patch UUID, or a path glob + /// (e.g. `packages/foo`, `apps/**`) selecting the patches whose + /// installed copies live under matching paths. Multiple targets union. + /// Omit to roll back ALL patch state — in-place patches, vendored + /// patches, and hosted lockfile redirects. + /// + /// A token counts as a path only when it is path-shaped (contains a + /// separator or a glob metacharacter, or is `./`-prefixed/absolute); + /// anything else keeps the PURL/UUID identifier semantics, so a + /// mistyped identifier stays a safe error rather than becoming a path + /// scope. Path targets select installed copies — manifest entries with + /// no installed package are reachable only by identifier or unscoped + /// runs. Rollback restores EVERY installed copy of a selected patch: + /// patches are tracked per-package, not per-path. + pub targets: Vec, #[command(flatten)] pub common: GlobalArgs, @@ -45,6 +110,51 @@ pub struct RollbackArgs { value_parser = parse_bool_flag, )] pub one_off: bool, + + /// Restore the system (files and lockfiles) but PRESERVE the local + /// patch state for a later re-apply: manifest entries are kept, + /// vendored artifacts and their ledger entries are kept (only the + /// lockfile wiring is reverted), and no blob/archive cleanup runs. + /// Hosted redirects have no preservable local state — their ledger + /// records describe live wiring and are dropped with it either way. + #[arg( + long = "preserve-state", + env = "SOCKET_PRESERVE_STATE", + default_value_t = false, + value_parser = parse_bool_flag, + )] + pub preserve_state: bool, +} + +/// One classified rollback target token. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum RollbackTarget { + /// PURL or UUID — today's `patch_matches` semantics. + Identifier(String), + /// A path glob scoping the run to patches with an installed copy + /// under a matching path. + PathGlob(String), +} + +/// Shape-classify a target token. Only path-SHAPED tokens become globs +/// (separator, glob metachar, `./` prefix, or absolute); `pkg:` and every +/// other bare word keep identifier semantics, so a truncated UUID or a +/// package name typed without its `pkg:` prefix stays a safe +/// "No patch found matching identifier" error instead of silently +/// selecting a directory subtree. +pub(crate) fn classify_target(token: &str) -> RollbackTarget { + if token.starts_with("pkg:") { + return RollbackTarget::Identifier(token.to_string()); + } + let path_shaped = token.contains('/') + || token.contains('\\') + || token.contains(['*', '?', '[']) + || Path::new(token).is_absolute(); + if path_shaped { + RollbackTarget::PathGlob(token.to_string()) + } else { + RollbackTarget::Identifier(token.to_string()) + } } struct PatchToRollback { @@ -74,6 +184,34 @@ struct RollbackOutcome { /// apply's `unmatched` twin (`package_not_installed`). Never in the /// before-blob plan, never a failed result. Sorted for determinism. 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, + /// 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, +} + +/// How `rollback_patches_inner` selects manifest entries. +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. + Identifier(Option<&'a str>), + /// 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. + Scope { + purls: &'a HashSet, + announce_empty: bool, + }, } // ── local-redirect rollback helpers (go only) ──────────────────────────────── @@ -348,15 +486,266 @@ fn missing_blob_abort_results( results } +/// Legacy top-level error emission (the pre-envelope rollback shape): +/// `{status: "error", error}` on `--json`, an `Error:` stderr line +/// otherwise. Errors print even under --silent ("errors only", never +/// "nothing"). +fn emit_rollback_error(json: bool, msg: &str) { + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": msg, + })) + .expect("serializing an in-memory JSON value cannot fail") + ); + } else { + eprintln!("Error: {msg}"); + } +} + +/// What the vendored leg did. Keys are LEDGER keys (which may differ from +/// manifest purls in qualifier spelling); each list feeds one envelope +/// array. +#[derive(Default)] +struct VendoredLegOutcome { + /// Reverted: lockfile unwired, artifact deleted, ledger entry dropped + /// (previewed on dry-run). + reverted: Vec, + /// `--preserve-state`: lockfile unwired, artifact + ledger entry kept. + preserved: Vec, + /// Drift-keeps: the backend refused to touch a drifted lock; entry, + /// artifact, and manifest record all stay (exit 1 — the system is + /// still patched). + kept: Vec<(String, String)>, + failed: Vec<(String, String)>, + warnings: Vec<(String, String)>, +} + +/// What the hosted leg did. Shared with remove's hosted leg. +#[derive(Default)] +pub(crate) struct HostedLegOutcome { + pub(crate) reverted: Vec, + pub(crate) failed: Vec<(String, String)>, + /// Scoped targets whose ecosystem has no per-purl hosted revert. + pub(crate) unsupported: Vec, + pub(crate) warnings: Vec<(String, String)>, + pub(crate) edited_files: std::collections::BTreeSet, +} + +/// Unwire the in-scope vendored entries. `preserve` keeps artifacts and +/// ledger entries (only the lockfile wiring is restored); otherwise a +/// clean revert drops the entry and saves the ledger per purl +/// (crash-consistent, like `vendor --revert`). +async fn run_vendored_leg( + common: &GlobalArgs, + keys: &[String], + state: &mut socket_patch_core::vendor::VendorState, + preserve: bool, +) -> VendoredLegOutcome { + use crate::commands::vendor::dispatch_revert_one_opts; + use socket_patch_core::vendor::{save_state, RevertOpts}; + + let mut out = VendoredLegOutcome::default(); + for key in keys { + let Some(entry) = state.entries.get(key).cloned() else { + continue; + }; + let outcome = dispatch_revert_one_opts( + &entry, + &common.cwd, + RevertOpts { + dry_run: common.dry_run, + keep_artifact: preserve, + }, + ) + .await; + for w in &outcome.warnings { + if !common.json && !common.silent { + eprintln!("Warning ({}): {}", w.code, w.detail); + } + out.warnings.push((w.code.to_string(), w.detail.clone())); + } + if !outcome.success { + let why = outcome + .error + .as_deref() + .unwrap_or("unknown error") + .to_string(); + // Errors print even under --silent. + if !common.json { + eprintln!("Failed to revert vendoring for {key}: {why}"); + } + out.failed.push((key.clone(), why)); + continue; + } + if outcome.kept_artifact { + // Drift-keep: the lock changed under us; the backend left both + // the wiring and the artifact alone. The entry (and the + // manifest record) must survive — see RevertOutcome's contract. + out.kept.push(( + key.clone(), + "lockfile wiring drifted; vendored state left untouched".to_string(), + )); + continue; + } + if common.dry_run { + if !common.json && !common.silent { + if preserve { + println!("Would unwire vendoring for {key} (artifact preserved)"); + } else { + println!("Would revert vendoring for {key}"); + } + } + if preserve { + out.preserved.push(key.clone()); + } else { + out.reverted.push(key.clone()); + } + continue; + } + if preserve { + // 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 probe. + if !common.json && !common.silent { + println!("Unwired vendoring for {key} (artifact preserved)"); + } + out.preserved.push(key.clone()); + } else { + state.entries.remove(key); + if let Err(e) = save_state(&common.cwd, state).await { + out.failed.push((key.clone(), format!("vendor ledger write failed: {e}"))); + continue; + } + if !common.json && !common.silent { + println!("Reverted vendoring for {key}"); + } + out.reverted.push(key.clone()); + } + } + out +} + +/// Unwind the in-scope hosted redirects: per-purl reverts where they +/// exist (cargo + npm-family), and — when the scope covers the ENTIRE +/// record set — the whole-ledger reverse replay for everything else. +/// Mutates `state`; the caller persists on wet runs. +pub(crate) async fn run_hosted_leg( + common: &GlobalArgs, + purls: &[String], + state: &mut socket_patch_core::patch::redirect::RedirectState, + replay_eligible: bool, +) -> HostedLegOutcome { + use socket_patch_core::patch::redirect::{ + redirect_revert_supported, revert_redirect_purl, revert_remaining_redirect_edits, + }; + + let mut out = HostedLegOutcome::default(); + // bun.lock edits hard-refuse the per-purl npm revert; when the replay + // will run it owns them instead, so npm purls on bun projects defer + // rather than fail. + let has_bun_edits = state + .edits + .iter() + .any(|e| e.kind == "redirect_bun_lock_package" || e.kind == "redirect_bun_lockb_migrated"); + let mut deferred_to_replay: Vec = Vec::new(); + for purl in purls { + let defer_bun = has_bun_edits && purl.starts_with("pkg:npm/") && replay_eligible; + if !defer_bun && redirect_revert_supported(purl) { + match revert_redirect_purl(&common.cwd, state, purl, common.dry_run).await { + Ok(revert) => { + if !common.json && !common.silent { + if common.dry_run { + println!("Would unwind hosted redirect for {purl}"); + } else { + println!("Unwound hosted redirect for {purl}"); + } + } + out.edited_files + .extend(revert.reverted_files.iter().cloned()); + out.reverted.push(purl.clone()); + } + Err(e) => { + if !common.json { + eprintln!("Failed to unwind hosted redirect for {purl}: {e}"); + } + out.failed.push((purl.clone(), e)); + } + } + } else if replay_eligible { + deferred_to_replay.push(purl.clone()); + } else { + if !common.json { + eprintln!( + "Cannot unwind hosted redirect for {purl}: no per-purl revert exists for \ + this ecosystem. Run an unscoped `socket-patch rollback` to unwind ALL \ + hosted redirects, or re-run `scan --mode hosted` to normalize." + ); + } + out.unsupported.push(purl.clone()); + } + } + // The whole-ledger replay runs when the scope covers every record + // (however it was spelled), and also as the "last one out turns off + // the lights" pass — per-purl reverts never claim the non-package + // rideshare edits (pnpm trustLockfile, the bun.lockb migration + // marker), so an emptied record map with leftover edits replays them + // here too. + if replay_eligible || (state.records.is_empty() && !state.edits.is_empty()) { + let replay = revert_remaining_redirect_edits(&common.cwd, state, common.dry_run).await; + for refusal in &replay.refusals { + let files: Vec<&str> = refusal.files.iter().map(String::as_str).collect(); + let why = format!("{} ({})", refusal.reason, files.join(", ")); + if !common.json { + eprintln!("Cannot unwind hosted redirect edits ({}): {why}", refusal.group); + } + out.failed.push((format!("group:{}", refusal.group), why)); + } + out.warnings.extend(replay.warnings.iter().cloned()); + out.edited_files.extend(replay.reverted_files.iter().cloned()); + // Deferred purls succeeded iff the replay dropped their records. + for purl in deferred_to_replay { + if replay.dropped_records.iter().any(|p| p == &purl) { + if !common.json && !common.silent { + if common.dry_run { + println!("Would unwind hosted redirect for {purl}"); + } else { + println!("Unwound hosted redirect for {purl}"); + } + } + out.reverted.push(purl); + } else if !out.failed.iter().any(|(p, _)| p.starts_with("group:")) { + out.failed + .push((purl, "hosted redirect edits could not be replayed".into())); + } + } + } + out +} + pub async fn run(args: RollbackArgs) -> i32 { apply_env_toggles(&args.common); + // Classify targets up front: the one-off stub and the glob validation + // are pre-network usage checks. + let mut identifiers: Vec = Vec::new(); + let mut path_patterns: Vec = Vec::new(); + for token in &args.targets { + match classify_target(token) { + RollbackTarget::Identifier(id) => identifiers.push(id), + RollbackTarget::PathGlob(p) => path_patterns.push(p), + } + } + // Bail on the unimplemented flag BEFORE constructing the API client: // client construction can auto-resolve the org slug over the network, // and the contract promises the one-off stub fails before any network // or disk activity. if args.one_off { - let msg = if args.identifier.is_none() { + let msg = if identifiers.is_empty() { "--one-off requires an identifier (UUID or PURL)" } else { "One-off rollback mode is not yet implemented" @@ -376,14 +765,59 @@ pub async fn run(args: RollbackArgs) -> i32 { return 1; } + // An unparseable glob is a usage error — same exit-2 stderr shape as + // scan's self-enforced mode conflicts. + let path_scope = match crate::path_scope::PathScope::parse(&path_patterns) { + Ok(s) => s, + Err(e) => { + eprintln!("error: {e}"); + return 2; + } + }; + 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(); + let cwd = args.common.cwd.clone(); - if tokio::fs::metadata(&manifest_path).await.is_err() { + // ── state discovery ───────────────────────────────────────────────── + // Rollback infers what to undo from the three state stores: the + // manifest (in-place/agent patches), the vendor ledger (vendored + // patches), and the redirect ledger (hosted lockfile redirects). A + // missing manifest is no longer fatal when a ledger holds work. + // + // Only cheap EXISTENCE probes happen before the lock (they decide the + // truly-empty error path, which never locks — the lock file would + // materialize `.socket/` in a project that has none). The stores + // themselves are LOADED UNDER the apply lock below: this run persists + // mutated clones of the ledgers, so a pre-lock snapshot could clobber + // a concurrent run's writes with stale state. + let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); + let vendor_ledger_exists = tokio::fs::metadata(cwd.join(".socket/vendor/state.json")) + .await + .is_ok(); + let redirect_ledger_exists = tokio::fs::metadata( + cwd.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL), + ) + .await + .is_ok(); + + if manifest_missing && !vendor_ledger_exists && !redirect_ledger_exists { + // Ledger-less but still wired? (a deleted/uncommitted state.json + // with lockfiles still consuming `.socket/vendor/` artifacts is a + // supported recovery state — `repair` reconstructs the ledger.) + let wired = crate::commands::repair_vendor::scan_vendor_references(&cwd).await; + if !wired.is_empty() { + emit_rollback_error( + args.common.json, + "lockfiles still reference .socket/vendor/ artifacts but the vendor ledger \ + is missing — run `socket-patch repair` to reconstruct it, then roll back", + ); + return 1; + } if args.common.json { println!( "{}", @@ -405,9 +839,12 @@ pub async fn run(args: RollbackArgs) -> i32 { // Serialize against concurrent socket-patch runs targeting the // same `.socket/` directory. See // `socket_patch_core::patch::apply_lock`. - let socket_dir = manifest_path.parent().unwrap_or(Path::new(".")); + let socket_dir = manifest_path + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf(); let _lock = match acquire_or_emit( - socket_dir, + &socket_dir, EnvelopeCommand::Rollback, args.common.json, args.common.dry_run, @@ -417,20 +854,603 @@ pub async fn run(args: RollbackArgs) -> i32 { Err(code) => return code, }; - match rollback_patches_inner(&args, &manifest_path, Some(&telemetry_client)).await { + // Load the state stores UNDER the lock (see the discovery note above). + let vendor_state_result = socket_patch_core::vendor::load_state(&cwd).await; + let redirect_state_result = + socket_patch_core::patch::redirect::load_redirect_state(&cwd).await; + let vendor_corrupt = vendor_state_result.is_err(); + let redirect_corrupt = redirect_state_result.is_err(); + + // ── scope resolution ──────────────────────────────────────────────── + let manifest = if manifest_missing { + PatchManifest::new() + } else { + match read_manifest(&manifest_path).await { + Ok(Some(m)) => m, + Ok(None) => { + track_patch_rollback_failed( + "Invalid manifest", + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + emit_rollback_error(args.common.json, "Invalid manifest"); + return 1; + } + Err(e) => { + let msg = e.to_string(); + track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()) + .await; + emit_rollback_error(args.common.json, &msg); + return 1; + } + } + }; + let vendor_entries: Vec<(String, socket_patch_core::vendor::VendorEntry)> = + match &vendor_state_result { + Ok(s) => { + let mut v: Vec<_> = s + .entries + .iter() + .map(|(k, e)| (k.clone(), e.clone())) + .collect(); + v.sort_by(|a, b| a.0.cmp(&b.0)); + v + } + Err(_) => Vec::new(), + }; + let redirect_records: Vec<(String, String)> = match &redirect_state_result { + Ok(Some(s)) => s + .records + .iter() + .map(|(purl, rec)| (purl.clone(), rec.uuid.clone())) + .collect(), + _ => Vec::new(), + }; + + let scoped = !identifiers.is_empty() || !path_scope.is_empty(); + + // Identifier matching runs across ALL THREE stores; an identifier + // matching nothing anywhere is the familiar exit-1 error. + let mut manifest_scope: HashSet = HashSet::new(); + let mut vendor_scope: HashSet = HashSet::new(); + let mut hosted_scope: HashSet = HashSet::new(); + if identifiers.is_empty() && path_scope.is_empty() { + manifest_scope.extend(manifest.patches.keys().cloned()); + vendor_scope.extend(vendor_entries.iter().map(|(k, _)| k.clone())); + hosted_scope.extend(redirect_records.iter().map(|(p, _)| p.clone())); + } + for id in &identifiers { + let mut matched = false; + for (purl, patch) in &manifest.patches { + if patch_matches(purl, &patch.uuid, id) { + manifest_scope.insert(purl.clone()); + matched = true; + } + } + for (key, entry) in &vendor_entries { + if patch_matches(key, &entry.uuid, id) + || patch_matches(&entry.base_purl, &entry.uuid, id) + { + vendor_scope.insert(key.clone()); + matched = true; + } + } + for (purl, uuid) in &redirect_records { + if patch_matches(purl, uuid, id) { + hosted_scope.insert(purl.clone()); + matched = true; + } + } + if !matched { + let hint = if id.starts_with("pkg:") || looks_like_uuid(id) { + String::new() + } else { + format!(" (to target a directory instead, use ./{id} or {id}/**)") + }; + let msg = format!("No patch found matching identifier: {id}{hint}"); + track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + if args.common.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "error", + "error": msg, + "rolledBack": 0, + "alreadyOriginal": 0, + "failed": 0, + "dryRun": args.common.dry_run, + "vendored": [], + "results": [], + })) + .expect("serializing an in-memory JSON value cannot fail") + ); + } else { + eprintln!("Error: {msg}"); + } + return 1; + } + } + + // Path scoping: discover installed copies of every candidate purl and + // select the purls with a copy under a matching path. Each pattern + // must select something — an empty pattern is an error, protecting a + // mistyped target from silently becoming an empty (or wrong) scope. + if !path_scope.is_empty() { + let mut candidates: Vec = manifest.patches.keys().cloned().collect(); + candidates.extend(vendor_entries.iter().map(|(k, _)| k.clone())); + candidates.extend(redirect_records.iter().map(|(p, _)| p.clone())); + candidates.sort(); + candidates.dedup(); + let partitioned = partition_purls(&candidates, args.common.ecosystems.as_deref()); + let crawler_options = CrawlerOptions { + cwd: cwd.clone(), + global: args.common.global, + global_prefix: args.common.global_prefix.clone(), + }; + let discovered = find_all_packages_for_rollback( + &partitioned, + &crawler_options, + args.common.silent || args.common.json, + ) + .await; + let mut matched_patterns: HashSet = HashSet::new(); + let mut path_selected: HashSet = HashSet::new(); + for (purl, paths) in &discovered { + for path in paths { + for (idx, raw) in path_scope.raw().iter().enumerate() { + let single = crate::path_scope::PathScope::parse(std::slice::from_ref(raw)) + .expect("already parsed above"); + if single.matches(&cwd, path) { + matched_patterns.insert(idx); + path_selected.insert(purl.clone()); + } + } + } + } + if let Some(unmatched) = path_scope + .raw() + .iter() + .enumerate() + .find(|(idx, _)| !matched_patterns.contains(idx)) + { + let msg = format!( + "path pattern matched no patched packages: {} (path targets select \ + installed copies; patches for uninstalled packages are reachable by \ + identifier or an unscoped rollback)", + unmatched.1 + ); + track_patch_rollback_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; + emit_rollback_error(args.common.json, &msg); + return 1; + } + for purl in &path_selected { + if manifest.patches.contains_key(purl) { + manifest_scope.insert(purl.clone()); + } + for (key, entry) in &vendor_entries { + if key == purl || &entry.base_purl == purl { + vendor_scope.insert(key.clone()); + } + } + if redirect_records.iter().any(|(p, _)| p == purl) { + hosted_scope.insert(purl.clone()); + } + } + } + + // `--ecosystems` narrows every leg (the manifest side is scoped inside + // the agent engine as before). + if args.common.ecosystems.is_some() { + vendor_scope.retain(|key| { + vendor_entries + .iter() + .find(|(k, _)| k == key) + .is_some_and(|(_, e)| { + crate::commands::vendor::ecosystem_in_scope(&args.common, &e.ecosystem) + }) + }); + hosted_scope.retain(|purl| { + Ecosystem::from_purl(purl) + .is_some_and(|e| crate::commands::vendor::ecosystem_in_scope(&args.common, e.cli_name())) + }); + } + + // The whole-ledger hosted replay (which covers the ecosystems without + // a per-purl revert) runs only when the scope covers EVERY record — + // however the scope was spelled. + let replay_eligible = match &redirect_state_result { + // A records-EMPTY ledger (degraded record-fetch-failed runs leave + // edits without records) is vacuously "covered" by any scope; only + // an UNSCOPED run may replay those leftover edits — a scoped + // rollback of an unrelated purl must not unwind live redirects it + // was never asked about. + Ok(Some(s)) => { + (!s.records.is_empty() || !scoped) + && s.records.keys().all(|p| hosted_scope.contains(p)) + } + _ => false, + }; + + // Corrupt-ledger containment: a corrupt store fails ONLY the legs that + // need it; the agent leg still restores files (emergency restores are + // never blocked by an unrelated corrupt ledger). Cleanup/GC also skip + // fail-closed — ownership cannot be established. + let mut run_warnings: Vec<(String, String)> = Vec::new(); + if vendor_corrupt { + run_warnings.push(( + "vendor_state_unreadable".into(), + format!( + "cannot read .socket/vendor/state.json: {} — the vendored leg, manifest \ + cleanup, and GC were skipped", + vendor_state_result.as_ref().expect_err("checked corrupt above") + ), + )); + } + if redirect_corrupt { + run_warnings.push(( + "redirect_state_unreadable".into(), + format!( + "cannot read the hosted redirect ledger: {} — the hosted leg was skipped; \ + quarantine or restore .socket/vendor/redirect-state.json and re-run", + redirect_state_result + .as_ref() + .expect_err("checked corrupt above") + ), + )); + } + + // ── confirmation ──────────────────────────────────────────────────── + // The default run deletes manifest entries, vendored artifacts, ledger + // records, and unused blobs — prompt once, remove-style. Auto-accepted + // under --yes/--json/non-TTY; skipped for previews and for + // --preserve-state runs (which delete no local state). + // Leftover hosted edits an eligible replay would unwind even with no + // in-scope records (degraded record-fetch-failed ledgers): they are + // work — and prompt-worthy mutation — too. + let hosted_leftover_edits = if replay_eligible { + match &redirect_state_result { + Ok(Some(st)) => st.edits.len(), + _ => 0, + } + } else { + 0 + }; + let has_work = !manifest_scope.is_empty() + || !vendor_scope.is_empty() + || !hosted_scope.is_empty() + || hosted_leftover_edits > 0; + if has_work && !args.common.dry_run && !args.preserve_state { + let detached_count = vendor_entries + .iter() + .filter(|(k, e)| vendor_scope.contains(k) && e.detached) + .count(); + // Compose only the clauses that apply, so a hosted-only run never + // claims manifest entries it does not have. + let mut clauses: Vec = Vec::new(); + if !manifest_scope.is_empty() { + clauses.push(format!( + "roll back {} patch(es) and remove them from the local manifest", + manifest_scope.len() + )); + } + if !vendor_scope.is_empty() { + let mut clause = format!("delete {} vendored artifact(s)", vendor_scope.len()); + if detached_count > 0 { + clause.push_str(&format!( + " ({detached_count} detached — their embedded patch records are the \ + only local copy)" + )); + } + clauses.push(clause); + } + if !hosted_scope.is_empty() { + clauses.push(format!( + "unwind {} hosted redirect(s)", + hosted_scope.len() + )); + } else if hosted_leftover_edits > 0 { + clauses.push(format!( + "replay {hosted_leftover_edits} leftover hosted redirect edit(s)" + )); + } + let mut prompt = clauses.join(", and "); + if let Some(first) = prompt.get(..1) { + let capitalized = first.to_uppercase(); + prompt.replace_range(..1, &capitalized); + } + prompt.push('?'); + if !crate::output::confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.json && !args.common.silent { + println!("Rollback cancelled."); + } + return 0; + } + } + + // ── agent leg (in-place restore) ──────────────────────────────────── + let selection = InnerSelection::Scope { + purls: &manifest_scope, + announce_empty: !scoped, + }; + match rollback_patches_inner( + &args.common, + &manifest_path, + selection, + Some(&telemetry_client), + ) + .await + { Ok(RollbackOutcome { - success: rollback_success, + success: agent_success, results, - vendored_skipped: vendored, + vendored_skipped: vendored_excluded, not_installed, + narrowed_out, + aborted, }) => { - // Not-installed entries never flip the exit code — not even - // when ALL in-scope targets land there. Rollback's job is - // "make the tree unpatched", and a not-installed package - // already satisfies that end state, so the run is a success - // (exit 0); apply's all-unmatched `partialFailure` deliberately - // does NOT mirror over. See `RollbackOutcome`. - let success = rollback_success; + // ── vendored leg ───────────────────────────────────────────── + // The in-scope ledger entries: unwire the lockfiles and (by + // default) delete the artifacts + drop the entries. + // `--preserve-state` keeps artifacts and entries. Skipped + // fail-closed when the ledger is unreadable. + let mut vendored_leg = VendoredLegOutcome::default(); + if !vendor_corrupt && !vendor_scope.is_empty() { + let mut vs = vendor_state_result + .as_ref() + .ok() + .cloned() + .unwrap_or_default(); + let mut keys: Vec = vendor_scope.iter().cloned().collect(); + keys.sort(); + vendored_leg = + run_vendored_leg(&args.common, &keys, &mut vs, args.preserve_state).await; + } + // `vendored` (the legacy "benign, untouched" array) is + // reserved-empty in v5.0: acted-on entries land in the + // vendoredReverted/vendoredPreserved/vendoredKept arrays, and + // the corrupt-ledger skip cannot name vendor-owned purls (the + // detection itself needs the ledger) — it surfaces via the + // `vendor_state_unreadable` warning and exit 1 instead. + let vendored: Vec = Vec::new(); + let _ = &vendored_excluded; + + // ── hosted leg ─────────────────────────────────────────────── + let mut hosted_leg = HostedLegOutcome::default(); + if !redirect_corrupt { + if let Ok(Some(existing)) = &redirect_state_result { + let mut st = existing.clone(); + let before = (st.edits.len(), st.records.len()); + let mut purls: Vec = hosted_scope.iter().cloned().collect(); + purls.sort(); + if !purls.is_empty() || (replay_eligible && !st.edits.is_empty()) { + hosted_leg = + run_hosted_leg(&args.common, &purls, &mut st, replay_eligible).await; + let changed = + (st.edits.len(), st.records.len()) != before; + if !args.common.dry_run && changed { + if let Err(e) = + socket_patch_core::patch::redirect::persist_redirect_state( + &cwd, &st, + ) + .await + { + let msg = + format!("failed to persist the hosted redirect ledger: {e}"); + if !args.common.json { + eprintln!("Error: {msg}"); + } + hosted_leg.failed.push(("ledger".to_string(), msg)); + } + } + } + } + } + + // ── manifest cleanup ───────────────────────────────────────── + // The new default: entries whose state was fully undone leave + // the manifest, and the now-unused blobs/archives are swept. + // Fail-closed skips: --preserve-state, a blob-gate abort + // (nothing was restored), and an unreadable vendor ledger + // (ownership unknowable). + let failed_purls: HashSet = results + .iter() + .filter(|r| !r.success) + .map(|r| r.package_key.clone()) + .collect(); + let cleanup_allowed = !args.preserve_state && !aborted && !vendor_corrupt; + // A vendor-owned manifest purl is removable only when its + // ledger entry was cleanly reverted this run (drift-keeps and + // failures keep the record; the matching mirrors the + // ledger-key / base-purl / qualifier-stripped triple). + let vendored_reverted_ok = |purl: &str| { + vendored_leg.reverted.iter().any(|key| { + key == purl + || strip_purl_qualifiers(key) == strip_purl_qualifiers(purl) + || vendor_entries + .iter() + .find(|(k, _)| k == key) + .is_some_and(|(_, e)| e.base_purl == strip_purl_qualifiers(purl)) + }) + }; + let succeeded_purls: HashSet = results + .iter() + .filter(|r| r.success) + .map(|r| r.package_key.clone()) + .collect(); + // Bases whose attempted variant(s) failed hold their whole + // group in the manifest (narrowed-away siblings included). + let failed_bases: HashSet<&str> = failed_purls + .iter() + .map(|p| strip_purl_qualifiers(p)) + .collect(); + let mut removable: Vec = manifest_scope + .iter() + .filter(|purl| { + if failed_purls.contains(*purl) { + return false; + } + if vendored_excluded.contains(purl) { + return vendored_reverted_ok(purl); + } + succeeded_purls.contains(*purl) + || not_installed.contains(purl) + || (narrowed_out.contains(purl) + && !failed_bases.contains(strip_purl_qualifiers(purl))) + }) + .cloned() + .collect(); + removable.sort(); + + let mut removed: Vec = Vec::new(); + let mut updated_manifest = manifest.clone(); + let mut manifest_write_failed: Option = None; + if cleanup_allowed && !removable.is_empty() { + updated_manifest + .patches + .retain(|purl, _| !removable.contains(purl)); + removed = removable.clone(); + if !args.common.dry_run { + if let Err(e) = write_manifest(&manifest_path, &updated_manifest).await { + manifest_write_failed = Some(e.to_string()); + removed.clear(); + updated_manifest = manifest.clone(); + } + } + } + + // ── GC ─────────────────────────────────────────────────────── + // Sweep against the post-removal manifest, with beforeHash + // blobs pinned (synthetic afterHash-slot records — the sweep + // keeps only afterHash blobs) for (a) removed-but-not-installed + // entries — a crawler miss must not destroy the only local + // revert data — and (b) in-scope entries that FAILED this run: + // their entries stay, and the blobs the gate just downloaded + // must survive for an offline retry. + let mut gc_json: serde_json::Value = serde_json::json!({ "skipped": true }); + let mut gc_bytes_freed: u64 = 0; + if cleanup_allowed { + let mut cleanup_reference = updated_manifest.clone(); + // Pin the beforeHash blobs of EVERY entry remaining in the + // manifest (still-active patches keep their revert data — + // an eco-scoped or failed run must never destroy the blobs + // a later rollback needs) plus removed-but-not-installed + // entries (remove's crawler-miss guard). Blobs referenced + // only by genuinely-removed entries are what gets swept. + let pinned_purls: Vec<&String> = removed + .iter() + .filter(|p| not_installed.contains(p)) + .chain(updated_manifest.patches.keys()) + .collect(); + pin_before_hash_blobs(&mut cleanup_reference, &manifest, pinned_purls); + let blobs_dir = socket_dir.join("blobs"); + let mut removed_blobs = 0usize; + let mut removed_diffs = 0usize; + let mut removed_packages = 0usize; + match cleanup_unused_blobs(&cleanup_reference, &blobs_dir, args.common.dry_run) + .await + { + Ok(r) => { + removed_blobs = r.blobs_removed; + gc_bytes_freed += r.bytes_freed; + } + Err(e) => run_warnings.push(( + "cleanup_failed".into(), + format!("blob cleanup failed: {e}"), + )), + } + for (dir, slot) in [ + ("diffs", &mut removed_diffs), + ("packages", &mut removed_packages), + ] { + match cleanup_unused_archives( + &cleanup_reference, + &socket_dir.join(dir), + args.common.dry_run, + ) + .await + { + Ok(r) => { + *slot = r.blobs_removed; + gc_bytes_freed += r.bytes_freed; + } + Err(e) => run_warnings.push(( + "cleanup_failed".into(), + format!("{dir} cleanup failed: {e}"), + )), + } + } + gc_json = serde_json::json!({ + "removedBlobs": removed_blobs, + "removedDiffArchives": removed_diffs, + "removedPackageArchives": removed_packages, + "bytesFreed": gc_bytes_freed, + }); + } + + // ── run-level warnings ─────────────────────────────────────── + let unwired_any = !vendored_leg.reverted.is_empty() + || !vendored_leg.preserved.is_empty() + || !hosted_leg.reverted.is_empty(); + if unwired_any { + run_warnings.push(( + "reinstall_required".into(), + "unwired packages keep their patched bytes in installed trees until \ + the next package-manager install" + .into(), + )); + } + if args.preserve_state && !hosted_leg.reverted.is_empty() { + run_warnings.push(( + "hosted_state_not_preservable".into(), + "hosted redirects have no preservable local state: their ledger \ + records were dropped with the unwound wiring; re-run \ + `scan --mode hosted` to re-wire" + .into(), + )); + } + if !path_scope.is_empty() { + let out_of_scope: Vec<&str> = results + .iter() + .filter(|r| { + r.success + && !r.files_rolled_back.is_empty() + && !path_scope.matches(&cwd, Path::new(&r.package_path)) + }) + .map(|r| r.package_key.as_str()) + .collect(); + if !out_of_scope.is_empty() { + run_warnings.push(( + "out_of_scope_copies_restored".into(), + format!( + "rollback restores every installed copy of a selected patch; \ + {} restored cop{} outside the given paths", + out_of_scope.len(), + if out_of_scope.len() == 1 { "y lives" } else { "ies live" } + ), + )); + } + } + vendored_leg + .warnings + .iter() + .chain(hosted_leg.warnings.iter()) + .for_each(|(code, detail)| run_warnings.push((code.clone(), detail.clone()))); + + // ── status / exit ──────────────────────────────────────────── + // Not-installed entries never flip the exit code (see + // `RollbackOutcome`). Everything that leaves the system still + // patched DOES: agent failures, vendored drift-keeps and + // failures, hosted refusals/unsupported targets, corrupt + // ledgers, and a failed manifest write. + let success = agent_success + && vendored_leg.kept.is_empty() + && vendored_leg.failed.is_empty() + && hosted_leg.failed.is_empty() + && hosted_leg.unsupported.is_empty() + && !vendor_corrupt + && !redirect_corrupt + && manifest_write_failed.is_none(); let rolled_back_count = results .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) @@ -441,12 +1461,19 @@ pub async fn run(args: RollbackArgs) -> i32 { .count(); let failed_count = results.iter().filter(|r| !r.success).count(); + if let Some(e) = &manifest_write_failed { + if !args.common.json { + eprintln!("Error: failed to update the manifest: {e}"); + } + run_warnings.push(( + "manifest_write_failed".into(), + format!("failed to update the manifest: {e}"), + )); + } + if args.common.json { - // `warnings` carries non-fatal audit info. Nothing - // populates it today (the `lock_broken` notice left with - // `--break-lock`), but the empty array stays present in - // the JSON shape so consumers can rely on `.warnings[]` - // without null-checking. + // Legacy shape plus the additive duality keys — every key + // always present so consumers never null-check. println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ @@ -455,10 +1482,47 @@ pub async fn run(args: RollbackArgs) -> i32 { "alreadyOriginal": already_original_count, "failed": failed_count, "dryRun": args.common.dry_run, - "warnings": [], - // Vendor-owned purls excluded from in-place rollback - // (benign — `remove` or `vendor --revert` undo them). + "warnings": run_warnings + .iter() + .map(|(code, detail)| serde_json::json!({ + "code": code, "detail": detail, + })) + .collect::>(), + // Vendor-owned purls the run did NOT act on (the + // corrupt-ledger skip); acted-on entries are in the + // vendored* arrays below. "vendored": vendored, + "vendoredReverted": vendored_leg.reverted, + "vendoredPreserved": vendored_leg.preserved, + "vendoredKept": vendored_leg.kept + .iter() + .map(|(key, reason)| serde_json::json!({ + "purl": key, "reason": reason, + })) + .collect::>(), + "vendoredFailed": vendored_leg.failed + .iter() + .map(|(key, error)| serde_json::json!({ + "purl": key, "error": error, + })) + .collect::>(), + "hosted": { + "reverted": hosted_leg.reverted, + "failed": hosted_leg.failed + .iter() + .map(|(purl, error)| serde_json::json!({ + "purl": purl, "error": error, + })) + .collect::>(), + "unsupported": hosted_leg.unsupported, + "editedFiles": hosted_leg.edited_files.len(), + }, + "manifest": { + "removedEntries": removed, + "preserved": args.preserve_state, + }, + "gc": gc_json, + "paths": path_scope.raw(), // Real result records first, then one skipped marker // per in-scope entry with no installed package — // apply's `package_not_installed` Skipped event, @@ -549,14 +1613,55 @@ pub async fn run(args: RollbackArgs) -> i32 { } } - if !args.common.json && !args.common.silent && !vendored.is_empty() { - println!( - "\n{} vendored package(s) skipped (managed by socket-patch vendor; \ - use `remove` or `vendor --revert`):", - vendored.len() - ); - for purl in &vendored { - println!(" {purl}"); + // Error-class notices print even under --silent ("errors only, + // never nothing"): drift-keeps and corrupt-ledger skips drive + // exit 1, so a silent run must still say why. + if !args.common.json { + for (key, reason) in &vendored_leg.kept { + eprintln!("Kept vendored state for {key}: {reason}"); + } + for (code, detail) in &run_warnings { + if code == "vendor_state_unreadable" || code == "redirect_state_unreadable" { + eprintln!("Error ({code}): {detail}"); + } + } + } + if !args.common.json && !args.common.silent { + if args.common.dry_run { + if cleanup_allowed && !removed.is_empty() { + println!("\nWould remove {} patch(es) from manifest:", removed.len()); + for purl in &removed { + println!(" - {purl}"); + } + } + } else if !removed.is_empty() { + println!("\nRemoved {} patch(es) from manifest:", removed.len()); + for purl in &removed { + println!(" - {purl}"); + } + } else if args.preserve_state && has_work { + println!( + "\nManifest entries and vendored artifacts preserved \ + (--preserve-state); re-apply with `socket-patch apply` or \ + `socket-patch vendor`." + ); + } + if gc_bytes_freed > 0 { + println!( + "{} {} bytes of unused blobs/archives", + if args.common.dry_run { + "Would free" + } else { + "Freed" + }, + gc_bytes_freed + ); + } + if unwired_any { + println!( + "\nNote: unwired packages keep their patched bytes in installed \ + trees until the next package-manager install." + ); } } @@ -623,8 +1728,9 @@ pub async fn run(args: RollbackArgs) -> i32 { } async fn rollback_patches_inner( - args: &RollbackArgs, + common: &GlobalArgs, manifest_path: &Path, + selection: InnerSelection<'_>, // The client `run()` already built. Constructing one per phase printed // the core client's "No SOCKET_API_TOKEN set" notice once per // construction — twice in a single rollback. `None` (the `remove` @@ -632,42 +1738,62 @@ async fn rollback_patches_inner( // below actually fires. api_client: Option<&ApiClient>, ) -> Result { - let manifest = read_manifest(manifest_path) - .await - .map_err(|e| e.to_string())? - .ok_or_else(|| "Invalid manifest".to_string())?; + // The Scope selection tolerates a missing manifest (ledger-only + // projects reach here with hosted/vendored work and no manifest); + // the Identifier selection keeps the legacy hard requirement. + let manifest = match read_manifest(manifest_path).await.map_err(|e| e.to_string())? { + Some(m) => m, + None => match &selection { + InnerSelection::Identifier(_) => return Err("Invalid manifest".to_string()), + InnerSelection::Scope { .. } => PatchManifest::new(), + }, + }; let socket_dir = manifest_path .parent() .expect("manifest path names a file, so it has a parent"); let mut blobs_path = socket_dir.join("blobs"); - // `--dry-run` must not mutate `.socket/` ("Preview, no mutations"): - // don't create the blobs dir; a throwaway stage replaces it below. - if !args.common.dry_run { - tokio::fs::create_dir_all(&blobs_path) - .await - .map_err(|e| e.to_string())?; - } - let patches_to_rollback = find_patches_to_rollback(&manifest, args.identifier.as_deref()); + let patches_to_rollback = match &selection { + InnerSelection::Identifier(identifier) => { + find_patches_to_rollback(&manifest, identifier.as_deref()) + } + InnerSelection::Scope { purls, .. } => manifest + .patches + .iter() + .filter(|(purl, _)| purls.contains(*purl)) + .map(|(purl, patch)| PatchToRollback { + purl: purl.clone(), + patch: patch.clone(), + }) + .collect(), + }; if patches_to_rollback.is_empty() { - if args.identifier.is_some() { - return Err(format!( - "No patch found matching identifier: {}", - args.identifier - .as_deref() - .expect("is_some checked by the enclosing if") - )); - } - if !args.common.silent && !args.common.json { - println!("No patches found in manifest"); + match &selection { + InnerSelection::Identifier(Some(identifier)) => { + return Err(format!("No patch found matching identifier: {identifier}")); + } + InnerSelection::Identifier(None) => { + if !common.silent && !common.json { + println!("No patches found in manifest"); + } + } + InnerSelection::Scope { announce_empty, .. } => { + // No-match errors were the resolver's job; an empty scoped + // selection here just means the work lives in other legs. + if *announce_empty && !common.silent && !common.json { + println!("No patches found in manifest"); + } + } } return Ok(RollbackOutcome { success: true, results: Vec::new(), vendored_skipped: Vec::new(), not_installed: Vec::new(), + narrowed_out: Vec::new(), + aborted: false, }); } @@ -678,7 +1804,7 @@ async fn rollback_patches_inner( // `vendor --revert` undoes it wholesale. Matching mirrors apply's // ledger-key / base-purl / qualifier-stripped triple; unreadable state // degrades to "nothing vendored". - let vendored_keys = socket_patch_core::vendor::vendored_purl_keys(&args.common.cwd).await; + let vendored_keys = socket_patch_core::vendor::vendored_purl_keys(&common.cwd).await; let is_vendored = |p: &str| vendored_keys.contains(p) || vendored_keys.contains(strip_purl_qualifiers(p)); let (vendored_targets, patches_to_rollback): (Vec<_>, Vec<_>) = patches_to_rollback @@ -694,9 +1820,21 @@ async fn rollback_patches_inner( results: Vec::new(), vendored_skipped, not_installed: Vec::new(), + narrowed_out: Vec::new(), + aborted: false, }); } + // `--dry-run` must not mutate `.socket/` ("Preview, no mutations"): + // don't create the blobs dir; a throwaway stage replaces it below. + // Created only now that in-place work is known to exist, so a + // hosted-/vendored-only rollback leaves no empty blobs dir behind. + if !common.dry_run { + tokio::fs::create_dir_all(&blobs_path) + .await + .map_err(|e| e.to_string())?; + } + // Create filtered manifest (a synthetic rollback-target subset, never // written to disk, so it carries no persisted setup state). let filtered_manifest = PatchManifest { @@ -714,7 +1852,7 @@ async fn rollback_patches_inner( // (or trigger fetches for) a run that will never restore it. Mirrors // apply's `scoped_manifest`. let rollback_purls: Vec = patches_to_rollback.iter().map(|p| p.purl.clone()).collect(); - let partitioned = partition_purls(&rollback_purls, args.common.ecosystems.as_deref()); + let partitioned = partition_purls(&rollback_purls, common.ecosystems.as_deref()); let in_scope: HashSet = partitioned .values() .flat_map(|purls| purls.iter().cloned()) @@ -725,9 +1863,9 @@ async fn rollback_patches_inner( .retain(|purl, _| in_scope.contains(purl)); let crawler_options = CrawlerOptions { - cwd: args.common.cwd.clone(), - global: args.common.global, - global_prefix: args.common.global_prefix.clone(), + cwd: common.cwd.clone(), + global: common.global, + global_prefix: common.global_prefix.clone(), }; // Multi-copy aware: npm nests genuine duplicates of one `name@version`, @@ -738,7 +1876,7 @@ async fn rollback_patches_inner( let all_packages_multi = find_all_packages_for_rollback( &partitioned, &crawler_options, - args.common.silent || args.common.json, + common.silent || common.json, ) .await; @@ -768,7 +1906,7 @@ async fn rollback_patches_inner( let undiscovered_redirects: Vec = scoped_manifest .patches .keys() - .filter(|purl| is_local_redirect(purl, &args.common) && !all_packages.contains_key(*purl)) + .filter(|purl| is_local_redirect(purl, common) && !all_packages.contains_key(*purl)) .cloned() .collect(); @@ -807,6 +1945,12 @@ async fn rollback_patches_inner( // Resolve which variant(s) each base PURL will actually roll back, // BEFORE the before-blob gate below, so the gate covers only them. + // Narrowed-away sibling variants (same base, distribution NOT on disk) + // are collected so the CLI boundary's manifest-cleanup default can + // drop them alongside their attempted siblings — a rolled-back + // package must not leave half its variant group in the manifest + // (remove's identifier flow drops the whole group the same way). + let mut narrowed_out: Vec = Vec::new(); for (_base, entries) in groups { let to_rollback: Vec<(&String, &PathBuf)> = if entries.len() == 1 { entries @@ -834,6 +1978,12 @@ async fn rollback_patches_inner( .iter() .map(|&i| candidates[i].0.to_string()) .collect(); + narrowed_out.extend( + entries + .iter() + .filter(|(p, _)| !winners.contains(*p)) + .map(|(p, _)| (*p).clone()), + ); entries .into_iter() .filter(|(p, _)| winners.contains(*p)) @@ -842,6 +1992,8 @@ async fn rollback_patches_inner( }; rollback_targets.extend(to_rollback); } + narrowed_out.sort(); + narrowed_out.dedup(); // Check for missing beforeHash blobs — AFTER discovery and variant // narrowing, so the gate covers ONLY the packages this run will @@ -874,7 +2026,7 @@ async fn rollback_patches_inner( .collect(), setup: None, }, - &args.common, + common, ); // Apply's `unmatched` twin: in-scope manifest entries the crawler found @@ -898,7 +2050,7 @@ async fn rollback_patches_inner( // too. `tempdir_in(socket_dir)` keeps it on the same filesystem for // hardlinks and is auto-removed on drop, like the `.socket-stage-*` // atomic-write siblings. - let _dry_run_blob_stage: Option = if args.common.dry_run { + let _dry_run_blob_stage: Option = if common.dry_run { let stage = tempfile::Builder::new() .prefix(".socket-stage-dryrun-blobs-") .tempdir_in(socket_dir) @@ -978,12 +2130,12 @@ async fn rollback_patches_inner( .collect(), setup: None, }; - if args.common.offline { + if common.offline { // Errors print even under --silent ("errors only", never // "nothing"): in human mode this bail is the run's only // stderr diagnostic; `--json` mutes it and instead carries // the synthesized per-package failures below. - if !args.common.json { + if !common.json { eprintln!( "Error: {} blob(s) are missing and --offline mode is enabled.", missing_blobs.len() @@ -1006,10 +2158,12 @@ async fn rollback_patches_inner( results, vendored_skipped, not_installed, + narrowed_out: Vec::new(), + aborted: true, }); } - if !args.common.silent && !args.common.json { + if !common.silent && !common.json { println!("Downloading {} missing blob(s)...", missing_blobs.len()); } @@ -1017,7 +2171,7 @@ async fn rollback_patches_inner( let client = match api_client { Some(c) => c, None => { - built_client = get_api_client_with_overrides(args.common.api_client_overrides()) + built_client = get_api_client_with_overrides(common.api_client_overrides()) .await .0; &built_client @@ -1025,7 +2179,7 @@ async fn rollback_patches_inner( }; let fetch_result = fetch_blobs_by_hash(&missing_blobs, &blobs_path, client, None).await; - if !args.common.silent && !args.common.json { + if !common.silent && !common.json { println!("{}", format_fetch_result(&fetch_result)); } @@ -1043,7 +2197,7 @@ async fn rollback_patches_inner( if !still_missing.is_empty() { // Errors print even under --silent — same contract as the // offline bail above (and same `--json` carrier). - if !args.common.json { + if !common.json { eprintln!( "{} blob(s) could not be downloaded. Cannot rollback.", still_missing.len() @@ -1082,12 +2236,14 @@ async fn rollback_patches_inner( results, vendored_skipped, not_installed, + narrowed_out: Vec::new(), + aborted: true, }); } } if all_packages.is_empty() && undiscovered_redirects.is_empty() { - if !args.common.silent && !args.common.json { + if !common.silent && !common.json { println!("No packages found that match patches to rollback"); } // `success: true` — per-package semantics for the `remove` @@ -1098,6 +2254,8 @@ async fn rollback_patches_inner( results: Vec::new(), vendored_skipped, not_installed, + narrowed_out: narrowed_out.clone(), + aborted: false, }); } @@ -1114,7 +2272,7 @@ async fn rollback_patches_inner( // Local go drops the project-local `replace`-redirect; everything // else — npm/pypi/gem and cargo (vendored or registry cache) — // restores in place from before-blobs. - let result = match try_rollback_local_go(purl, pkg_path, patch, &args.common).await { + let result = match try_rollback_local_go(purl, pkg_path, patch, common).await { Some(r) => r, None => { rollback_package_patch( @@ -1122,7 +2280,7 @@ async fn rollback_patches_inner( pkg_path, &patch.files, &blobs_path, - args.common.dry_run, + common.dry_run, ) .await } @@ -1133,7 +2291,7 @@ async fn rollback_patches_inner( // Errors print even under --silent ("errors only", never // "nothing"): with the summary muted, this line is the // silent run's only failure diagnostic. - if !args.common.json { + if !common.json { eprintln!( "Failed to rollback {}: {}", purl, @@ -1152,7 +2310,7 @@ async fn rollback_patches_inner( let Some(patch) = scoped_manifest.patches.get(purl) else { continue; }; - let Some(result) = try_rollback_local_go(purl, &args.common.cwd, patch, &args.common).await + let Some(result) = try_rollback_local_go(purl, &common.cwd, patch, common).await else { continue; }; @@ -1160,7 +2318,7 @@ async fn rollback_patches_inner( has_errors = true; // Errors print even under --silent — same contract as the // in-place loop above. - if !args.common.json { + if !common.json { eprintln!( "Failed to rollback {}: {}", purl, @@ -1176,6 +2334,8 @@ async fn rollback_patches_inner( results, vendored_skipped, not_installed, + narrowed_out, + aborted: false, }) } @@ -1214,18 +2374,20 @@ pub(crate) async fn rollback_patches( silent: bool, ecosystems: Option>, ) -> Result<(bool, Vec, Vec, Vec), String> { - let args = RollbackArgs { - identifier: identifier.map(String::from), - common: crate::args::GlobalArgs { - manifest_path: manifest_path.display().to_string(), - ecosystems, - silent, - dry_run, - ..common.clone() - }, - one_off: false, + let delegated_common = crate::args::GlobalArgs { + manifest_path: manifest_path.display().to_string(), + ecosystems, + silent, + dry_run, + ..common.clone() }; - let outcome = rollback_patches_inner(&args, manifest_path, None).await?; + let outcome = rollback_patches_inner( + &delegated_common, + manifest_path, + InnerSelection::Identifier(identifier), + None, + ) + .await?; Ok(( outcome.success, outcome.results, diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index cc17d0e6..bbc6f206 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -147,6 +147,18 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { } else if args.apply || args.sync { args.mode = Some(ScanMode::Agent); } + if !args.paths.is_empty() + && matches!(args.mode, Some(ScanMode::Hosted) | Some(ScanMode::Vendored)) + { + // Hosted/vendored consume root lockfiles (and, for vendored, the + // WHOLE manifest) — path scoping cannot mean anything coherent + // there. Same phrasing family as the conflicts above. + return Err(format!( + "path targeting cannot be used with --mode {}: it applies to \ + agent-mode and read-only scans", + args.mode.expect("checked Some above").cli_name(), + )); + } if args.detached && args.mode != Some(ScanMode::Vendored) { // "required" phrasing matches clap's requires errors — the // scan_vendor_e2e contract test accepts exactly that shape. @@ -160,6 +172,18 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { #[derive(Args)] pub struct ScanArgs { + /// Optional path globs scoping DISCOVERY to packages installed under + /// matching paths (e.g. `packages/foo`, `apps/**`). A bare directory + /// pattern scopes its whole subtree. Scoping selects which PACKAGES + /// are considered; the prune universe (`--prune`/`--sync`) always + /// stays the full crawl, so a scoped scan never prunes out-of-scope + /// manifest entries. Lockfile-only and vendor-ledger supplements have + /// no installed path and are excluded from a path-scoped scan (a + /// run-level warning carries the count). Applies to agent-mode and + /// read-only scans; rejected with `--mode hosted`/`--mode vendored` + /// (their lockfile rewiring is whole-project by construction). + pub paths: Vec, + #[command(flatten)] pub common: GlobalArgs, @@ -1199,12 +1223,12 @@ pub(super) async fn hosted_wiring_retained_purls( out } -/// Detail for [`HOSTED_WIRING_RETAINED`]. Names the package(s) and the two -/// real options — stay hosted, or migrate via the vendored flow (which -/// reconciles the superseded ledger entries per package). It must never -/// advise hand-deleting the redirect ledger (the only store of the -/// pre-redirect originals plus the records VEX reads) and never promise a -/// hosted→agent unwind that does not exist for npm/yarn. +/// Detail for [`HOSTED_WIRING_RETAINED`]. Names the package(s) and the +/// real options — stay hosted, migrate via the vendored flow (which +/// reconciles the superseded ledger entries per package), or unwind via +/// `rollback`. It must never advise hand-deleting the redirect ledger +/// (the only store of the pre-redirect originals plus the records VEX +/// reads). pub(super) fn hosted_wiring_retained_detail(retained: &[String]) -> String { let list = retained.join(", "); format!( @@ -1212,15 +1236,16 @@ pub(super) fn hosted_wiring_retained_detail(retained: &[String]) -> String { The lockfile still resolves these package(s) to the hosted patch \ server and `.socket/vendor/redirect-state.json` still records the \ redirect — an agent run patches installed files in place but does \ - NOT unwind hosted lockfile wiring (no hosted revert exists for \ - this ecosystem yet), so installs keep fetching these package(s) \ - from the patch server. Either keep the project in hosted mode \ - (`scan --mode hosted`), or migrate to committed artifacts with \ - `scan --mode vendored`, which takes these package(s) over in the \ - lockfile and reconciles the superseded redirect ledger entries. \ - Do not delete `.socket/vendor/redirect-state.json` by hand: it \ - holds the recorded pre-redirect lockfile originals (the only \ - revert data) and the redirect records VEX reads." + NOT unwind hosted lockfile wiring, so installs keep fetching \ + these package(s) from the patch server. Either keep the project \ + in hosted mode (`scan --mode hosted`), migrate to committed \ + artifacts with `scan --mode vendored` (which takes these \ + package(s) over in the lockfile and reconciles the superseded \ + redirect ledger entries), or unwind the redirects with \ + `socket-patch rollback`. Do not delete \ + `.socket/vendor/redirect-state.json` by hand: it holds the \ + recorded pre-redirect lockfile originals (the only revert data) \ + and the redirect records VEX reads." ) } @@ -1336,6 +1361,16 @@ pub async fn run(mut args: ScanArgs) -> i32 { return 2; } + // Positional PATH globs (see `ScanArgs::paths`). An unparseable glob + // is a usage error, same exit-2 stderr shape as the mode conflicts. + let path_scope = match crate::path_scope::PathScope::parse(&args.paths) { + Ok(s) => s, + Err(message) => { + eprintln!("error: {message}"); + return 2; + } + }; + // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the // network; operations that need remote data fail loudly). Scan's // patch discovery IS remote data — proceeding would POST the crawled @@ -1360,6 +1395,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "canAccessPaidPatches": false, "packages": [], "updates": [], + "paths": path_scope.raw(), }); println!( "{}", @@ -1486,11 +1522,17 @@ pub async fn run(mut args: ScanArgs) -> i32 { layout_refusals.push((code.to_string(), detail)); } } + // Supplement purls, captured for the path-scope filter below: their + // `path` fields are fabricated placeholders, so a path-scoped scan + // excludes them (with a counted warning) instead of glob-matching + // meaningless paths. + let mut supplement_purls: HashSet = HashSet::new(); if !lockfile_only.packages.is_empty() { for pkg in &lockfile_only.packages { if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { *eco_counts.entry(eco).or_insert(0) += 1; } + supplement_purls.insert(pkg.purl.clone()); } all_crawled.extend(lockfile_only.packages.iter().cloned()); } @@ -1499,6 +1541,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { *eco_counts.entry(eco).or_insert(0) += 1; } + supplement_purls.insert(pkg.purl.clone()); } all_crawled.extend(ledger_supplement); @@ -1537,6 +1580,39 @@ pub async fn run(mut args: ScanArgs) -> i32 { all_crawled }; + // PATH scoping — applied strictly AFTER the `scanned_purls` capture + // above (the prune universe stays full-crawl: `scan PATHS --prune` + // must never treat out-of-scope packages as uninstalled) and after the + // `--ecosystems` filter. A purl is in scope when ANY genuinely-crawled + // copy of it sits under a matching path. + let filtered_crawled: Vec<_> = if path_scope.is_empty() { + filtered_crawled + } else { + let excluded_supplements = filtered_crawled + .iter() + .filter(|pkg| supplement_purls.contains(&pkg.purl)) + .count(); + if excluded_supplements > 0 { + layout_refusals.push(( + "path_scope_excluded_supplements".to_string(), + format!( + "{excluded_supplements} lockfile-only/vendor-ledger package(s) have \ + no installed path and were excluded from the path-scoped scan" + ), + )); + } + let in_scope: HashSet = filtered_crawled + .iter() + .filter(|pkg| !supplement_purls.contains(&pkg.purl)) + .filter(|pkg| path_scope.matches(&args.common.cwd, &pkg.path)) + .map(|pkg| pkg.purl.clone()) + .collect(); + filtered_crawled + .into_iter() + .filter(|pkg| in_scope.contains(&pkg.purl)) + .collect() + }; + let all_purls: Vec = filtered_crawled.iter().map(|p| p.purl.clone()).collect(); let package_count = all_purls.len(); @@ -1582,6 +1658,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "canAccessPaidPatches": false, "packages": [], "updates": [], + "paths": path_scope.raw(), }); // PnP layout refusals: additive top-level `warnings` (omitted // when empty — run-level warnings precedent) so a JSON consumer @@ -1807,6 +1884,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "canAccessPaidPatches": false, "packages": [], "updates": [], + "paths": path_scope.raw(), }); println!( "{}", @@ -1937,6 +2015,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "paidPatches": paid_patches, "canAccessPaidPatches": can_access_paid_patches, "packages": all_packages_with_patches, + "paths": path_scope.raw(), "updates": updates.iter().map(|u| serde_json::json!({ "purl": u.purl, "oldUuid": u.old_uuid, diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index db41aee6..977284f4 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -27,8 +27,8 @@ use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendor use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::vendor::{ self, ecosystem_dir_for_purl, load_state, lock_inventory, lookup_entry, registry_fetch, - save_state, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, VendorSource, - VendorState, VendorWarning, + save_state, RevertOpts, RevertOutcome, VendorEntry, VendorOutcome, VendorServiceConfig, + VendorSource, VendorState, VendorWarning, }; use socket_patch_core::vex::time::now_rfc3339; use std::collections::{HashMap, HashSet}; @@ -171,16 +171,27 @@ pub(crate) async fn dispatch_revert_one( entry: &VendorEntry, project_root: &Path, dry_run: bool, +) -> RevertOutcome { + dispatch_revert_one_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`dispatch_revert_one`] with full [`RevertOpts`]: `keep_artifact` is the +/// `rollback/remove --preserve-state` shape — restore the lockfile wiring +/// but keep the artifact dir (the caller keeps the ledger entry). +pub(crate) async fn dispatch_revert_one_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, ) -> RevertOutcome { match entry.ecosystem.as_str() { - "npm" => vendor::npm_flavor::revert_npm_any(entry, project_root, dry_run).await, - "pypi" => vendor::pypi::revert_pypi(entry, project_root, dry_run).await, - "gem" => vendor::gem::revert_gem(entry, project_root, dry_run).await, - "cargo" => vendor::cargo::revert_cargo_vendor(entry, project_root, dry_run).await, - "golang" => vendor::golang::revert_go_vendor(entry, project_root, dry_run).await, - "composer" => vendor::composer_lock::revert_composer(entry, project_root, dry_run).await, - "nuget" => vendor::nuget_feed::revert_nuget(entry, project_root, dry_run).await, - "maven" => vendor::maven_repo::revert_maven(entry, project_root, dry_run).await, + "npm" => vendor::npm_flavor::revert_npm_any_opts(entry, project_root, opts).await, + "pypi" => vendor::pypi::revert_pypi_opts(entry, project_root, opts).await, + "gem" => vendor::gem::revert_gem_opts(entry, project_root, opts).await, + "cargo" => vendor::cargo::revert_cargo_vendor_opts(entry, project_root, opts).await, + "golang" => vendor::golang::revert_go_vendor_opts(entry, project_root, opts).await, + "composer" => vendor::composer_lock::revert_composer_opts(entry, project_root, opts).await, + "nuget" => vendor::nuget_feed::revert_nuget_opts(entry, project_root, opts).await, + "maven" => vendor::maven_repo::revert_maven_opts(entry, project_root, opts).await, other => RevertOutcome::failed(format!( "this build has no vendor backend for ecosystem `{other}`" )), @@ -1055,6 +1066,7 @@ pub(crate) async fn vendor_records( &common.cwd, ledger, candidate, + false, ) .await { diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index ff44743f..550b068b 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -10,6 +10,7 @@ pub mod commands; pub(crate) mod ecosystem_dispatch; pub mod json_envelope; pub mod output; +pub mod path_scope; pub mod update_notifier; use clap::{Parser, Subcommand}; @@ -122,8 +123,10 @@ impl Commands { /// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). /// /// Used by [`parse_with_uuid_fallback`] to detect the convenience form -/// `socket-patch ` and rewrite it to `socket-patch get `. -fn looks_like_uuid(s: &str) -> bool { +/// `socket-patch ` and rewrite it to `socket-patch get `, and +/// by rollback's target resolver to decide whether a no-match identifier +/// error should hint at the path-glob spelling. +pub(crate) fn looks_like_uuid(s: &str) -> bool { let parts: Vec<&str> = s.split('-').collect(); if parts.len() != 5 { return false; diff --git a/crates/socket-patch-cli/src/path_scope.rs b/crates/socket-patch-cli/src/path_scope.rs new file mode 100644 index 00000000..3a47aed2 --- /dev/null +++ b/crates/socket-patch-cli/src/path_scope.rs @@ -0,0 +1,260 @@ +//! Path-glob scoping shared by `scan` and `rollback`. +//! +//! A [`PathScope`] holds the user's positional PATH patterns and answers +//! "is this installed-package directory in scope?". Matching rules +//! (documented in CLI_CONTRACT.md): +//! +//! * Patterns use Unix-shell glob syntax (`*`, `?`, `[...]`, `**`) with +//! `require_literal_separator` — `*` never crosses a `/`, `**` spans +//! directories. +//! * Relative patterns match the package path relative to `--cwd`; +//! absolute patterns match the absolute path (the only way to scope +//! packages outside the project tree, e.g. `--global` stores). +//! * A pattern that matches any ANCESTOR directory of the package path +//! also matches, so `scan packages/foo` scopes the whole subtree +//! without needing an explicit `packages/foo/**`. +//! * Matching is purely textual on `/`-normalized paths — no filesystem +//! access, no symlink resolution. +//! +//! Scoping is PURL-level at the call sites: a package is in scope when +//! any of its discovered copies matches, and scoped operations then act +//! on every copy of the selected package. + +use glob::{MatchOptions, Pattern}; +use std::path::Path; + +/// `*` and `?` stay within one path component; `**` is the only way to +/// cross directories. Case-sensitive on Unix; case-insensitive on Windows, +/// whose filesystems are case-insensitive (a drive-letter case mismatch +/// must not silently empty a scope). +const MATCH_OPTIONS: MatchOptions = MatchOptions { + case_sensitive: cfg!(not(windows)), + require_literal_separator: true, + require_literal_leading_dot: false, +}; + +/// Parsed positional PATH patterns. +#[derive(Debug)] +pub struct PathScope { + patterns: Vec<(Pattern, bool)>, // (compiled, is_absolute) + raw: Vec, +} + +/// Normalize a pattern for matching: strip a leading `./`, trailing `/`s, +/// and convert `\` to `/` so Windows-style input still compiles to the +/// separator the match side uses. +fn normalize_pattern(raw: &str) -> String { + let mut p = raw.replace('\\', "/"); + while let Some(stripped) = p.strip_prefix("./") { + p = stripped.to_string(); + } + while p.len() > 1 && p.ends_with('/') { + p.pop(); + } + p +} + +/// `/`-normalized string form of a path for textual glob matching. +fn slashed(path: &Path) -> String { + let s = path.to_string_lossy(); + if std::path::MAIN_SEPARATOR == '/' { + s.into_owned() + } else { + s.replace(std::path::MAIN_SEPARATOR, "/") + } +} + +impl PathScope { + /// Compile the user's patterns. An unparseable glob is a usage error — + /// the caller maps `Err` to exit 2 like any other invalid argument. + pub fn parse(raw_patterns: &[String]) -> Result { + let mut patterns = Vec::with_capacity(raw_patterns.len()); + let mut raw = Vec::with_capacity(raw_patterns.len()); + for r in raw_patterns { + let normalized = normalize_pattern(r); + if normalized.is_empty() { + return Err(format!("invalid path pattern {r:?}: empty pattern")); + } + let compiled = Pattern::new(&normalized) + .map_err(|e| format!("invalid path pattern {r:?}: {e}"))?; + // `has_root` rather than `is_absolute`: identical on Unix, but a + // Windows drive-RELATIVE rooted pattern (`/global/store`, no + // drive letter) is not `is_absolute` — and a rooted pattern can + // never be cwd-relative, so it must match against the absolute + // candidate path either way. + let is_absolute = Path::new(&normalized).has_root(); + patterns.push((compiled, is_absolute)); + raw.push(r.clone()); + } + Ok(Self { patterns, raw }) + } + + /// No patterns given — scoping is inactive and every path matches. + pub fn is_empty(&self) -> bool { + self.patterns.is_empty() + } + + /// The patterns exactly as the user typed them (for envelopes/errors). + pub fn raw(&self) -> &[String] { + &self.raw + } + + /// Is `candidate` (an absolute package directory from a crawler) in + /// scope? An empty scope matches everything. + pub fn matches(&self, cwd: &Path, candidate: &Path) -> bool { + if self.patterns.is_empty() { + return true; + } + // Textual prefix-strip against the absolutized cwd; crawler paths + // are already absolute, so this stays a pure string operation. + let abs_cwd = std::path::absolute(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let abs = slashed(candidate); + let rel = candidate + .strip_prefix(&abs_cwd) + .ok() + .or_else(|| candidate.strip_prefix(cwd).ok()) + .map(slashed); + self.patterns.iter().any(|(pattern, is_absolute)| { + let target = if *is_absolute { + Some(abs.as_str()) + } else { + rel.as_deref() + }; + match target { + Some(t) => matches_path_or_ancestor(pattern, t), + // A relative pattern can never match a path outside cwd. + None => false, + } + }) + } +} + +/// True when `pattern` matches `path` or any of its ancestor prefixes +/// (successively dropping trailing `/`-components), so a plain directory +/// pattern scopes its whole subtree. +fn matches_path_or_ancestor(pattern: &Pattern, path: &str) -> bool { + let mut current = path; + loop { + if pattern.matches_with(current, MATCH_OPTIONS) { + return true; + } + match current.rfind('/') { + // Root ancestor of an absolute path is "/" — test it too, then stop. + Some(0) if current.len() > 1 => current = "/", + Some(idx) => current = ¤t[..idx], + None => return false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn scope(patterns: &[&str]) -> PathScope { + PathScope::parse(&patterns.iter().map(|s| s.to_string()).collect::>()) + .expect("test patterns are valid") + } + + fn cwd() -> PathBuf { + PathBuf::from("/proj") + } + + #[test] + fn empty_scope_matches_everything() { + let s = scope(&[]); + assert!(s.is_empty()); + assert!(s.matches(&cwd(), Path::new("/anywhere/at/all"))); + } + + #[test] + fn exact_relative_path_matches() { + let s = scope(&["node_modules/lodash"]); + assert!(s.matches(&cwd(), Path::new("/proj/node_modules/lodash"))); + assert!(!s.matches(&cwd(), Path::new("/proj/node_modules/left-pad"))); + } + + #[test] + fn directory_pattern_scopes_its_subtree() { + // No `/**` needed: matching an ancestor is enough. + let s = scope(&["packages/foo"]); + assert!(s.matches( + &cwd(), + Path::new("/proj/packages/foo/node_modules/lodash") + )); + assert!(!s.matches( + &cwd(), + Path::new("/proj/packages/bar/node_modules/lodash") + )); + } + + #[test] + fn star_does_not_cross_separators() { + let s = scope(&["packages/*"]); + // `packages/*` matches the ancestor `packages/foo`, scoping its tree… + assert!(s.matches( + &cwd(), + Path::new("/proj/packages/foo/node_modules/lodash") + )); + // …but `nested/*` must not match a deeper path component-wise. + let s2 = scope(&["*"]); + assert!(s2.matches(&cwd(), Path::new("/proj/anything"))); + let s3 = scope(&["src/*.js"]); + assert!(!s3.matches(&cwd(), Path::new("/proj/src/deep/file.js"))); + } + + #[test] + fn double_star_spans_directories() { + let s = scope(&["packages/**/lodash"]); + assert!(s.matches( + &cwd(), + Path::new("/proj/packages/foo/node_modules/lodash") + )); + assert!(!s.matches(&cwd(), Path::new("/proj/apps/foo/node_modules/lodash"))); + } + + #[test] + fn absolute_pattern_matches_paths_outside_cwd() { + let s = scope(&["/global/store"]); + assert!(s.matches(&cwd(), Path::new("/global/store/lib/node_modules/x"))); + assert!(!s.matches(&cwd(), Path::new("/other/store/lib"))); + } + + #[test] + fn relative_pattern_never_matches_outside_cwd() { + let s = scope(&["store"]); + assert!(!s.matches(&cwd(), Path::new("/global/store"))); + } + + #[test] + fn leading_dot_slash_and_trailing_slash_are_normalized() { + let s = scope(&["./packages/foo/"]); + assert!(s.matches(&cwd(), Path::new("/proj/packages/foo/nested"))); + } + + #[test] + fn invalid_pattern_is_a_parse_error() { + let err = PathScope::parse(&["packages/[".to_string()]).unwrap_err(); + assert!(err.contains("invalid path pattern"), "{err}"); + let err = PathScope::parse(&["".to_string()]).unwrap_err(); + assert!(err.contains("empty pattern"), "{err}"); + } + + #[test] + fn case_sensitivity_follows_the_platform() { + // Sensitive on Unix; insensitive on Windows (whose filesystems + // are case-insensitive — a drive-letter case mismatch must not + // silently empty a scope). Mirrors MATCH_OPTIONS. + let s = scope(&["Packages/foo"]); + let matches = s.matches(&cwd(), Path::new("/proj/packages/foo/x")); + assert_eq!(matches, cfg!(windows)); + } + + #[test] + fn matching_is_purely_textual() { + // Paths that do not exist on disk still match — no fs access. + let s = scope(&["no/such/dir"]); + assert!(s.matches(&cwd(), Path::new("/proj/no/such/dir/pkg"))); + } +} diff --git a/crates/socket-patch-cli/tests/cli_parse_remove.rs b/crates/socket-patch-cli/tests/cli_parse_remove.rs index 519f5ea8..3a73819e 100644 --- a/crates/socket-patch-cli/tests/cli_parse_remove.rs +++ b/crates/socket-patch-cli/tests/cli_parse_remove.rs @@ -192,6 +192,7 @@ async fn run_missing_manifest_exits_one() { }, identifier: "pkg:npm/foo@1".to_string(), skip_rollback: false, + preserve_state: false, }; let exit = run(args).await; assert_eq!(exit, 1, "missing manifest must exit 1"); @@ -271,6 +272,7 @@ async fn run_removes_matching_patch_and_exits_zero() { // Skip rollback so we exercise the manifest-mutation path without // needing installed packages on disk. skip_rollback: true, + preserve_state: false, }; let exit = run(args).await; assert_eq!(exit, 0, "removing an existing patch must exit 0"); diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index d5d4825c..d46f04b8 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -40,6 +40,7 @@ fn bool_flags(a: &RollbackArgs) -> Vec<(&'static str, bool)> { ("debug", a.common.debug), ("no_telemetry", a.common.no_telemetry), ("one_off", a.one_off), + ("preserve_state", a.preserve_state), ] } @@ -61,7 +62,8 @@ fn assert_only_true(a: &RollbackArgs, expected_true: &[&str]) { #[test] fn defaults_no_positional() { let args = parse_rollback(&[]); - assert_eq!(args.identifier, None); + assert!(args.targets.is_empty()); + assert!(!args.preserve_state); assert_eq!(args.common.cwd, PathBuf::from(".")); assert!(!args.common.dry_run); assert!(!args.common.silent); @@ -91,15 +93,15 @@ fn defaults_no_positional() { fn positional_identifier_uuid() { let args = parse_rollback(&["80630680-4da6-45f9-bba8-b888e0ffd58c"]); assert_eq!( - args.identifier, - Some("80630680-4da6-45f9-bba8-b888e0ffd58c".to_string()) + args.targets, + vec!["80630680-4da6-45f9-bba8-b888e0ffd58c".to_string()] ); } #[test] fn positional_identifier_purl() { let args = parse_rollback(&["pkg:npm/foo@1"]); - assert_eq!(args.identifier, Some("pkg:npm/foo@1".to_string())); + assert_eq!(args.targets, vec!["pkg:npm/foo@1".to_string()]); } #[test] @@ -222,7 +224,7 @@ fn ecosystems_csv_split() { #[test] fn positional_plus_flags() { let args = parse_rollback(&["pkg:npm/foo@1", "--dry-run", "--json"]); - assert_eq!(args.identifier, Some("pkg:npm/foo@1".to_string())); + assert_eq!(args.targets, vec!["pkg:npm/foo@1".to_string()]); assert!(args.common.dry_run); assert!(args.common.json); // Exactly these two flags — nothing else rode along on the combination. @@ -316,6 +318,7 @@ fn all_bools_settable_together() { "--debug", "--no-telemetry", "--one-off", + "--preserve-state", ]); assert_only_true( &args, @@ -330,6 +333,7 @@ fn all_bools_settable_together() { "debug", "no_telemetry", "one_off", + "preserve_state", ], ); } @@ -363,20 +367,45 @@ fn all_short_flags_map_to_distinct_fields() { fn bare_bool_does_not_consume_next_token() { let args = parse_rollback(&["--one-off", "pkg:npm/foo@1"]); assert!(args.one_off); - // The trailing token landed in `identifier`, not as a value for `--one-off`. - assert_eq!(args.identifier, Some("pkg:npm/foo@1".to_string())); + // The trailing token landed in `targets`, not as a value for `--one-off`. + assert_eq!(args.targets, vec!["pkg:npm/foo@1".to_string()]); assert_only_true(&args, &["one_off"]); } -/// A second positional is rejected — `identifier` takes exactly one value, so -/// a stray extra arg must not be silently swallowed. +/// Variadic targets (v4 duality rework): multiple positionals parse in +/// order. Before v4 a second positional was an UnknownArgument error — +/// pinned here as a DELIBERATE contract change (MAJOR), so wrappers that +/// relied on the rejection get a test-visible flip instead of a silent one. #[test] -fn second_positional_fails() { - let err = match Cli::try_parse_from(["socket-patch", "rollback", "a", "b"]) { - Ok(_) => panic!("expected parse failure for extra positional"), - Err(e) => e, - }; - assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +fn multiple_targets_parse_in_order() { + let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]); + assert_eq!( + args.targets, + vec![ + "pkg:npm/foo@1".to_string(), + "packages/api/**".to_string(), + "b0630680-4da6-45f9-bba8-b888e0ffd58c".to_string(), + ] + ); + assert_only_true(&args, &[]); +} + +/// `--preserve-state` parses and flips only its own field. +#[test] +fn preserve_state_long() { + let args = parse_rollback(&["--preserve-state"]); + assert!(args.preserve_state); + assert_only_true(&args, &["preserve_state"]); +} + +/// `--preserve-state` composes with targets: the flag never consumes the +/// following token. +#[test] +fn preserve_state_does_not_consume_next_token() { + let args = parse_rollback(&["--preserve-state", "pkg:npm/foo@1"]); + assert!(args.preserve_state); + assert_eq!(args.targets, vec!["pkg:npm/foo@1".to_string()]); + assert_only_true(&args, &["preserve_state"]); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 2247ae36..1fe14f53 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -508,6 +508,9 @@ fn scan_json_empty_cwd_emits_updates_key() { "canAccessPaidPatches": false, "packages": [], "updates": [], + // v4 duality rework: the positional PATH globs are echoed on every + // scan envelope, always present (empty when no scoping was given). + "paths": [], }); assert_eq!( v, @@ -703,10 +706,11 @@ fn legacy_mode_spellings_still_parse() { /// `Debug` derive) are formatted individually. fn snap(a: &ScanArgs) -> String { format!( - "{:?} batch_size={} apply={} prune={} sync={} vendor={} detached={} \ + "{:?} paths={:?} batch_size={} apply={} prune={} sync={} vendor={} detached={} \ redirect={} mode={:?} all_releases={} vex={:?} vex_product={:?} \ vex_no_verify={} vex_doc_id={:?} vex_compact={}", a.common, + a.paths, a.batch_size, a.apply, a.prune, diff --git a/crates/socket-patch-cli/tests/cli_remove_silent.rs b/crates/socket-patch-cli/tests/cli_remove_silent.rs index e6f35a44..1b08e7cd 100644 --- a/crates/socket-patch-cli/tests/cli_remove_silent.rs +++ b/crates/socket-patch-cli/tests/cli_remove_silent.rs @@ -193,8 +193,9 @@ fn write_vendor_state_wired(root: &Path, purl: &str, uuid: &str, detached: bool, } /// A wiring record naming a file the npm revert backend does not edit: -/// the revert still succeeds (artifact deleted, ledger entry dropped) but -/// emits a `vendor_lock_entry_drifted` warning — fully offline. +/// the backend emits a `vendor_lock_entry_drifted` warning and DRIFT-KEEPS +/// (`kept_artifact`) — since v5.0 the caller then keeps the ledger entry +/// AND the manifest entry, and an all-kept remove exits 1 — fully offline. const DRIFTED_WIRING: &str = r#"[{ "file": "weird.txt", "kind": "npm_lock_entry", "action": "added", "key": "node_modules/x" }]"#; /// `--silent` must also gate the vendor-revert chatter on the manifest @@ -365,7 +366,16 @@ fn remove_silent_suppresses_vendor_revert_warnings() { write_vendor_state_wired(tmp.path(), purl, uuid, false, DRIFTED_WIRING); let (code, _stdout, stderr) = run_remove(tmp.path(), &[purl, "--silent", "--yes"]); - assert_eq!(code, 0, "remove must succeed; stderr={stderr:?}"); + // v4: a drift-kept revert KEEPS the vendored state and the manifest + // entry (the RevertOutcome contract), so an all-matches-kept remove is + // a partialFailure — nothing was removed. The error line still prints + // under --silent ("errors only, never nothing"); the backend WARNING + // chatter stays suppressed, which is what this test pins. + assert_eq!(code, 1, "all-kept remove is a partialFailure; stderr={stderr:?}"); + assert!( + stderr.contains("drift-kept"), + "the drift-kept error must print even under --silent; got {stderr:?}" + ); assert!( !stderr.contains("Warning ("), "--silent must suppress backend revert warnings; got {stderr:?}" @@ -376,11 +386,26 @@ fn remove_silent_suppresses_vendor_revert_warnings() { make_socket_dir(tmp2.path()); write_vendor_state_wired(tmp2.path(), purl, uuid, false, DRIFTED_WIRING); let (loud_code, _loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); - assert_eq!(loud_code, 0); + assert_eq!(loud_code, 1); assert!( loud_stderr.contains("Warning (vendor_lock_entry_drifted)"), "non-silent run must print the backend warning; got {loud_stderr:?}" ); + // The drift-keep must leave BOTH stores intact: ledger entry and + // manifest entry survive for a later normalize + retry. + assert!( + tmp2.path().join(".socket/vendor/state.json").exists(), + "drift-kept ledger entry must survive" + ); + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp2.path().join(".socket/manifest.json")) + .expect("manifest survives"), + ) + .expect("manifest parses"); + assert!( + manifest["patches"].get(purl).is_some(), + "drift-kept manifest entry must survive; got {manifest}" + ); } /// The `--dry-run` "Would revert vendoring for ..." preview (stdout) is diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 227262e5..002a94dd 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -542,7 +542,9 @@ fn test_gem_full_lifecycle() { ); // -- ROLLBACK: restore original files ------------------------------------- - assert_run_ok(cwd, &["rollback"], "rollback"); + // --preserve-state keeps the manifest entry (+ blobs) so the re-apply + // below still has a record to work from; the v4 default removes both. + assert_run_ok(cwd, &["rollback", "--preserve-state"], "rollback"); assert_before_hashes(&gem_dir, files); // -- APPLY: re-apply from manifest ---------------------------------------- diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 5a0b30ea..6f618328 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -36,7 +36,7 @@ //! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h (CVE-2021-44906) | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | GHSA-gm62-xv2j-4w53 &co | //! | Cargo | `pkg:cargo/traitobject@0.1.1` | `cf2e6f58-d9fa-4096-9151-c34afa717f89` | GHSA-pp8r-vv2j-9j5v | -//! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_UUIDS`] (four today) | GHSA-m42x-37p3-fv5w (CVE-2020-8162), GHSA-w749-p3v6-hccq (CVE-2022-21831), GHSA-9xrj-h377-fr87 (CVE-2026-33195), GHSA-r4mg-4433-c7g3 (CVE-2025-24293) | +//! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_UUIDS`] (five today) | GHSA-m42x-37p3-fv5w (CVE-2020-8162), GHSA-w749-p3v6-hccq (CVE-2022-21831), GHSA-9xrj-h377-fr87 (CVE-2026-33195), GHSA-r4mg-4433-c7g3 (CVE-2025-24293), GHSA-xr9x-r78c-5hrm (CVE-2026-66066) | //! //! `docs/testing/hosted-production-e2e.md` explains how these were chosen and //! how to re-pick one if it is ever withdrawn. @@ -162,10 +162,16 @@ const GEM_UUIDS: &[&str] = &[ // Community Patch header. "eeb6bf9f-96c0-4963-a0f1-2e88f91f8b1a", // GHSA-r4mg-4433-c7g3 / CVE-2025-24293 (image_processing_transformer.rb), - // published 2026-08-20T20:31Z — the fourth advisory, and the one the - // server-ranked selection now wires. /patch/view blobs live-verified - // 2026-08-20: carries the Socket Community Patch header. + // published 2026-08-20T20:31Z — the fourth advisory. /patch/view blobs + // live-verified 2026-08-20: carries the Socket Community Patch header. "c1a1cd3c-b670-4e44-b4fa-1a63ecd42db6", + // GHSA-xr9x-r78c-5hrm / CVE-2026-66066 (Active Storage libvips variant + // processing arbitrary-file-read/RCE) — the fifth advisory, published + // 2026-08-24, and the one the server-ranked selection now wires. + // Live-verified 2026-08-24 via the public proxy /patch/view: free tier, + // purl pkg:gem/activestorage@6.0.3?platform=ruby, single CRITICAL + // advisory GHSA-xr9x-r78c-5hrm. + "9c2b4925-b413-4a3a-bb3a-9990440fb446", ]; /// Header the patch service injects into patched npm / PyPI source files. diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 61689c4d..91af8d1d 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -229,7 +229,9 @@ fn test_npm_full_lifecycle() { assert!(has_cve, "vulnerability list should include CVE-2021-44906"); // -- ROLLBACK: restore original file ----------------------------------- - assert_run_ok(cwd, &["rollback"], "rollback"); + // --preserve-state keeps the manifest entry (+ blobs) so the re-apply + // below still has a record to work from; the v4 default removes both. + assert_run_ok(cwd, &["rollback", "--preserve-state"], "rollback"); assert_eq!( git_sha256_file(&index_js), diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index 2c9a5eda..9b2b4a99 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -331,7 +331,9 @@ fn test_pypi_full_lifecycle() { assert!(has_cve, "vulnerability list should include CVE-2026-25580"); // -- ROLLBACK: restore original files ---------------------------------- - assert_run_ok(cwd, &["rollback"], "rollback"); + // --preserve-state keeps the manifest entry (+ blobs) so the re-apply + // below still has a record to work from; the v4 default removes both. + assert_run_ok(cwd, &["rollback", "--preserve-state"], "rollback"); // Verify files are restored to their original state. for (rel_path, info) in files { diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index eaed0f01..087a9fd2 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -352,6 +352,11 @@ fn rollback_global_prefix_uses_explicit_path() { assert_eq!(code, 0, "empty rollback → exit 0; stdout={stdout}"); assert_rollback_noop(&stdout); + // The v4 default dropped the not-installed entry from the manifest on + // the empty run above (rollback now cleans up by default) — re-seed it + // so the positive control below has an entry to resolve. + write_manifest(tmp.path(), PREFIX_PURL); + // Positive control: plant the matching package under the prefix. The // rollback must now report a per-package result whose `path` lives // inside the explicit prefix — proving the prefix (not the real npm diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index e71f5c99..13bc4630 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -220,6 +220,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() { make_writable(&lib_file); let args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().join("proj"), org: Some(ORG.to_string()), @@ -335,6 +336,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { make_writable(&lib_file); let args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().join("proj"), org: Some(ORG.to_string()), @@ -436,6 +438,7 @@ async fn cargo_crawler_finds_real_fetched_crate() { std::env::set_var("CARGO_HOME", &cargo_home); let args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().join("proj"), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_edge_cases.rs b/crates/socket-patch-cli/tests/in_process_edge_cases.rs index 37ba11ad..7894ec93 100644 --- a/crates/socket-patch-cli/tests/in_process_edge_cases.rs +++ b/crates/socket-patch-cli/tests/in_process_edge_cases.rs @@ -554,7 +554,8 @@ async fn rollback_already_original_short_circuits() { verbose: false, ..socket_patch_cli::args::GlobalArgs::default() }, - identifier: None, + targets: Vec::new(), + preserve_state: false, one_off: false, }; let target = tmp.path().join("node_modules/already-orig/index.js"); diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index b53ce12e..1e70fd6e 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -199,6 +199,7 @@ async fn gem_install_scan_sync_patches_real_file() { .await; let args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -310,6 +311,7 @@ async fn gem_crawler_finds_real_installed_gem() { .await; let args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index 244ee6b3..862234a4 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -217,6 +217,7 @@ async fn mount_view( fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), @@ -489,6 +490,7 @@ async fn remove_base_purl_clears_all_platforms_and_rolls_back() { ..socket_patch_cli::args::GlobalArgs::default() }, skip_rollback: false, + preserve_state: false, }; let code = remove_run(remove_args).await; assert_eq!(code, 0, "remove base PURL should succeed (exit 0)"); @@ -522,7 +524,8 @@ fn delete_darwin_before_blob(cwd: &Path) -> String { fn rollback_args(cwd: &Path, api_url: String, offline: bool) -> RollbackArgs { RollbackArgs { - identifier: None, + targets: Vec::new(), + preserve_state: false, common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), @@ -584,14 +587,13 @@ async fn rollback_succeeds_when_uninstalled_sibling_before_blob_unfetchable() { .map(|r| r.url.path().to_string()) .collect::>() ); - // Rollback is not remove: both variants stay recorded. - let mut keys = manifest_keys(tmp.path()); - keys.sort(); - let mut expected = vec![qualified(PLATFORM_INSTALLED), qualified(PLATFORM_OTHER)]; - expected.sort(); - assert_eq!( - keys, expected, - "rollback must leave both variants in the manifest" + // v4 default: rollback removes the rolled-back group from the + // manifest — the attempted variant AND its narrowed-away sibling + // (half a variant group must never linger). --preserve-state keeps + // them; the default cleans up. + assert!( + manifest_keys(tmp.path()).is_empty(), + "the rolled-back variant group must leave the manifest" ); } @@ -684,6 +686,7 @@ async fn remove_succeeds_when_uninstalled_sibling_before_blob_unfetchable() { ..socket_patch_cli::args::GlobalArgs::default() }, skip_rollback: false, + preserve_state: false, }) .await; assert_eq!( @@ -727,7 +730,8 @@ async fn rollback_all_over_broad_manifest_succeeds() { ); let rollback_args = RollbackArgs { - identifier: None, + targets: Vec::new(), + preserve_state: false, common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -746,16 +750,11 @@ async fn rollback_all_over_broad_manifest_succeeds() { ORIGINAL_BYTES, "rollback must restore exactly the original gem file bytes" ); - // Rollback restores files but, unlike `remove`, must NOT prune the - // manifest — both platform variants stay recorded so they can be - // re-applied. (If this ever flips to empty, rollback has silently become - // a destructive remove.) - let mut keys = manifest_keys(tmp.path()); - keys.sort(); - let mut expected = vec![qualified(PLATFORM_INSTALLED), qualified(PLATFORM_OTHER)]; - expected.sort(); - assert_eq!( - keys, expected, - "rollback must leave both variants in the manifest (it is not a remove)" + // v4 default: rollback removes the rolled-back group from the manifest + // — the attempted variant AND its narrowed-away sibling. (Keeping the + // records for a later re-apply is now `--preserve-state`'s job.) + assert!( + manifest_keys(tmp.path()).is_empty(), + "the rolled-back variant group must leave the manifest" ); } diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index 4d40f010..4ddaa9cc 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -248,6 +248,7 @@ async fn pypi_install_scan_sync_patches_real_file() { setup_pypi_apply_mock(&server, &before_hash, &after_hash, &patched).await; let mut args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -323,6 +324,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() { // 1. scan --sync to write the manifest + blob. let scan_args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -430,6 +432,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() { setup_pypi_apply_mock(&server, &before_hash, &after_hash, &patched).await; let scan_args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), @@ -561,6 +564,7 @@ async fn pypi_crawler_finds_real_installed_six() { .await; let args = ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index 2b996a86..8b4ec39b 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -291,6 +291,7 @@ async fn mount_view( fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: tmp.to_path_buf(), org: Some(ORG.to_string()), @@ -517,6 +518,7 @@ async fn remove_base_purl_clears_all_variants_and_rolls_back() { ..socket_patch_cli::args::GlobalArgs::default() }, skip_rollback: false, + preserve_state: false, }; let code = remove_run(remove_args).await; assert_eq!(code, 0, "remove base PURL should succeed (exit 0)"); @@ -564,7 +566,8 @@ async fn rollback_all_over_broad_manifest_succeeds() { // this exited non-zero (HashMismatch on the two non-installed // variants against the single on-disk file). let rollback_args = RollbackArgs { - identifier: None, + targets: Vec::new(), + preserve_state: false, common: socket_patch_cli::args::GlobalArgs { cwd: tmp.path().to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 3c78cb03..3fa04562 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -114,6 +114,7 @@ async fn scan_scrubbed(args: ScanArgs) -> i32 { fn default_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 4be63128..400a73c7 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -29,6 +29,7 @@ const GHSA: &str = "GHSA-rdir-aaaa-bbbb"; fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index 73b9a375..bcc6ce7e 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -30,6 +30,7 @@ const GHSA: &str = "GHSA-rdir-pnpm-bbbb"; /// `--mode hosted` (the released spelling that folds to `redirect: true`). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index d14da4a5..c9361369 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -72,6 +72,7 @@ async fn assert_discovered_purl(server: &MockServer, expected_purl: &str) { fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index 862742f8..0f6dd1c3 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -102,6 +102,7 @@ async fn remove_with_rollback_full_chain() { }, identifier: "pkg:npm/remove-target@1.0.0".to_string(), skip_rollback: false, + preserve_state: false, }; let code = remove_run(args).await; assert_eq!(code, 0, "remove with rollback must succeed"); @@ -171,6 +172,7 @@ async fn remove_by_uuid_finds_correct_purl() { }, identifier: uuid.to_string(), skip_rollback: true, + preserve_state: false, }; assert_eq!(remove_run(args).await, 0); let m: serde_json::Value = @@ -227,6 +229,7 @@ async fn remove_no_matching_purl_exits_not_found() { }, identifier: "pkg:npm/does-not-exist@9.9.9".to_string(), skip_rollback: true, + preserve_state: false, }; assert_eq!(remove_run(args).await, 1); // The bystander entry must remain — a non-match deletes nothing. @@ -263,6 +266,7 @@ async fn remove_invalid_manifest_emits_error() { }, identifier: "pkg:npm/anything@1.0.0".to_string(), skip_rollback: true, + preserve_state: false, }; assert_eq!(remove_run(args).await, 1); // A manifest it could not parse must be left byte-for-byte intact — remove @@ -290,6 +294,7 @@ async fn remove_no_manifest_emits_not_found() { }, identifier: "pkg:npm/anything@1.0.0".to_string(), skip_rollback: true, + preserve_state: false, }; assert_eq!(remove_run(args).await, 1); // Removing from a non-existent manifest must not conjure one into being. @@ -758,6 +763,7 @@ async fn remove_detached_vendored_without_manifest_reverts() { }, identifier: purl.to_string(), skip_rollback: false, + preserve_state: false, }; assert_eq!( remove_run(args).await, diff --git a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs index f65101a3..dc8345ca 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs @@ -109,7 +109,8 @@ fn default_rollback_args(cwd: &Path, eco: &str) -> RollbackArgs { verbose: false, ..socket_patch_cli::args::GlobalArgs::default() }, - identifier: None, + targets: Vec::new(), + preserve_state: false, one_off: false, } } diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs new file mode 100644 index 00000000..3885537b --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -0,0 +1,899 @@ +//! In-process rollback tests for HOSTED-mode state (the redirect ledger). +//! +//! The genuine-wiring fixtures run the REAL hosted flow first — in-process +//! `scan --mode hosted` over an npm package-lock project (the +//! `in_process_redirect.rs` fixture, wiremock API) and in-process +//! `get --mode hosted` over a pip requirements.txt project (the +//! `in_process_get_hosted_ecosystems.rs` fixture) — then roll back and +//! byte-compare the lockfiles against their pristine snapshots. The +//! fail-closed / replay fixtures hand-write the redirect ledger through the +//! exported `socket_patch_core::patch::redirect` types (real schema, real +//! edit kinds) with matching file fragments on disk. +//! +//! Convention split (the same one `in_process_redirect.rs` documents): +//! in-process `rollback::run(RollbackArgs)` for exit codes + on-disk +//! post-state, and the `SOCKET_*`-scrubbed subprocess binary wherever the +//! `--json` envelope must be parsed back — an in-process `run` prints its +//! JSON to the real stdout, which the hosting test cannot read. +//! +//! `#[serial]`: every command's `run` mirrors env toggles into +//! process-global env vars (`apply_env_toggles`). + +use std::collections::HashMap; +use std::path::Path; + +use serde_json::Value; +use serial_test::serial; +use socket_patch_cli::commands::rollback::{run as rollback_run, RollbackArgs}; +use socket_patch_cli::commands::scan::{run as scan_run, ScanArgs, ScanMode}; +use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; +use socket_patch_core::patch::redirect::{save_redirect_state, FileEdit, RedirectState}; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; + +// ── the real-flow npm fixture (in_process_redirect.rs shapes) ─────────────── +const NAME: &str = "in-proc-redirect"; +const VERSION: &str = "1.0.0"; +const PURL: &str = "pkg:npm/in-proc-redirect@1.0.0"; +const UUID: &str = "11111111-1111-4111-8111-111111111111"; +const HOSTED_URL: &str = "http://patch.test/patch/npm/in-proc-redirect/1.0.0/22222222-2222-4222-8222-222222222222/11111111-1111-4111-8111-111111111111/in-proc-redirect-1.0.0.tgz"; +const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; +const GHSA: &str = "GHSA-rbhr-aaaa-bbbb"; + +// ── the hand-written two-record ledger fixture ────────────────────────────── +const LP_PURL: &str = "pkg:npm/left-pad@1.2.3"; +const LP_UUID: &str = "55555555-5555-4555-8555-555555555555"; +const LP_HOSTED_URL: &str = "http://patch.test/patch/npm/left-pad/1.2.3/66666666-6666-4666-8666-666666666666/55555555-5555-4555-8555-555555555555/left-pad-1.2.3.tgz"; +const GEM_PURL: &str = "pkg:gem/rex@1.0.0"; +const GEM_UUID: &str = "77777777-7777-4777-8777-777777777777"; +const GEM_UPSTREAM_REMOTE: &str = "https://rubygems.org/"; +const GEM_PATCH_REMOTE: &str = "http://patch.test/gems/t0k3nt0k3n/"; + +fn hosted_scan_args(cwd: &Path, api_url: String) -> ScanArgs { + ScanArgs { + paths: Vec::new(), + common: socket_patch_cli::args::GlobalArgs { + cwd: cwd.to_path_buf(), + org: Some(ORG.to_string()), + api_token: Some("fake".to_string()), + api_url: Some(api_url), + json: true, + yes: true, + ..socket_patch_cli::args::GlobalArgs::default() + }, + batch_size: 100, + apply: false, + prune: false, + sync: false, + vendor: false, + detached: false, + redirect: false, + mode: Some(ScanMode::Hosted), + all_releases: false, + vex: Default::default(), + } +} + +/// Bare (or targeted) in-process rollback with the sibling suites' arg +/// defaults: `--json --yes --offline`, manifest at the default path. +async fn rollback_in_process(cwd: &Path, targets: Vec, preserve_state: bool) -> i32 { + let args = RollbackArgs { + targets, + common: socket_patch_cli::args::GlobalArgs { + cwd: cwd.to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + offline: true, + json: true, + yes: true, + silent: true, + ..socket_patch_cli::args::GlobalArgs::default() + }, + one_off: false, + preserve_state, + }; + let code = rollback_run(args).await; + // `apply_env_toggles` mirrored `--offline` into the PROCESS env and + // nothing unsets it; scrub so a later in-process `scan`/`get` in this + // `#[serial]` process isn't silently forced offline. + std::env::remove_var("SOCKET_OFFLINE"); + code +} + +/// A `socket-patch` Command with the ambient `SOCKET_*` env surface scrubbed +/// (the `in_process_redirect.rs` seed-then-scrub pattern): hostile seeds +/// never reach the child because `env_remove` clears them too, but if a +/// scrub line is ever dropped the seed turns the suite red immediately. +/// Telemetry opt-outs are deliberately kept so an opted-out dev stays +/// opted out. +fn scrubbed_cli() -> std::process::Command { + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.env("SOCKET_DRY_RUN", "true") + .env("SOCKET_OFFLINE", "true") + .env("SOCKET_ECOSYSTEMS", "cargo") + .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") + .env("SOCKET_PRESERVE_STATE", "true") + .env_remove("SOCKET_DRY_RUN") + .env_remove("SOCKET_OFFLINE") + .env_remove("SOCKET_ECOSYSTEMS") + .env_remove("SOCKET_MANIFEST_PATH") + .env_remove("SOCKET_PRESERVE_STATE"); + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd +} + +/// Run `rollback --json --yes --offline [extra]` as a scrubbed subprocess +/// and parse the envelope back (in-process runs print to the real stdout, +/// which a hosting test can't read). Returns (exit code, envelope). +fn run_rollback_subprocess(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let out = scrubbed_cli() + .args([ + "rollback", + "--json", + "--yes", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]) + .args(extra) + .output() + .expect("run socket-patch"); + let envelope: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "rollback --json stdout must be a pure JSON envelope: {e}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), envelope) +} + +/// The `code` field of every run-level warning in a rollback envelope. +fn warning_codes(envelope: &Value) -> Vec { + envelope["warnings"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +async fn mock_discovery(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "rollback hosted fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; +} + +async fn mock_reference(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": HOSTED_URL, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": HOSTED_URL, + "integrity": { "sha512": PATCHED_SHA512 } + }], + "registryOverride": null + } + } + }))) + .mount(server) + .await; +} + +/// The `view/{uuid}` endpoint the hosted flow calls to build the patch +/// record it persists into the ledger — WITHOUT it the ledger is a degraded +/// records-empty ledger and the per-purl revert has nothing to claim. +async fn mock_view(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{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": "a".repeat(64), + "afterHash": "b".repeat(64), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2024-9"], + "summary": "rollback hosted fixture", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; +} + +/// Write the npm project (package.json + installed tree + package-lock.json) +/// and return the PRISTINE lock bytes. The lock is normalized through the +/// same `to_string_pretty + "\n"` form the redirect writer emits +/// (`serialize_json`), so the pristine snapshot is a meaningful byte-identity +/// oracle for the wire→unwind round trip. +fn write_npm_project(root: &Path) -> String { + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = root.join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + let raw = format!( + r#"{{ + "name": "consumer", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": {{ + "": {{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}, + "node_modules/{NAME}": {{ + "version": "{VERSION}", + "resolved": "https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz", + "integrity": "sha512-UPSTREAMupstream==" + }} + }} +}} +"# + ); + let normalized = format!( + "{}\n", + serde_json::to_string_pretty(&serde_json::from_str::(&raw).unwrap()).unwrap() + ); + std::fs::write(root.join("package-lock.json"), &normalized).unwrap(); + normalized +} + +fn ledger_path(root: &Path) -> std::path::PathBuf { + root.join(".socket/vendor/redirect-state.json") +} + +/// A full camelCase patch record for hand-written ledgers (the same shape +/// the hosted flow persists from `view/{uuid}`). +fn patch_record(uuid: &str, ghsa: &str) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: "a".repeat(64), + after_hash: "b".repeat(64), + }, + ); + let mut vulns = HashMap::new(); + vulns.insert( + ghsa.to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2024-1".to_string()], + summary: "s".to_string(), + severity: "high".to_string(), + description: "d".to_string(), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: vulns, + description: "x".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + } +} + +/// Serialize a hand-written ledger through the real core writer (real +/// schema: version, mode "hosted", edits[FileEdit], records{purl: record}). +async fn write_hosted_ledger(root: &Path, records: Vec<(&str, PatchRecord)>, edits: Vec) { + let mut state = RedirectState::new(); + state.edits = edits; + for (purl, record) in records { + state.records.insert(purl.to_string(), record); + } + save_redirect_state(root, &state) + .await + .expect("write redirect ledger"); +} + +// ── yarn-classic fragments for the hand-written npm record ───────────────── +// `redirect_yarn_classic_entry` is one of the text kinds the per-purl npm +// revert claims by `@` key; original/new record whole blocks, +// exactly as the real writer does. + +fn yarn_block(resolved: &str, integrity: &str) -> String { + format!( + "left-pad@1.2.3:\n version \"1.2.3\"\n resolved \"{resolved}\"\n integrity {integrity}" + ) +} + +fn yarn_original_block() -> String { + yarn_block( + "https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.3.tgz#aaaa", + "sha512-UPSTREAMupstream==", + ) +} + +fn yarn_redirected_block() -> String { + yarn_block(LP_HOSTED_URL, "sha512-PATCHEDpatched==") +} + +fn yarn_lock_content(block: &str) -> String { + format!( + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n{block}\n" + ) +} + +fn yarn_classic_edit() -> FileEdit { + FileEdit { + path: "yarn.lock".to_string(), + kind: "redirect_yarn_classic_entry".to_string(), + action: "rewritten".to_string(), + key: Some("left-pad@1.2.3".to_string()), + original: Some(Value::String(yarn_original_block())), + new: Some(Value::String(yarn_redirected_block())), + } +} + +// ── gem fragments for the hand-written gem record ─────────────────────────── +// `redirect_gemfile_lock_source_url` has NO per-purl revert (gem is not in +// `redirect_revert_supported`); its unwind is the whole-ledger replay's +// ReplaceFragment arm. + +fn gemfile_lock_content(remote: &str) -> String { + format!( + "GEM\n remote: {remote}\n specs:\n rex (1.0.0)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n rex\n\nBUNDLED WITH\n 2.5.9\n" + ) +} + +fn gem_source_edit() -> FileEdit { + FileEdit { + path: "Gemfile.lock".to_string(), + kind: "redirect_gemfile_lock_source_url".to_string(), + action: "rewritten".to_string(), + key: Some("rex".to_string()), + original: Some(Value::String(GEM_UPSTREAM_REMOTE.to_string())), + new: Some(Value::String(GEM_PATCH_REMOTE.to_string())), + } +} + +/// The two-record fixture: an npm purl with a yarn-classic text edit (owned +/// by the per-purl npm revert) and a gem purl with a Gemfile.lock edit +/// (replay-only), both with REAL redirected fragments on disk. +async fn write_two_record_fixture(root: &Path) { + std::fs::write( + root.join("yarn.lock"), + yarn_lock_content(&yarn_redirected_block()), + ) + .unwrap(); + std::fs::write( + root.join("Gemfile.lock"), + gemfile_lock_content(GEM_PATCH_REMOTE), + ) + .unwrap(); + write_hosted_ledger( + root, + vec![ + (LP_PURL, patch_record(LP_UUID, "GHSA-lpad-aaaa-bbbb")), + (GEM_PURL, patch_record(GEM_UUID, "GHSA-gems-cccc-dddd")), + ], + vec![yarn_classic_edit(), gem_source_edit()], + ) + .await; +} + +/// Single-record npm fixture (yarn-classic wiring) for the manifest-less and +/// preserve-state tests. +async fn write_single_npm_fixture(root: &Path) { + std::fs::write( + root.join("yarn.lock"), + yarn_lock_content(&yarn_redirected_block()), + ) + .unwrap(); + write_hosted_ledger( + root, + vec![(LP_PURL, patch_record(LP_UUID, "GHSA-lpad-aaaa-bbbb"))], + vec![yarn_classic_edit()], + ) + .await; +} + +// --------------------------------------------------------------------------- +// 1. npm round trip: real scan --mode hosted wiring, then bare rollback +// --------------------------------------------------------------------------- + +/// Snapshot the pristine lock → `scan --mode hosted` wires it (resolved URL +/// rewritten + ledger written) → bare in-process rollback → exit 0, lock +/// byte-identical to pristine, redirect-state.json DELETED, and no manifest +/// materialized as a side effect. +#[tokio::test] +#[serial] +async fn npm_hosted_round_trip() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_npm_project(tmp.path()); + + let code = scan_run(hosted_scan_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed"); + let wired = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!( + wired.contains(HOSTED_URL) && wired.contains(PATCHED_SHA512), + "the lock must be wired to the hosted patch before the rollback \ + means anything; got:\n{wired}" + ); + assert_ne!(wired, pristine, "wiring must actually change the lock"); + assert!( + ledger_path(tmp.path()).is_file(), + "scan --mode hosted must write the redirect ledger" + ); + + let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + assert_eq!(code, 0, "bare rollback over hosted wiring should exit 0"); + + let restored = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert_eq!( + restored, pristine, + "rollback must restore the lock byte-identical to the pristine snapshot" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "an emptied redirect ledger must be DELETED, not left as an empty file" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "a hosted-only rollback must not materialize a manifest" + ); +} + +/// Dry-run twin of the round trip — the review-caught regression: the +/// per-purl dry revert must claim its npm JSON edits IN MEMORY so the +/// whole-ledger replay does not refuse them as unclaimed (`group:npm`) +/// and flip a would-succeed run to partial_failure. A hosted npm dry run +/// exits 0, reports the purl as would-be-reverted, and mutates NOTHING. +#[tokio::test] +#[serial] +async fn npm_hosted_dry_run_previews_cleanly() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path()); + let code = scan_run(hosted_scan_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed"); + let wired = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + + let args = RollbackArgs { + targets: Vec::new(), + common: socket_patch_cli::args::GlobalArgs { + cwd: tmp.path().to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + offline: true, + json: true, + yes: true, + silent: true, + dry_run: true, + ..socket_patch_cli::args::GlobalArgs::default() + }, + one_off: false, + preserve_state: false, + }; + let code = rollback_run(args).await; + std::env::remove_var("SOCKET_OFFLINE"); + std::env::remove_var("SOCKET_DRY_RUN"); + assert_eq!( + code, 0, + "a hosted npm dry run must preview cleanly, never refuse its own \ + per-purl-claimed edits" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + wired, + "dry run must not touch the lock" + ); + assert_eq!( + std::fs::read(ledger_path(tmp.path())).unwrap(), + ledger_before, + "dry run must not touch the on-disk ledger" + ); +} + +/// The same round trip through the binary so the `--json` envelope can be +/// parsed back: `hosted.reverted == [purl]`, `hosted.editedFiles >= 1`, +/// nothing failed/unsupported, status success. +#[tokio::test] +#[serial] +async fn npm_hosted_round_trip_envelope() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let pristine = write_npm_project(tmp.path()); + let code = scan_run(hosted_scan_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --mode hosted should succeed"); + + let (code, envelope) = run_rollback_subprocess(tmp.path(), &[]); + assert_eq!(code, 0, "bare rollback should exit 0: {envelope}"); + assert_eq!(envelope["status"], "success", "{envelope}"); + assert_eq!( + envelope["hosted"]["reverted"], + serde_json::json!([PURL]), + "the unwound purl must be reported: {envelope}" + ); + assert!( + envelope["hosted"]["editedFiles"].as_u64().unwrap_or(0) >= 1, + "at least the lockfile was rewritten: {envelope}" + ); + assert_eq!(envelope["hosted"]["failed"], serde_json::json!([])); + assert_eq!(envelope["hosted"]["unsupported"], serde_json::json!([])); + assert_eq!( + envelope["manifest"]["removedEntries"], + serde_json::json!([]), + "hosted state lives in the ledger, not the manifest: {envelope}" + ); + assert!( + warning_codes(&envelope).contains(&"reinstall_required".to_string()), + "unwiring must carry the stale-install warning: {envelope}" + ); + + let restored = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert_eq!(restored, pristine, "lock must be byte-restored"); + assert!(!ledger_path(tmp.path()).exists(), "ledger must be deleted"); +} + +// --------------------------------------------------------------------------- +// 2. pypi requirements.txt round trip (real hosted flow via get --mode hosted) +// --------------------------------------------------------------------------- + +/// A pip project wired by the REAL hosted flow (`get --mode hosted`, +/// the `in_process_get_hosted_ecosystems.rs` fixture — the UUID path needs +/// no installed tree), then a bare rollback: requirements.txt restored +/// byte-for-byte via the whole-ledger replay (pypi has no per-purl revert), +/// ledger deleted, exit 0. +#[tokio::test] +#[serial] +async fn pypi_requirements_hosted_round_trip() { + const PY_UUID: &str = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + const PY_PURL: &str = "pkg:pypi/requests@2.31.0"; + const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let url = format!( + "http://patch.test/patch/pypi/requests/2.31.0/22222222-2222-4222-8222-222222222222/{PY_UUID}/requests-2.31.0-py3-none-any.whl" + ); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{PY_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": PY_UUID, + "purl": PY_PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "requests/api.py": { + "beforeHash": "a".repeat(64), + "afterHash": "b".repeat(64), + } + }, + "vulnerabilities": { + "GHSA-pypi-eeee-ffff": { + "cves": ["CVE-2024-2"], + "summary": "pypi hosted rollback fixture", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + PY_UUID: { + "status": "granted", + "url": url, + "purl": PY_PURL, + "artifacts": [{ + "kind": "tarball", + "url": url, + "integrity": { "sha256": SHA256 } + }], + "registryOverride": null + } + } + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + let pristine = "flask==2.0.1\nrequests==2.31.0\n"; + std::fs::write(tmp.path().join("requirements.txt"), pristine).unwrap(); + + let get_args = socket_patch_cli::commands::get::GetArgs { + common: socket_patch_cli::args::GlobalArgs { + org: Some(ORG.to_string()), + cwd: tmp.path().to_path_buf(), + yes: true, + api_token: Some("fake".to_string()), + api_url: Some(server.uri()), + json: true, + ..socket_patch_cli::args::GlobalArgs::default() + }, + identifier: PY_UUID.to_string(), + id: false, + cve: false, + ghsa: false, + package: false, + save_only: false, + one_off: false, + all_releases: false, + mode: Some(ScanMode::Hosted), + }; + let code = socket_patch_cli::commands::get::run(get_args).await; + assert_eq!(code, 0, "get --mode hosted (pypi) should succeed"); + + let wired = std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(); + assert!( + wired.contains(&url), + "requirements.txt must be wired to the hosted wheel; got:\n{wired}" + ); + let ledger = std::fs::read_to_string(ledger_path(tmp.path())).unwrap(); + assert!( + ledger.contains(PY_PURL) && ledger.contains("redirect_requirements_line"), + "the ledger must record the pypi redirect; got:\n{ledger}" + ); + + let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + assert_eq!(code, 0, "bare rollback over the pypi redirect should exit 0"); + + let restored = std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(); + assert_eq!( + restored, pristine, + "requirements.txt must be restored byte-for-byte" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "the emptied ledger must be deleted" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "hosted mode never touches the manifest" + ); +} + +// --------------------------------------------------------------------------- +// 3. scoped rollback of an unsupported ecosystem fails closed +// --------------------------------------------------------------------------- + +/// A two-record ledger (npm + gem) scoped to ONLY the gem purl: gem has no +/// per-purl revert and the scope does not cover the full record set, so the +/// replay may not run — the run fails closed with the purl in +/// `hosted.unsupported`, exit 1, and both the ledger and every wired file +/// stay byte-identical on disk. +#[tokio::test] +#[serial] +async fn scoped_unsupported_ecosystem_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + write_two_record_fixture(tmp.path()).await; + let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + let yarn_before = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + let gem_before = std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(); + + let (code, envelope) = run_rollback_subprocess(tmp.path(), &[GEM_PURL]); + assert_eq!( + code, 1, + "a scoped hosted purl with no per-purl revert must fail closed: {envelope}" + ); + assert_eq!(envelope["status"], "partial_failure", "{envelope}"); + assert_eq!( + envelope["hosted"]["unsupported"], + serde_json::json!([GEM_PURL]), + "the refused purl must be reported unsupported: {envelope}" + ); + assert_eq!( + envelope["hosted"]["reverted"], + serde_json::json!([]), + "nothing may be unwound on a refused scoped run: {envelope}" + ); + + assert_eq!( + std::fs::read(ledger_path(tmp.path())).unwrap(), + ledger_before, + "the ledger must stay byte-identical on a fail-closed run" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_before, + "the out-of-scope npm wiring must be untouched" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(), + gem_before, + "the refused gem wiring must be untouched" + ); +} + +// --------------------------------------------------------------------------- +// 4. unscoped rollback replays the unsupported ecosystems +// --------------------------------------------------------------------------- + +/// The same two-record ledger, unscoped: the npm purl unwinds through the +/// per-purl revert and the gem purl through the whole-ledger reverse replay +/// (its scope covers every record). Both files are byte-restored, the +/// ledger is deleted, exit 0. +#[tokio::test] +#[serial] +async fn unscoped_replays_unsupported_ecosystems() { + let tmp = tempfile::tempdir().unwrap(); + write_two_record_fixture(tmp.path()).await; + + let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + assert_eq!(code, 0, "unscoped rollback must unwind BOTH records"); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_original_block()), + "the npm wiring must be unwound (per-purl revert)" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(), + gemfile_lock_content(GEM_UPSTREAM_REMOTE), + "the gem wiring must be unwound (whole-ledger replay)" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "all records and edits unwound: the ledger must be deleted" + ); +} + +// --------------------------------------------------------------------------- +// 5. manifest-less hosted-only project vs. the truly-empty project +// --------------------------------------------------------------------------- + +/// A hosted-only project (redirect ledger + wired lock, NO manifest) rolls +/// back fine — a missing manifest is no longer fatal when a ledger holds +/// work. A TRULY empty directory keeps the legacy "Manifest not found" +/// exit-1 error. +#[tokio::test] +#[serial] +async fn hosted_only_project_without_manifest() { + // Hosted-only: unwinds and exits 0. + let tmp = tempfile::tempdir().unwrap(); + write_single_npm_fixture(tmp.path()).await; + assert!(!tmp.path().join(".socket/manifest.json").exists()); + + let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + assert_eq!( + code, 0, + "a manifest-less hosted-only project must roll back fine" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_original_block()), + "the hosted wiring must be unwound" + ); + assert!(!ledger_path(tmp.path()).exists(), "ledger must be deleted"); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "no manifest may be materialized" + ); + + // Truly empty: all three stores absent keeps the legacy error. + let empty = tempfile::tempdir().unwrap(); + let (code, envelope) = run_rollback_subprocess(empty.path(), &[]); + assert_eq!(code, 1, "a truly-empty project must keep exit 1: {envelope}"); + assert_eq!(envelope["status"], "error", "{envelope}"); + assert!( + envelope["error"] + .as_str() + .unwrap_or_default() + .contains("Manifest not found"), + "the legacy error message must be preserved: {envelope}" + ); +} + +// --------------------------------------------------------------------------- +// 6. --preserve-state still unwinds hosted state +// --------------------------------------------------------------------------- + +/// Hosted redirects have no preservable local state: a `--preserve-state` +/// run still unwinds the wiring and drops the ledger records, surfacing the +/// `hosted_state_not_preservable` warning; manifest cleanup and GC stay +/// skipped (`manifest.preserved`, `gc.skipped`). +#[tokio::test] +#[serial] +async fn preserve_state_still_unwinds_hosted() { + let tmp = tempfile::tempdir().unwrap(); + write_single_npm_fixture(tmp.path()).await; + + let (code, envelope) = run_rollback_subprocess(tmp.path(), &["--preserve-state"]); + assert_eq!(code, 0, "preserve-state hosted rollback exits 0: {envelope}"); + assert_eq!(envelope["status"], "success", "{envelope}"); + assert_eq!( + envelope["hosted"]["reverted"], + serde_json::json!([LP_PURL]), + "the wiring must still be unwound under --preserve-state: {envelope}" + ); + assert!( + warning_codes(&envelope).contains(&"hosted_state_not_preservable".to_string()), + "dropping hosted records under --preserve-state must be surfaced: {envelope}" + ); + assert_eq!( + envelope["manifest"]["preserved"], true, + "manifest cleanup must be skipped: {envelope}" + ); + assert_eq!( + envelope["gc"]["skipped"], true, + "GC must be skipped under --preserve-state: {envelope}" + ); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_original_block()), + "the hosted wiring must be unwound on disk" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "hosted ledger records are dropped with the wiring — no preservable state" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs new file mode 100644 index 00000000..87778a6a --- /dev/null +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -0,0 +1,610 @@ +//! In-process rollback tests over a VENDORED npm fixture — the vendored leg +//! of the v5.0 scan↔rollback duality (CLI_CONTRACT.md "Rollback command +//! contract (v5.0)"): +//! +//! * `--preserve-state` unwires the lockfile but keeps the artifact, the +//! ledger entry (byte-identical — R6: wiring records intact), and the +//! manifest entry, and skips GC; +//! * a re-vendor after a preserve-rollback re-wires from the LIVE lock +//! (the in-sync probe regression); +//! * a drift-keep (wiring fragments matching nothing in the lock) exits +//! partial_failure and holds BOTH the ledger entry and the manifest +//! entry; +//! * detached ledger entries are reverted by the unscoped default run. +//! +//! Fixture and conventions are copied from `in_process_vendor.rs`: the +//! lifecycle steps call `commands::vendor::run` / `commands::rollback::run` +//! in-process and assert exit codes + on-disk post-state; every assertion +//! that needs the JSON envelope goes through the built binary +//! (`CARGO_BIN_EXE_socket-patch`) with a scrubbed `SOCKET_*` child env. +//! +//! Hermeticity: each fixture stages its patch blob under `.socket/blobs/` +//! and runs with `--offline`, so nothing touches the network. No test +//! mutates this process's environment (the in-process runs only mirror +//! `--offline` INTO the env, which every test here wants anyway), so none +//! need `#[serial]` — each runs in its own tempdir. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{json, Value}; +use socket_patch_cli::args::GlobalArgs; +use socket_patch_cli::commands::rollback::{run as rollback_run, RollbackArgs}; +use socket_patch_cli::commands::vendor::{run as vendor_run, VendorArgs}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + +/// Canonical-grammar patch UUID — the vendor path layer validates the uuid +/// path level fail-closed, so fixtures must use the real shape. +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const PURL: &str = "pkg:npm/left-pad@1.3.0"; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; +const REG_RESOLVED: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const REG_INTEGRITY: &str = "sha512-orig=="; + +/// Project-relative tarball path the npm backend produces: +/// `.socket/vendor///-.tgz`. +fn rel_tgz() -> String { + format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz") +} + +// ───────────────────────────── fixture ───────────────────────────── + +/// One self-contained npm project: root package.json, a v3 package-lock with +/// a registry-resolved `left-pad` entry, the installed package under +/// node_modules/, and a `.socket/` manifest + after-hash blob so vendor runs +/// fully offline. Copied from `in_process_vendor.rs`. +struct NpmFixture { + tmp: tempfile::TempDir, + /// The lockfile bytes exactly as the fixture wrote them — the + /// byte-identity oracle for the rollback round-trips. + original_lock: Vec, + /// Manifest bytes as written (preserve/drift rollbacks must not + /// rewrite the manifest). + original_manifest: Vec, + after_hash: String, +} + +impl NpmFixture { + fn root(&self) -> &Path { + self.tmp.path() + } + fn lock_path(&self) -> PathBuf { + self.root().join("package-lock.json") + } + fn lock_bytes(&self) -> Vec { + std::fs::read(self.lock_path()).expect("read package-lock.json") + } + fn manifest_path(&self) -> PathBuf { + self.root().join(".socket/manifest.json") + } + fn tgz_path(&self) -> PathBuf { + self.root().join(rel_tgz()) + } + fn state_path(&self) -> PathBuf { + self.root().join(".socket/vendor/state.json") + } + fn state_value(&self) -> Value { + serde_json::from_slice(&std::fs::read(self.state_path()).expect("read state.json")) + .expect("state.json is JSON") + } + fn blob_path(&self) -> PathBuf { + self.root().join(".socket/blobs").join(&self.after_hash) + } + fn installed_index(&self) -> PathBuf { + self.root().join("node_modules/left-pad/index.js") + } +} + +/// The manifest patch record the fixture purl uses. +fn patch_record(before_hash: &str, after_hash: &str) -> Value { + json!({ + "uuid": UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { "beforeHash": before_hash, "afterHash": after_hash } + }, + "vulnerabilities": {}, + "description": "synthetic vendored-rollback test patch", + "license": "MIT", + "tier": "free" + }) +} + +fn npm_fixture() -> NpmFixture { + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + + // Installed package (original, unpatched bytes). + let pkg = root.join("node_modules/left-pad"); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + br#"{"name":"left-pad","version":"1.3.0"}"#, + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + + // Root project files. The lock is written pretty + 2-space indent + + // trailing newline — the exact shape the production serializer emits — + // so byte-identity assertions across vendor/rollback are meaningful. + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true}"#, + ) + .unwrap(); + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fixture", + "version": "1.0.0", + "dependencies": { "left-pad": "^1.3.0" } + }, + "node_modules/left-pad": { + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": REG_INTEGRITY, + "license": "WTFPL" + } + } + }); + let mut original_lock = serde_json::to_vec_pretty(&lock).unwrap(); + original_lock.push(b'\n'); + std::fs::write(root.join("package-lock.json"), &original_lock).unwrap(); + + // Manifest + staged after-hash blob (offline source for vendor). + let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); + let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); + let manifest = json!({ "patches": { PURL: patch_record(&before_hash, &after_hash) } }); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + let mut original_manifest = serde_json::to_vec_pretty(&manifest).unwrap(); + original_manifest.push(b'\n'); + std::fs::write(socket.join("manifest.json"), &original_manifest).unwrap(); + std::fs::write(socket.join("blobs").join(&after_hash), PATCHED_INDEX).unwrap(); + + NpmFixture { + tmp, + original_lock, + original_manifest, + after_hash, + } +} + +/// In-process `VendorArgs` for the fixture — `in_process_vendor.rs`'s +/// helper verbatim: `json`+`silent` suppress prompts/output, `offline` +/// keeps the patch pipeline on the staged local blobs. +fn vendor_args(cwd: &Path) -> VendorArgs { + VendorArgs { + common: GlobalArgs { + cwd: cwd.to_path_buf(), + json: true, + silent: true, + offline: true, + // Absorb the fork→exec OFD-lock window (see in_process_vendor.rs). + lock_timeout: Some(5), + ..GlobalArgs::default() + }, + force: false, + revert: false, + vex: Default::default(), + } +} + +/// In-process `RollbackArgs`: unscoped (no targets), json auto-accepts the +/// confirmation prompt, offline keeps every leg local. +fn rollback_args(cwd: &Path, preserve_state: bool) -> RollbackArgs { + RollbackArgs { + targets: Vec::new(), + common: GlobalArgs { + cwd: cwd.to_path_buf(), + json: true, + silent: true, + offline: true, + lock_timeout: Some(5), + ..GlobalArgs::default() + }, + one_off: false, + preserve_state, + } +} + +// ───────────────────────── subprocess runner ───────────────────────── + +/// Run the built `socket-patch` binary with every ambient `SOCKET_*` env var +/// scrubbed from the child (env-robustness: the assertions must reflect the +/// argv, not the developer's shell) and telemetry hard-disabled. +fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args).current_dir(cwd); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + let out = cmd.output().expect("spawn socket-patch binary"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +/// `rollback --json --offline --cwd ` through the binary, +/// returning `(exit_code, parsed envelope)`. `--json` auto-accepts the +/// confirmation prompt (the shared `confirm` semantics). +fn rollback_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { + let mut args = vec![ + "rollback", + "--json", + "--offline", + "--cwd", + cwd.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli(cwd, &args); + let env: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("rollback --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) +} + +// ───────────────────────────────────────────────────────────────────── +// 1. --preserve-state: unwire but keep artifact + ledger + manifest +// ───────────────────────────────────────────────────────────────────── + +/// `rollback --preserve-state` on a vendored purl restores the lockfile +/// byte-for-byte but PRESERVES all local patch state: the vendored artifact +/// stays, the ledger entry stays byte-identical (R6 — wiring records +/// intact, never cleared), the manifest entry stays, and no GC runs (the +/// staged blob survives). The envelope surfaces the purl in +/// `vendoredPreserved` with `manifest.preserved: true` and `gc.skipped`. +#[tokio::test] +async fn preserve_state_unwires_but_keeps_artifact_and_ledger() { + // ── in-process lifecycle: vendor, then rollback --preserve-state ── + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "vendor"); + assert_ne!( + fx.lock_bytes(), + fx.original_lock, + "sanity: vendor actually rewired the lock" + ); + let state_before = std::fs::read(fx.state_path()).expect("state.json after vendor"); + let entry_before = fx.state_value()["entries"][PURL].clone(); + assert!(entry_before.is_object(), "sanity: ledger entry written"); + let manifest_before = std::fs::read(fx.manifest_path()).unwrap(); + assert_eq!( + manifest_before, fx.original_manifest, + "sanity: vendor never touches the manifest" + ); + + let code = rollback_run(rollback_args(fx.root(), true)).await; + assert_eq!(code, 0, "rollback --preserve-state must exit 0"); + + // The system is unpatched: the lock is byte-for-byte the pre-vendor + // registry spelling again. + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "--preserve-state must restore the lock byte-for-byte" + ); + // …but the LOCAL STATE is all still there. + assert!( + fx.tgz_path().is_file(), + "the vendored artifact must be kept under --preserve-state" + ); + assert_eq!( + std::fs::read(fx.state_path()).expect("state.json survives"), + state_before, + "the vendor ledger must be byte-identical (entry kept INTACT, \ + wiring records included — R6)" + ); + assert_eq!( + fx.state_value()["entries"][PURL], + entry_before, + "the ledger entry must be unchanged" + ); + assert_eq!( + std::fs::read(fx.manifest_path()).unwrap(), + fx.original_manifest, + "the manifest entry must be kept (no rewrite at all)" + ); + assert!( + fx.blob_path().is_file(), + "GC is skipped under --preserve-state: the staged blob survives" + ); + assert_eq!( + std::fs::read(fx.installed_index()).unwrap(), + ORIG_INDEX, + "the installed tree of a vendored purl is never touched" + ); + + // ── envelope contract, on a fresh identical fixture ── + let fx2 = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx2.root())).await, 0, "vendor #2"); + let (code, env) = rollback_cli(fx2.root(), &["--preserve-state"]); + assert_eq!(code, 0, "preserve rollback exits 0: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + env["vendoredPreserved"], + json!([PURL]), + "the unwired-but-kept purl rides vendoredPreserved: {env:#}" + ); + assert_eq!(env["vendoredReverted"], json!([]), "{env:#}"); + assert_eq!(env["vendoredKept"], json!([]), "{env:#}"); + assert_eq!(env["manifest"]["preserved"], json!(true), "{env:#}"); + assert_eq!(env["manifest"]["removedEntries"], json!([]), "{env:#}"); + assert_eq!(env["gc"], json!({ "skipped": true }), "{env:#}"); + assert_eq!(fx2.lock_bytes(), fx2.original_lock, "lock restored"); + assert!(fx2.tgz_path().is_file(), "artifact kept"); + assert!( + fx2.state_value()["entries"][PURL].is_object(), + "ledger entry kept" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 2. re-vendor after --preserve-state re-wires the lock +// ───────────────────────────────────────────────────────────────────── + +/// The preserved ledger entry's wiring records now describe already-reverted +/// fragments; a later `vendor` run must read the LIVE lock (registry +/// spelling), see the entry is out of sync, and re-wire — not trust the +/// ledger and skip as "already vendored", which would strand the lock +/// unwired forever (the in-sync probe regression). +#[tokio::test] +async fn revendor_after_preserve_rewires() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "first vendor"); + let wired_lock = fx.lock_bytes(); + + assert_eq!( + rollback_run(rollback_args(fx.root(), true)).await, + 0, + "rollback --preserve-state" + ); + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "sanity: the lock is back at the registry spelling" + ); + + // Re-vendor: exit 0 and the lock is re-wired to the .socket/vendor path + // (byte-identical to the first wiring — deterministic pack, same uuid). + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "re-vendor"); + assert_eq!( + fx.lock_bytes(), + wired_lock, + "re-vendor must re-wire the lock to the .socket/vendor artifact" + ); + let lock_text = String::from_utf8(fx.lock_bytes()).unwrap(); + assert!( + lock_text.contains(&format!("file:{}", rel_tgz())), + "the lock must point at the vendored tarball again: {lock_text}" + ); + assert!(fx.tgz_path().is_file(), "artifact present after re-vendor"); + + // The ledger entry survived the round-trip and still records the + // pre-vendor registry fragment, so a LATER revert can still restore it. + let state = fx.state_value(); + let entry = &state["entries"][PURL]; + assert_eq!(entry["uuid"], UUID, "{state:#}"); + let wiring = entry["wiring"].as_array().expect("wiring array"); + assert_eq!( + wiring[0]["original"]["resolved"], REG_RESOLVED, + "the registry original must survive the preserve→re-vendor \ + round-trip: {state:#}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 3. drift-keep: exit 1, ledger AND manifest entries survive +// ───────────────────────────────────────────────────────────────────── + +/// A vendored entry whose wiring records match NOTHING in the live lock is +/// a drift-keep: the backend refuses to touch the drifted lock, the run +/// exits 1 (`partial_failure` — the system is still patched, R5), the +/// envelope carries the purl + reason in `vendoredKept`, and BOTH the +/// ledger entry and the manifest entry survive for a later normalize + +/// retry (fail-closed manifest cleanup). +#[tokio::test] +async fn drift_keep_exits_partial_failure_and_holds_manifest() { + const DRIFT_PURL: &str = "pkg:npm/__rollback_drift_kept__@1.0.0"; + const DRIFT_UUID: &str = "33333333-3333-4333-8333-333333333333"; + + let tmp = tempfile::tempdir().expect("tempdir"); + let root = tmp.path(); + + // A real lock that contains NO fragment the wiring below names. + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true}"#, + ) + .unwrap(); + let lock = json!({ + "name": "fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { "": { "name": "fixture", "version": "1.0.0" } } + }); + let mut original_lock = serde_json::to_vec_pretty(&lock).unwrap(); + original_lock.push(b'\n'); + std::fs::write(root.join("package-lock.json"), &original_lock).unwrap(); + + // Manifest entry for the same purl (files empty: the drift-keep must + // hold the entry regardless of any agent-leg work). + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let original_manifest = format!( + r#"{{ + "patches": {{ + "{DRIFT_PURL}": {{ + "uuid": "{DRIFT_UUID}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "synthetic drift-keep fixture", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), &original_manifest).unwrap(); + + // Vendor ledger entry wired with a fragment the lock does not contain — + // cli_remove_silent.rs's DRIFTED_WIRING shape (the npm revert backend + // classifies the vanished `node_modules/x` entry as third-party drift + // and keeps the artifact + wiring untouched). + let artifact_dir = socket.join("vendor/npm").join(DRIFT_UUID); + std::fs::create_dir_all(&artifact_dir).unwrap(); + std::fs::write(artifact_dir.join("package.tgz"), b"tgz").unwrap(); + let original_state = format!( + r#"{{ + "version": 1, + "entries": {{ + "{DRIFT_PURL}": {{ + "ecosystem": "npm", + "basePurl": "{DRIFT_PURL}", + "uuid": "{DRIFT_UUID}", + "artifact": {{ "path": ".socket/vendor/npm/{DRIFT_UUID}/package.tgz" }}, + "wiring": [{{ "file": "weird.txt", "kind": "npm_lock_entry", "action": "added", "key": "node_modules/x" }}] + }} + }} +}}"# + ); + let state_path = socket.join("vendor/state.json"); + std::fs::write(&state_path, &original_state).unwrap(); + + // ── in-process bare rollback: exit 1, nothing on disk moves ── + let code = rollback_run(rollback_args(root, false)).await; + assert_eq!(code, 1, "a drift-keep must exit partial_failure (R5)"); + assert_eq!( + std::fs::read(&state_path).expect("ledger survives"), + original_state.as_bytes(), + "the drift-kept ledger entry must survive byte-identical" + ); + assert_eq!( + std::fs::read(socket.join("manifest.json")).expect("manifest survives"), + original_manifest.as_bytes(), + "the manifest entry must survive a drift-keep (fail-closed cleanup)" + ); + assert_eq!( + std::fs::read(root.join("package-lock.json")).unwrap(), + original_lock, + "the drifted lock must be left alone" + ); + assert!( + artifact_dir.join("package.tgz").is_file(), + "the kept artifact must survive" + ); + + // ── envelope: the drift-keep leaves everything untouched, so the same + // fixture replays identically through the binary ── + let (code, env) = rollback_cli(root, &[]); + assert_eq!(code, 1, "drift-keep exits 1: {env:#}"); + assert_eq!(env["status"], "partial_failure", "{env:#}"); + let kept = env["vendoredKept"].as_array().expect("vendoredKept array"); + assert_eq!(kept.len(), 1, "{env:#}"); + assert_eq!(kept[0]["purl"], DRIFT_PURL, "{env:#}"); + assert!( + kept[0]["reason"] + .as_str() + .is_some_and(|r| r.contains("drifted")), + "the kept reason must name the drift: {env:#}" + ); + assert_eq!(env["vendoredReverted"], json!([]), "{env:#}"); + assert_eq!( + env["manifest"]["removedEntries"], + json!([]), + "a drift-kept purl's manifest entry is never removed: {env:#}" + ); + + // Still nothing moved. + assert_eq!( + std::fs::read(&state_path).unwrap(), + original_state.as_bytes(), + "ledger byte-identical after the second (binary) run" + ); + let manifest: Value = + serde_json::from_slice(&std::fs::read(socket.join("manifest.json")).unwrap()).unwrap(); + assert!( + manifest["patches"].get(DRIFT_PURL).is_some(), + "manifest entry survives: {manifest:#}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// 4. detached entries are reverted by the unscoped default +// ───────────────────────────────────────────────────────────────────── + +/// A detached ledger entry (`scan --vendor --detached` — never +/// manifest-tracked) is IN SCOPE for the unscoped default rollback: the +/// lock is restored byte-for-byte, the artifact is deleted, the emptied +/// ledger is deleted, and the purl rides `vendoredReverted` (exit 0). +/// The fixture detaches a REAL vendor run's entry (the +/// `in_process_vendor.rs` idiom), so the wiring genuinely points into the +/// lock. +#[tokio::test] +async fn detached_entries_reverted_by_unscoped_default() { + let fx = npm_fixture(); + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0, "vendor"); + + // Mark the entry detached (the shape `scan --vendor --detached` writes) + // and drop the patch from the manifest — detached entries are never + // manifest-tracked. + let mut state = fx.state_value(); + state["entries"][PURL]["detached"] = json!(true); + std::fs::write(fx.state_path(), serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + std::fs::write(fx.manifest_path(), b"{\"patches\": {}}\n").unwrap(); + assert_ne!( + fx.lock_bytes(), + fx.original_lock, + "sanity: the detached entry is wired into the lock" + ); + + let (code, env) = rollback_cli(fx.root(), &[]); + assert_eq!(code, 0, "detached revert exits 0: {env:#}"); + assert_eq!(env["status"], "success", "{env:#}"); + assert_eq!( + env["vendoredReverted"], + json!([PURL]), + "the detached entry must ride vendoredReverted: {env:#}" + ); + assert_eq!(env["vendoredPreserved"], json!([]), "{env:#}"); + assert_eq!(env["vendoredKept"], json!([]), "{env:#}"); + assert_eq!( + env["manifest"]["removedEntries"], + json!([]), + "detached entries have no manifest record to remove: {env:#}" + ); + + // On-disk post-state: fully reverted. + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "the lock must be restored byte-for-byte" + ); + assert!(!fx.tgz_path().exists(), "artifact deleted"); + assert!( + !fx.root() + .join(format!(".socket/vendor/npm/{UUID}")) + .exists(), + "the artifact uuid dir must be gone" + ); + assert!( + !fx.state_path().exists(), + "the emptied ledger must be deleted" + ); + assert_eq!( + std::fs::read(fx.installed_index()).unwrap(), + ORIG_INDEX, + "the installed tree is never touched by a vendored revert" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index c0ea588f..5382a7d2 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -19,6 +19,7 @@ const UUID: &str = "11111111-1111-4111-8111-111111111111"; fn default_args(cwd: &Path) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: socket_patch_cli::args::GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index e54baf87..4ded3379 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -946,11 +946,32 @@ async fn vendored_npm_purl_skipped_even_without_installed_tree() { /// `not_found`. #[tokio::test] async fn vendored_purl_excluded_from_rollback() { + // v4 duality rework: `rollback` REVERTS vendored state by default — + // lock restored byte-for-byte, artifact + ledger entry gone, manifest + // entry removed. (The pre-v4 benign skip is what `--preserve-state`'s + // artifact/entry retention replaced.) Both the unscoped and the + // identifier-scoped spellings act; the fixture is re-vendored between + // them. let fx = npm_fixture(); - assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); - let lock_after_vendor = fx.lock_bytes(); - + // The default rollback now removes the manifest entry AND sweeps the + // now-unused blobs, and re-vendoring needs both back — snapshot the + // seeded manifest + blob store and restore them between iterations. + let seeded_manifest = std::fs::read(fx.manifest_path()).expect("seeded manifest"); + let blobs_dir = fx.root().join(".socket").join("blobs"); + let seeded_blobs: Vec<(std::ffi::OsString, Vec)> = std::fs::read_dir(&blobs_dir) + .expect("seeded blobs dir") + .map(|e| { + let e = e.expect("dir entry"); + (e.file_name(), std::fs::read(e.path()).expect("blob bytes")) + }) + .collect(); for extra in [&[][..], &[PURL][..]] { + std::fs::write(fx.manifest_path(), &seeded_manifest).expect("re-seed manifest"); + std::fs::create_dir_all(&blobs_dir).expect("blobs dir"); + for (name, bytes) in &seeded_blobs { + std::fs::write(blobs_dir.join(name), bytes).expect("re-seed blob"); + } + assert_eq!(vendor_run(vendor_args(fx.root())).await, 0); let mut argv = vec![ "rollback", "--json", @@ -963,28 +984,41 @@ async fn vendored_purl_excluded_from_rollback() { let out: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { panic!("rollback --json must emit JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") }); - assert_eq!(code, 0, "vendored-only rollback exits 0: {out:#}"); + assert_eq!(code, 0, "vendored rollback exits 0: {out:#}"); assert_eq!(out["status"], "success", "{out:#}"); assert_eq!( out["vendored"], + json!([]), + "no benign skip remains — the vendored leg acted: {out:#}" + ); + assert_eq!( + out["vendoredReverted"], json!([PURL]), - "vendored skip must be surfaced: {out:#}" + "the revert must be surfaced: {out:#}" ); - assert_eq!(out["rolledBack"], 0, "{out:#}"); assert_eq!(out["failed"], 0, "{out:#}"); - } + assert_eq!( + out["manifest"]["removedEntries"], + json!([PURL]), + "the manifest entry leaves with the vendored state: {out:#}" + ); - assert_eq!( - std::fs::read(fx.installed_index()).unwrap(), - ORIG_INDEX, - "rollback must not touch the installed tree of a vendored purl" - ); - assert_eq!( - fx.lock_bytes(), - lock_after_vendor, - "rollback must not disturb the vendored lock wiring" - ); - assert!(fx.tgz_path().is_file(), "artifact untouched"); + assert_eq!( + std::fs::read(fx.installed_index()).unwrap(), + ORIG_INDEX, + "rollback must not touch the installed tree of a vendored purl" + ); + assert_eq!( + fx.lock_bytes(), + fx.original_lock, + "the lock must be restored byte-for-byte" + ); + assert!(!fx.tgz_path().is_file(), "artifact deleted"); + assert!(!fx.state_path().is_file(), "emptied ledger deleted"); + let manifest: Value = + serde_json::from_slice(&std::fs::read(fx.manifest_path()).unwrap()).unwrap(); + assert_eq!(manifest["patches"], json!({}), "manifest entry removed"); + } } // ───────────────────────────────────────────────────────────────────── @@ -2378,6 +2412,7 @@ snapshots: /// `in_process_redirect_pnpm.rs` shape). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { + paths: Vec::new(), common: GlobalArgs { cwd: cwd.to_path_buf(), org: Some(ORG.to_string()), diff --git a/crates/socket-patch-cli/tests/remove_duality_invariants.rs b/crates/socket-patch-cli/tests/remove_duality_invariants.rs new file mode 100644 index 00000000..7012492e --- /dev/null +++ b/crates/socket-patch-cli/tests/remove_duality_invariants.rs @@ -0,0 +1,852 @@ +//! Integration tests for the v5.0 remove↔rollback duality surface of +//! `remove`: `--preserve-state` (restore the tree, keep the local patch +//! state), its `--skip-rollback` conflict, the archive-sweep extension of +//! the default GC, the hosted-redirect leg, and the drift-keep +//! partial-failure contract. +//! +//! Binary-driven (spawns `CARGO_BIN_EXE_socket-patch` through +//! `common::run_with_env`, which scrubs the ambient `SOCKET_*` env), fully +//! offline: every fixture is hand-written camelCase JSON plus blobs staged +//! under `.socket/blobs`, and every wet run passes `--offline`. +//! +//! DISCREPANCY PINS (implementation is the source of truth): +//! CLI_CONTRACT.md ("remove unwinds hosted redirects (v5.0)") promises "a +//! hosted-only match works with no manifest at all (mirroring the +//! detached-vendored escape)". The implementation does NOT deliver that: +//! `remove.rs`'s manifest-missing gate recognizes the hosted-only match and +//! proceeds, but the `matching.is_empty()` branch afterwards knows only the +//! detached-vendored escape and falls through to `not_found` (exit 1) +//! without ever reaching the hosted leg. The two `*_pins_not_found` tests +//! below pin that ACTUAL behavior; the hosted leg itself is reachable (and +//! covered here) only when the identifier also matches a manifest entry. + +use std::path::{Path, PathBuf}; + +#[path = "common/mod.rs"] +mod common; + +/// Spawn `socket-patch remove` with the scrubbed env (`common::run_with_env`) +/// plus telemetry disabled; `env` entries land last so per-test injections +/// (e.g. `SOCKET_PRESERVE_STATE`) survive the scrub. +fn run_remove(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { + let mut full = vec!["remove"]; + full.extend_from_slice(args); + let mut env_full = vec![("SOCKET_TELEMETRY_DISABLED", "1")]; + env_full.extend_from_slice(env); + common::run_with_env(cwd, &full, &env_full) +} + +fn read_json_file(path: &Path) -> serde_json::Value { + let body = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_str(&body).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +fn read_manifest(socket: &Path) -> serde_json::Value { + read_json_file(&socket.join("manifest.json")) +} + +/// Events carrying `action == "removed"` and a string purl. +fn removed_event_purls(v: &serde_json::Value) -> Vec { + v["events"] + .as_array() + .map(|events| { + events + .iter() + .filter(|e| e["action"] == "removed" && e["purl"].is_string()) + .filter_map(|e| e["purl"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// 1. --preserve-state on an installed, genuinely patched agent-mode package +// --------------------------------------------------------------------------- + +const PRESERVE_PURL: &str = "pkg:npm/__preserve_dual_test__@1.0.0"; +const PRESERVE_UUID: &str = "77777777-7777-4777-8777-777777777777"; +const ORIGINAL_BYTES: &[u8] = b"original contents\n"; +const PATCHED_BYTES: &[u8] = b"patched contents\n"; + +/// Manifest + blobs + installed-at-PATCHED-bytes package for +/// [`PRESERVE_PURL`]. Returns (socket_dir, before_hash, after_hash). +fn make_preserve_fixture(root: &Path) -> (PathBuf, String, String) { + let before_hash = common::git_sha256(ORIGINAL_BYTES); + let after_hash = common::git_sha256(PATCHED_BYTES); + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + let manifest = format!( + r#"{{ + "patches": {{ + "{PRESERVE_PURL}": {{ + "uuid": "{PRESERVE_UUID}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/a.js": {{ "beforeHash": "{before_hash}", "afterHash": "{after_hash}" }} + }}, + "vulnerabilities": {{}}, + "description": "synthetic preserve test patch", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).expect("create blobs dir"); + std::fs::write(blobs.join(&before_hash), ORIGINAL_BYTES).expect("stage before blob"); + std::fs::write(blobs.join(&after_hash), PATCHED_BYTES).expect("stage after blob"); + + std::fs::write( + root.join("package.json"), + r#"{ "name": "preserve-fixture", "version": "0.0.0" }"#, + ) + .expect("write root package.json"); + let pkg_dir = root.join("node_modules/__preserve_dual_test__"); + std::fs::create_dir_all(&pkg_dir).expect("create package dir"); + std::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "__preserve_dual_test__", "version": "1.0.0" }"#, + ) + .expect("write package.json"); + std::fs::write(pkg_dir.join("a.js"), PATCHED_BYTES).expect("write patched a.js"); + (socket, before_hash, after_hash) +} + +/// `remove --preserve-state` on an installed, patched package must restore +/// the file to its ORIGINAL bytes (the rollback half still runs) while +/// keeping ALL local state: the manifest entry survives byte-for-byte, both +/// blobs survive (GC is skipped entirely), `summary.removed` stays 0, and +/// no per-purl `removed` event fires. +/// +/// ACTUAL event shape pinned here: for a pure agent-mode patch the wet run +/// emits ONLY the purl-less artifact carrier (`details.rolledBack: 1`) — the +/// `vendor_state_preserved` Skipped reason exists only for vendored entries +/// (pinned by the next test). `--offline` proves the restore came from the +/// staged before-blob, not the network. +#[test] +fn preserve_state_restores_but_keeps_entry() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (socket, before_hash, after_hash) = make_preserve_fixture(tmp.path()); + let manifest_before = std::fs::read(socket.join("manifest.json")).expect("read before"); + + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[PRESERVE_PURL, "--json", "--yes", "--offline", "--preserve-state"], + &[], + ); + assert_eq!( + code, 0, + "preserve-state remove must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "remove"); + assert_eq!(v["status"], "success"); + assert_eq!(v["dryRun"], serde_json::Value::Bool(false)); + assert_eq!( + v["summary"]["removed"], 0, + "no manifest entry is deleted under --preserve-state; envelope={v}" + ); + + // The system half really happened: the installed file is back at its + // ORIGINAL bytes. + let restored = + std::fs::read(tmp.path().join("node_modules/__preserve_dual_test__/a.js")).unwrap(); + assert_eq!( + restored, ORIGINAL_BYTES, + "the patched file must be restored to its original bytes" + ); + + // The state half was preserved: manifest byte-identical (entry kept)... + let manifest_after = std::fs::read(socket.join("manifest.json")).expect("read after"); + assert_eq!( + manifest_before, manifest_after, + "--preserve-state must not touch the manifest" + ); + // ...and BOTH blobs survive — GC is skipped, so even the afterHash blob + // (an orphan a default remove would sweep) stays for the re-apply. + assert!( + socket.join("blobs").join(&before_hash).exists(), + "beforeHash blob must be kept" + ); + assert!( + socket.join("blobs").join(&after_hash).exists(), + "afterHash blob must be kept (GC skipped under --preserve-state)" + ); + + // Envelope events: no per-purl removal, and the artifact carrier reports + // the rollback that DID happen. + assert!( + removed_event_purls(&v).is_empty(), + "no per-purl removed event may fire under --preserve-state; envelope={v}" + ); + let events = v["events"].as_array().expect("events array"); + let carrier = events + .iter() + .find(|e| e["action"] == "removed" && e["purl"].is_null()) + .unwrap_or_else(|| panic!("expected the artifact carrier event: {events:?}")); + assert_eq!( + carrier["details"]["rolledBack"], 1, + "the carrier must report the one rolled-back package; carrier={carrier}" + ); + assert_eq!( + carrier["details"]["blobsRemoved"], 0, + "no blobs may be swept under --preserve-state; carrier={carrier}" + ); +} + +// --------------------------------------------------------------------------- +// 1b. --preserve-state on a vendored entry: the actual state-preserved reason +// --------------------------------------------------------------------------- + +const PV_PURL: &str = "pkg:npm/__preserve_vendored__@1.0.0"; +const PV_UUID: &str = "55555555-5555-4555-8555-555555555555"; + +fn write_manifest_files_empty(root: &Path, purl: &str, uuid: &str) -> PathBuf { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + let manifest = format!( + r#"{{ + "patches": {{ + "{purl}": {{ + "uuid": "{uuid}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "synthetic remove-duality test patch", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); + socket +} + +/// Vendor ledger with one npm entry (fixture copied from +/// cli_remove_silent.rs / remove_invariants.rs — do not edit those files). +fn write_vendor_state_wired(root: &Path, purl: &str, uuid: &str, wiring: &str) -> PathBuf { + let vendor = root.join(".socket/vendor"); + let artifact_dir = vendor.join("npm").join(uuid); + std::fs::create_dir_all(&artifact_dir).expect("create artifact dir"); + std::fs::write(artifact_dir.join("package.tgz"), b"tgz").expect("write artifact"); + let state = format!( + r#"{{ + "version": 1, + "entries": {{ + "{purl}": {{ + "ecosystem": "npm", + "basePurl": "{purl}", + "uuid": "{uuid}", + "artifact": {{ "path": ".socket/vendor/npm/{uuid}/package.tgz" }}, + "wiring": {wiring} + }} + }} +}}"# + ); + std::fs::write(vendor.join("state.json"), state).expect("write vendor state"); + artifact_dir +} + +/// The vendored flavor of `--preserve-state` pins the ACTUAL state-preserved +/// reason code remove.rs emits: `skipped`/`vendor_state_preserved`. The +/// ledger entry is kept byte-identical, the artifact dir survives, the +/// manifest entry survives, and `summary.removed` stays 0. +#[test] +fn preserve_state_keeps_vendored_ledger_and_artifact() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = write_manifest_files_empty(tmp.path(), PV_PURL, PV_UUID); + let artifact_dir = write_vendor_state_wired(tmp.path(), PV_PURL, PV_UUID, "[]"); + let manifest_before = std::fs::read(socket.join("manifest.json")).expect("read before"); + let ledger_path = tmp.path().join(".socket/vendor/state.json"); + let ledger_before = std::fs::read(&ledger_path).expect("read ledger before"); + + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[PV_PURL, "--json", "--yes", "--offline", "--preserve-state"], + &[], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success"); + assert_eq!(v["summary"]["removed"], 0); + + // The ACTUAL preserved-state reason code from remove.rs. + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["action"] == "skipped" + && e["errorCode"] == "vendor_state_preserved" + && e["purl"] == PV_PURL), + "expected a skipped/vendor_state_preserved event: {events:?}" + ); + + // Ledger entry kept BYTE-IDENTICAL (the liveness contract: its wiring + // records replay as no-ops on a later revert), artifact + manifest kept. + assert_eq!( + std::fs::read(&ledger_path).expect("read ledger after"), + ledger_before, + "--preserve-state must keep the vendor ledger entry byte-identical" + ); + assert!( + artifact_dir.join("package.tgz").exists(), + "the vendored artifact must be kept" + ); + assert_eq!( + std::fs::read(socket.join("manifest.json")).expect("read after"), + manifest_before, + "the manifest entry must be kept" + ); +} + +// --------------------------------------------------------------------------- +// 2. --preserve-state conflicts with --skip-rollback (exit 2), flag- or +// env-sourced +// --------------------------------------------------------------------------- + +/// The two flags select the do-nothing quadrant: `--skip-rollback` keeps the +/// tree and drops the state, `--preserve-state` restores the tree and keeps +/// the state. Together → self-enforced usage error, exit 2, before anything +/// is read or created. Fires identically when either side comes from its +/// env var (`SOCKET_PRESERVE_STATE=true`). +#[test] +fn preserve_conflicts_with_skip_rollback() { + // Flag-sourced. + let tmp = tempfile::tempdir().expect("tempdir"); + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[ + "pkg:npm/x@1.0.0", + "--json", + "--yes", + "--preserve-state", + "--skip-rollback", + ], + &[], + ); + assert_eq!( + code, 2, + "the conflict is a usage error; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stderr.contains("no-op"), + "the error must explain the no-op quadrant; got {stderr:?}" + ); + assert!( + stdout.trim().is_empty(), + "usage errors print to stderr, not a JSON envelope; got {stdout:?}" + ); + // The conflict fires before any store is read or created. + assert!( + !tmp.path().join(".socket").exists(), + "a usage error must not create a .socket directory" + ); + + // Env-sourced: SOCKET_PRESERVE_STATE=true + --skip-rollback conflicts + // exactly the same way (the contract row says flag- or env-sourced alike). + let tmp2 = tempfile::tempdir().expect("tempdir"); + let (code2, _stdout2, stderr2) = run_remove( + tmp2.path(), + &["pkg:npm/x@1.0.0", "--json", "--yes", "--skip-rollback"], + &[("SOCKET_PRESERVE_STATE", "true")], + ); + assert_eq!( + code2, 2, + "env-sourced preserve-state must conflict too; stderr=\n{stderr2}" + ); + assert!( + stderr2.contains("no-op"), + "same self-enforced usage error text; got {stderr2:?}" + ); + assert!(!tmp2.path().join(".socket").exists()); +} + +// --------------------------------------------------------------------------- +// 3. Default remove sweeps diff/package archives too (v5.0 GC extension) +// --------------------------------------------------------------------------- + +const ARCH_UUID_A: &str = "11111111-1111-4111-8111-111111111111"; +const ARCH_UUID_B: &str = "22222222-2222-4222-8222-222222222222"; + +/// Two-entry manifest whose uuids anchor the archive keep-rule. +fn make_two_entry_socket_dir(root: &Path) -> PathBuf { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + let manifest = format!( + r#"{{ + "patches": {{ + "pkg:npm/__archive_a__@1.0.0": {{ + "uuid": "{ARCH_UUID_A}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "synthetic archive test patch A", + "license": "MIT", + "tier": "free" + }}, + "pkg:npm/__archive_b__@2.0.0": {{ + "uuid": "{ARCH_UUID_B}", + "exportedAt": "2024-01-02T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "synthetic archive test patch B", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ); + std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); + socket +} + +/// The default cleanup now covers `.socket/diffs` and `.socket/packages` +/// (`.tar.gz`, kept iff the uuid is still referenced by the +/// post-removal manifest) in addition to blobs. Removing A must sweep A's +/// archives from BOTH dirs while B's — still referenced by the second +/// manifest entry — survive; the artifact carrier reports the count. +#[test] +fn default_remove_sweeps_archives_too() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = make_two_entry_socket_dir(tmp.path()); + for dir in ["diffs", "packages"] { + let d = socket.join(dir); + std::fs::create_dir_all(&d).expect("create archive dir"); + std::fs::write(d.join(format!("{ARCH_UUID_A}.tar.gz")), b"a-archive").unwrap(); + std::fs::write(d.join(format!("{ARCH_UUID_B}.tar.gz")), b"b-archive").unwrap(); + } + + let (code, stdout, stderr) = run_remove( + tmp.path(), + &["pkg:npm/__archive_a__@1.0.0", "--json", "--yes", "--skip-rollback"], + &[], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success"); + assert_eq!(v["summary"]["removed"], 1); + assert_eq!( + removed_event_purls(&v), + vec!["pkg:npm/__archive_a__@1.0.0"], + "exactly A's manifest entry is removed" + ); + + // A's archives are gone from BOTH archive dirs; B's survive in both. + for dir in ["diffs", "packages"] { + assert!( + !socket.join(dir).join(format!("{ARCH_UUID_A}.tar.gz")).exists(), + "the removed entry's {dir} archive must be swept" + ); + assert!( + socket.join(dir).join(format!("{ARCH_UUID_B}.tar.gz")).exists(), + "the kept entry's {dir} archive must survive" + ); + } + + // The purl-less artifact carrier reports the two swept archives. + let events = v["events"].as_array().expect("events array"); + let carrier = events + .iter() + .find(|e| e["action"] == "removed" && e["purl"].is_null()) + .unwrap_or_else(|| panic!("expected the artifact carrier event: {events:?}")); + assert_eq!( + carrier["details"]["archivesRemoved"], 2, + "one diff + one package archive swept; carrier={carrier}" + ); + + // The keep-rule really is manifest-anchored: B's entry survives. + let manifest = read_manifest(&socket); + assert!(manifest["patches"]["pkg:npm/__archive_b__@2.0.0"].is_object()); +} + +// --------------------------------------------------------------------------- +// 4. Hosted-redirect leg +// --------------------------------------------------------------------------- + +const NPM_PURL: &str = "pkg:npm/left-pad@1.3.0"; +const NPM_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const ORIG_RESOLVED: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; +const ORIG_INTEGRITY: &str = "sha512-UPSTREAM=="; +const HOSTED_RESOLVED: &str = "https://patch.socket.dev/patch/npm/left-pad/1.3.0/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; +const HOSTED_INTEGRITY: &str = "sha512-PATCHED=="; + +/// A lockfileVersion-3 package-lock.json whose left-pad entry currently +/// holds the HOSTED (redirected) resolved/integrity pair. +fn redirected_lock_text() -> String { + format!( + r#"{{ + "name": "hosted-fixture", + "version": "0.0.0", + "lockfileVersion": 3, + "packages": {{ + "": {{ "name": "hosted-fixture", "version": "0.0.0" }}, + "node_modules/left-pad": {{ + "name": "left-pad", + "version": "1.3.0", + "resolved": "{HOSTED_RESOLVED}", + "integrity": "{HOSTED_INTEGRITY}" + }} + }} +}} +"# + ) +} + +/// One hand-written camelCase PatchRecord body (the shared record shape of +/// the manifest and the redirect ledger). +fn record_json(uuid: &str, description: &str) -> String { + format!( + r#"{{ + "uuid": "{uuid}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "{description}", + "license": "MIT", + "tier": "free" + }}"# + ) +} + +/// Redirect ledger (real `RedirectState` schema: version/mode/edits/records) +/// with ONE npm record and its recorded `redirect_npm_lock_entry` edit +/// matching [`redirected_lock_text`]. +fn npm_redirect_ledger_text() -> String { + let record = record_json(NPM_UUID, "synthetic hosted npm patch"); + format!( + r#"{{ + "version": 1, + "mode": "hosted", + "edits": [ + {{ + "path": "package-lock.json", + "kind": "redirect_npm_lock_entry", + "action": "rewritten", + "key": "node_modules/left-pad", + "original": {{ "resolved": "{ORIG_RESOLVED}", "integrity": "{ORIG_INTEGRITY}" }}, + "new": {{ "resolved": "{HOSTED_RESOLVED}", "integrity": "{HOSTED_INTEGRITY}" }} + }} + ], + "records": {{ + "{NPM_PURL}": {record} + }} +}}"# + ) +} + +fn write_redirect_ledger_text(root: &Path, text: &str) -> PathBuf { + let vendor = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor).expect("create .socket/vendor"); + let path = vendor.join("redirect-state.json"); + std::fs::write(&path, text).expect("write redirect ledger"); + path +} + +/// Hosted-only remove with no manifest at all (a hosted-only project's +/// per-purl exit path): the redirect is unwound — lock restored to the +/// pre-redirect entry, emptied ledger deleted — and the unwind IS the +/// removal, so the `hosted_reverted` event counts toward +/// `summary.removed` (the detached-vendored convention). +#[test] +fn hosted_only_remove_without_manifest_unwinds_redirect() { + let tmp = tempfile::tempdir().expect("tempdir"); + let lock_text = redirected_lock_text(); + let lock_path = tmp.path().join("package-lock.json"); + std::fs::write(&lock_path, &lock_text).unwrap(); + let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "remove"); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!( + v["summary"]["removed"], 1, + "the hosted unwind IS the removal on this path; envelope={v}" + ); + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["action"] == "removed" + && e["purl"] == NPM_PURL + && e["errorCode"] == "hosted_reverted"), + "removed/hosted_reverted event expected; envelope={v}" + ); + + // The lock holds exactly the pre-redirect entry again (same whole-file + // derivation as the manifest-path twin below). + let mut expected: serde_json::Value = serde_json::from_str(&lock_text).unwrap(); + let entry = expected["packages"]["node_modules/left-pad"] + .as_object_mut() + .expect("lock entry object"); + entry.insert("resolved".into(), serde_json::json!(ORIG_RESOLVED)); + entry.insert("integrity".into(), serde_json::json!(ORIG_INTEGRITY)); + let expected_text = format!("{}\n", serde_json::to_string_pretty(&expected).unwrap()); + assert_eq!( + std::fs::read_to_string(&lock_path).unwrap(), + expected_text, + "the lock must hold exactly the pre-redirect entry" + ); + assert!( + !ledger_path.exists(), + "the emptied redirect ledger must be deleted" + ); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "no manifest may be materialized as a side effect" + ); +} + +/// The hosted leg where it IS reachable: the identifier matches a manifest +/// entry AND the redirect ledger's record for the same purl. The remove +/// unwinds the redirect (per-purl npm revert): the lock entry gets its +/// original resolved/integrity back byte-exactly, the emptied ledger is +/// deleted, and the envelope carries the `hosted_reverted` event alongside +/// the per-purl manifest removal. +#[test] +fn hosted_remove_with_manifest_entry_unwinds_redirect() { + let tmp = tempfile::tempdir().expect("tempdir"); + let lock_text = redirected_lock_text(); + let lock_path = tmp.path().join("package-lock.json"); + std::fs::write(&lock_path, &lock_text).unwrap(); + let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); + let socket = write_manifest_files_empty(tmp.path(), NPM_PURL, NPM_UUID); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success"); + assert_eq!( + v["summary"]["removed"], 1, + "the hosted_reverted event must not inflate the manifest-entry count" + ); + + // The lock is restored byte-exactly: derive the expected bytes the same + // way the revert writes them (parse the fixture, put the originals back, + // serialize with the workspace's preserve_order serde_json + trailing + // newline). This pins the WHOLE file, not just the two fields. + let mut expected: serde_json::Value = serde_json::from_str(&lock_text).unwrap(); + let entry = expected["packages"]["node_modules/left-pad"] + .as_object_mut() + .expect("lock entry object"); + entry.insert("resolved".into(), serde_json::json!(ORIG_RESOLVED)); + entry.insert("integrity".into(), serde_json::json!(ORIG_INTEGRITY)); + let expected_text = format!("{}\n", serde_json::to_string_pretty(&expected).unwrap()); + let reverted_text = std::fs::read_to_string(&lock_path).unwrap(); + assert_eq!( + reverted_text, expected_text, + "the lock must hold exactly the pre-redirect entry" + ); + assert!( + !reverted_text.contains(NPM_UUID), + "no hosted artifact URL (patch uuid) may survive in the lock" + ); + + // Record + edit dropped → empty ledger deleted outright. + assert!( + !ledger_path.exists(), + "the emptied redirect ledger must be deleted; envelope={v}" + ); + + // Envelope: the hosted unwind event plus the plain per-purl removal. + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["action"] == "removed" + && e["errorCode"] == "hosted_reverted" + && e["purl"] == NPM_PURL), + "expected a removed/hosted_reverted event: {events:?}" + ); + assert!( + events + .iter() + .any(|e| e["action"] == "removed" && e["purl"] == NPM_PURL && e["errorCode"].is_null()), + "expected the per-purl manifest-removal event: {events:?}" + ); + + // The manifest entry itself is gone. + let manifest = read_manifest(&socket); + assert!( + manifest["patches"].as_object().expect("patches").is_empty(), + "the manifest entry must be removed" + ); +} + +// --------------------------------------------------------------------------- +// 5. Unsupported-ecosystem hosted purl fails closed +// --------------------------------------------------------------------------- + +const GEM_PURL: &str = "pkg:gem/rexml@3.2.5"; +const GEM_UUID: &str = "aaaa1111-2222-4333-8444-555566667777"; + +/// Ledger with a gem record (no per-purl revert exists) AND a second npm +/// record, so a `remove pkg:gem/…` identifier does NOT cover the full +/// record set and the whole-ledger replay cannot serve it. +fn gem_plus_npm_ledger_text() -> String { + let gem_record = record_json(GEM_UUID, "synthetic hosted gem patch"); + let npm_record = record_json(NPM_UUID, "synthetic hosted npm patch"); + format!( + r#"{{ + "version": 1, + "mode": "hosted", + "edits": [ + {{ + "path": "Gemfile", + "kind": "redirect_gem_source_block", + "action": "added", + "key": "rexml", + "new": "source \"https://patch.socket.dev/gem/t0k3n\" do\n gem \"rexml\"\nend\n" + }} + ], + "records": {{ + "{GEM_PURL}": {gem_record}, + "{NPM_PURL}": {npm_record} + }} +}}"# + ) +} + +/// With a manifest entry for the gem purl (the only way the hosted leg is +/// reachable — see the module docs), the unsupported-ecosystem hosted +/// target fails closed BEFORE the manifest mutation: exit 1, top-level +/// `hosted_revert_unsupported`, and BOTH stores byte-identical. +#[test] +fn hosted_unsupported_ecosystem_remove_fails_closed() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ledger_path = write_redirect_ledger_text(tmp.path(), &gem_plus_npm_ledger_text()); + let ledger_before = std::fs::read(&ledger_path).unwrap(); + let socket = write_manifest_files_empty(tmp.path(), GEM_PURL, GEM_UUID); + let manifest_before = std::fs::read(socket.join("manifest.json")).unwrap(); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[GEM_PURL, "--json", "--yes", "--offline"], &[]); + assert_eq!( + code, 1, + "unsupported hosted revert must fail; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "remove"); + assert_eq!(v["status"], "error"); + assert_eq!( + v["error"]["code"], "hosted_revert_unsupported", + "envelope={v}" + ); + let msg = v["error"]["message"].as_str().expect("message string"); + assert!( + msg.contains(GEM_PURL) && msg.contains("scan --mode hosted"), + "the error must name the purl and the remedy; got {msg}" + ); + assert_eq!(v["summary"]["removed"], 0); + + // Fail-closed: ledger AND manifest byte-identical. + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + ledger_before, + "the redirect ledger must be unchanged" + ); + assert_eq!( + std::fs::read(socket.join("manifest.json")).unwrap(), + manifest_before, + "the manifest was not modified (the error message promises it)" + ); +} + +/// Manifest-less twin of the unsupported-ecosystem refusal: the gem+npm +/// ledger's gem identifier reaches the hosted-only removal path (the +/// manifest-missing escape), where the gem record has no per-purl revert +/// and the identifier does NOT cover the full record set (so the +/// whole-ledger replay cannot serve it) — fail closed with +/// `hosted_revert_unsupported`, ledger untouched. +#[test] +fn hosted_only_unsupported_remove_without_manifest_fails_closed() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ledger_path = write_redirect_ledger_text(tmp.path(), &gem_plus_npm_ledger_text()); + let ledger_before = std::fs::read(&ledger_path).unwrap(); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[GEM_PURL, "--json", "--yes", "--offline"], &[]); + assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "error", "envelope={v}"); + assert_eq!(v["error"]["code"], "hosted_revert_unsupported", "envelope={v}"); + let msg = v["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains(GEM_PURL) && msg.contains("socket-patch rollback"), + "the refusal names the purl and the unscoped-rollback remedy; envelope={v}" + ); + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + ledger_before, + "the redirect ledger must be unchanged" + ); +} + +// --------------------------------------------------------------------------- +// 6. Drift-kept vendored revert = partial failure (v5.0 drift-keep fix) +// --------------------------------------------------------------------------- + +const DK_PURL: &str = "pkg:npm/__remove_dual_test__@1.0.0"; +const DK_UUID: &str = "33333333-3333-4333-8333-333333333333"; + +/// A wiring record naming a file the npm revert backend does not edit: the +/// revert drift-keeps (`kept_artifact`) — fixture copied from +/// cli_remove_silent.rs (do not edit that file). +const DRIFTED_WIRING: &str = r#"[{ "file": "weird.txt", "kind": "npm_lock_entry", "action": "added", "key": "node_modules/x" }]"#; + +/// When EVERY matching entry's vendored revert drift-keeps, the remove did +/// not happen: exit 1, `status: partialFailure`, top-level +/// `vendor_revert_kept` (NOT `not_found` — the identifier DID match), +/// `summary.removed` honest at 0, and BOTH the manifest entry and the +/// ledger entry survive byte-for-byte (plus the artifact) so a later +/// normalize + retry can finish the job. +#[test] +fn drift_kept_vendored_remove_is_partial_failure() { + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = write_manifest_files_empty(tmp.path(), DK_PURL, DK_UUID); + let artifact_dir = write_vendor_state_wired(tmp.path(), DK_PURL, DK_UUID, DRIFTED_WIRING); + let manifest_before = std::fs::read(socket.join("manifest.json")).unwrap(); + let ledger_path = tmp.path().join(".socket/vendor/state.json"); + let ledger_before = std::fs::read(&ledger_path).unwrap(); + + let (code, stdout, stderr) = + run_remove(tmp.path(), &[DK_PURL, "--json", "--yes", "--offline"], &[]); + assert_eq!( + code, 1, + "an all-kept remove is a partial failure; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["command"], "remove"); + assert_eq!(v["status"], "partialFailure", "envelope={v}"); + assert_eq!(v["error"]["code"], "vendor_revert_kept", "envelope={v}"); + assert_eq!( + v["summary"]["removed"], 0, + "nothing was removed, the count must say so" + ); + + // The per-purl Skipped event carries the same reason code. + let events = v["events"].as_array().expect("events array"); + assert!( + events.iter().any(|e| e["action"] == "skipped" + && e["errorCode"] == "vendor_revert_kept" + && e["purl"] == DK_PURL), + "expected a skipped/vendor_revert_kept event: {events:?}" + ); + + // Fail-closed: manifest entry, ledger entry, and artifact all survive. + assert_eq!( + std::fs::read(socket.join("manifest.json")).unwrap(), + manifest_before, + "the drift-kept purl's manifest entry must survive byte-for-byte" + ); + assert_eq!( + std::fs::read(&ledger_path).unwrap(), + ledger_before, + "the drift-kept ledger entry must survive byte-for-byte" + ); + assert!( + artifact_dir.join("package.tgz").exists(), + "the vendored artifact must survive a drift-keep" + ); +} diff --git a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs new file mode 100644 index 00000000..818a8270 --- /dev/null +++ b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs @@ -0,0 +1,925 @@ +//! Integration tests for the v5.0 scan↔rollback duality: the default +//! full-state rollback (manifest cleanup + blob/archive GC), the +//! `--preserve-state` opt-out, path-glob targets, and the fail-closed +//! blob-pinning rules that keep revert data alive for out-of-scope and +//! not-installed entries. +//! +//! Same shape as `rollback_invariants.rs`: binary-driven, SOCKET_*-scrubbed +//! child processes, hand-written camelCase manifests, git-sha256 oracle, +//! `--offline` throughout (before-blobs are staged, so nothing fetches). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +/// A `rollback` command with the full `SOCKET_*` environment scrubbed and +/// the working directory pinned (same rationale as the twin helper in +/// `rollback_invariants.rs`: an ambient `SOCKET_OFFLINE`/`SOCKET_DRY_RUN`/ +/// `SOCKET_PRESERVE_STATE` must never satisfy — or sabotage — a test that +/// is named after the flag's real code path). Scrub by prefix, not list. +fn rollback_cmd(cwd: &Path) -> Command { + let mut cmd = Command::new(binary()); + cmd.arg("rollback").current_dir(cwd); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd +} + +fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + let out = rollback_cmd(cwd) + .args(args) + .output() + .expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// Git-SHA256: SHA256("blob \0" ++ content). +fn git_sha256(content: &[u8]) -> String { + let header = format!("blob {}\0", content.len()); + let mut hasher = Sha256::new(); + hasher.update(header.as_bytes()); + hasher.update(content); + hex::encode(hasher.finalize()) +} + +/// One hand-written camelCase manifest entry (single `package/index.js` +/// file row), matching the TS-compatible on-disk schema. +fn manifest_entry(purl: &str, uuid: &str, before_hash: &str, after_hash: &str) -> String { + format!( + r#""{purl}": {{ + "uuid": "{uuid}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{ + "package/index.js": {{ + "beforeHash": "{before_hash}", + "afterHash": "{after_hash}" + }} + }}, + "vulnerabilities": {{}}, + "description": "synthetic duality test patch", + "license": "MIT", + "tier": "free" + }}"# + ) +} + +/// Write `.socket/manifest.json` from pre-rendered entries, optionally with +/// a persisted `setup` block (which the manifest-cleanup default must +/// preserve verbatim). Returns the `.socket` dir. +fn write_socket_manifest(root: &Path, entries: &[String], with_setup: bool) -> PathBuf { + let socket = root.join(".socket"); + std::fs::create_dir_all(&socket).expect("create .socket"); + let patches = entries.join(",\n "); + let setup = if with_setup { + r#", + "setup": { "exclude": ["packages/skip-me"] }"# + } else { + "" + }; + std::fs::write( + socket.join("manifest.json"), + format!("{{\n \"patches\": {{\n {patches}\n }}{setup}\n}}"), + ) + .expect("write manifest"); + socket +} + +/// Install a fake npm package at `//` with the given +/// `index.js` bytes (`nm_rel` names the node_modules dir, e.g. +/// `node_modules` or `packages/app/node_modules`). The crawler discovers +/// nested workspace trees, so both spellings work. +fn install_npm_pkg(root: &Path, nm_rel: &str, name: &str, index_js: &[u8]) -> PathBuf { + let pkg_dir = root.join(nm_rel).join(name); + std::fs::create_dir_all(&pkg_dir).expect("create package dir"); + std::fs::write( + pkg_dir.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "1.0.0" }}"#), + ) + .expect("write package.json"); + std::fs::write(pkg_dir.join("index.js"), index_js).expect("write index.js"); + pkg_dir +} + +fn write_root_package_json(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "duality-invariants-root", "version": "0.0.0" }"#, + ) + .expect("write root package.json"); +} + +fn stage_blob(socket: &Path, hash: &str, content: &[u8]) { + let blobs = socket.join("blobs"); + std::fs::create_dir_all(&blobs).expect("create blobs dir"); + std::fs::write(blobs.join(hash), content).expect("stage blob"); +} + +/// Stage `.tar.gz` in both archive stores (`.socket/diffs` and +/// `.socket/packages`) — the per-patch download artifacts the default GC +/// must sweep once the entry leaves the manifest. +fn stage_archives(socket: &Path, uuid: &str) -> (PathBuf, PathBuf) { + let diff = socket.join("diffs").join(format!("{uuid}.tar.gz")); + let pkg = socket.join("packages").join(format!("{uuid}.tar.gz")); + for path in [&diff, &pkg] { + std::fs::create_dir_all(path.parent().expect("archive path has a parent")) + .expect("create archive dir"); + std::fs::write(path, b"synthetic-archive-bytes").expect("stage archive"); + } + (diff, pkg) +} + +/// Sorted file names in `dir` (empty when the dir does not exist). +fn dir_entries(dir: &Path) -> Vec { + match std::fs::read_dir(dir) { + Ok(rd) => { + let mut v: Vec = rd + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + v.sort(); + v + } + Err(_) => Vec::new(), + } +} + +/// The default single-package fixture: an installed npm package whose +/// `index.js` holds the PATCHED bytes, a manifest entry (with a `setup` +/// block), both blobs staged, and the entry's diff + package archives +/// staged. Returned paths/hashes drive the post-state assertions. +struct DefaultFixture { + root: tempfile::TempDir, + socket: PathBuf, + pkg_dir: PathBuf, + purl: &'static str, + uuid: &'static str, + before: &'static [u8], + after: &'static [u8], + before_hash: String, + after_hash: String, +} + +fn default_fixture() -> DefaultFixture { + let before: &[u8] = b"duality-original-content\n"; + let after: &[u8] = b"duality-patched-content\n"; + let before_hash = git_sha256(before); + let after_hash = git_sha256(after); + let purl = "pkg:npm/duality-target@1.0.0"; + let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + + let root = tempfile::tempdir().expect("tempdir"); + write_root_package_json(root.path()); + let pkg_dir = install_npm_pkg(root.path(), "node_modules", "duality-target", after); + let socket = write_socket_manifest( + root.path(), + &[manifest_entry(purl, uuid, &before_hash, &after_hash)], + true, + ); + stage_blob(&socket, &before_hash, before); + stage_blob(&socket, &after_hash, after); + stage_archives(&socket, uuid); + + DefaultFixture { + root, + socket, + pkg_dir, + purl, + uuid, + before, + after, + before_hash, + after_hash, + } +} + +// --------------------------------------------------------------------------- +// 1. Default full-state rollback: restore + manifest cleanup + GC sweep +// --------------------------------------------------------------------------- + +#[test] +fn default_rollback_removes_entry_and_sweeps_blobs() { + let fx = default_fixture(); + let (code, stdout, stderr) = run(fx.root.path(), &["--json", "--offline", "--yes"]); + assert_eq!( + code, 0, + "default rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!(v["rolledBack"], 1); + assert_eq!(v["failed"], 0); + assert_eq!(v["dryRun"], false); + + // Envelope: the entry left the manifest and the GC actually swept. + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!([fx.purl]), + "stdout=\n{stdout}" + ); + assert_eq!(v["manifest"]["preserved"], false); + assert_eq!( + v["gc"]["removedBlobs"], 2, + "both the before and after blob are orphaned by the removal; stdout=\n{stdout}" + ); + assert_eq!(v["gc"]["removedDiffArchives"], 1, "stdout=\n{stdout}"); + assert_eq!(v["gc"]["removedPackageArchives"], 1, "stdout=\n{stdout}"); + assert!( + v["gc"]["bytesFreed"].as_u64().expect("bytesFreed number") > 0, + "a real sweep frees bytes; stdout=\n{stdout}" + ); + assert_eq!( + v["paths"], + serde_json::json!([]), + "no path targets were given; stdout=\n{stdout}" + ); + + // Disk: file restored to ORIGINAL bytes (independent hash oracle). + let restored = std::fs::read(fx.pkg_dir.join("index.js")).expect("read restored file"); + assert_eq!(restored, fx.before, "rollback must restore BEFORE content"); + assert_eq!(git_sha256(&restored), fx.before_hash); + + // Manifest file still exists, `patches` is empty, and the persisted + // `setup` block survived the rewrite (rollback never touches setup). + let manifest_raw = + std::fs::read_to_string(fx.socket.join("manifest.json")).expect("manifest still exists"); + let m: serde_json::Value = serde_json::from_str(&manifest_raw).expect("valid manifest JSON"); + assert_eq!( + m["patches"], + serde_json::json!({}), + "the rolled-back entry must leave the manifest; manifest=\n{manifest_raw}" + ); + assert_eq!( + m["setup"]["exclude"], + serde_json::json!(["packages/skip-me"]), + "setup state must survive manifest cleanup; manifest=\n{manifest_raw}" + ); + + // Blobs dir swept EMPTY; both archives gone. + assert_eq!( + dir_entries(&fx.socket.join("blobs")), + Vec::::new(), + "no manifest entry references any blob anymore" + ); + assert_eq!( + dir_entries(&fx.socket.join("diffs")), + Vec::::new(), + "the entry's diff archive must be swept" + ); + assert_eq!( + dir_entries(&fx.socket.join("packages")), + Vec::::new(), + "the entry's package archive must be swept" + ); +} + +// --------------------------------------------------------------------------- +// 2. --preserve-state: restore the tree, keep ALL local state, skip GC +// --------------------------------------------------------------------------- + +#[test] +fn preserve_state_keeps_everything() { + let fx = default_fixture(); + let manifest_before = + std::fs::read(fx.socket.join("manifest.json")).expect("read manifest bytes"); + + let (code, stdout, stderr) = run(fx.root.path(), &["--json", "--offline", "--preserve-state"]); + assert_eq!( + code, 0, + "preserve-state rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!(v["rolledBack"], 1, "the file restore still happens"); + assert_eq!( + v["manifest"]["preserved"], true, + "envelope must flag the preserve; stdout=\n{stdout}" + ); + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!([]), + "nothing leaves the manifest under --preserve-state; stdout=\n{stdout}" + ); + assert_eq!( + v["gc"], + serde_json::json!({ "skipped": true }), + "GC is skipped wholesale, not run-with-zero-removals; stdout=\n{stdout}" + ); + + // The system IS restored... + let restored = std::fs::read(fx.pkg_dir.join("index.js")).expect("read restored file"); + assert_eq!(restored, fx.before); + assert_eq!(git_sha256(&restored), fx.before_hash); + + // ...but every piece of local state survives byte-for-byte / on disk. + let manifest_after = + std::fs::read(fx.socket.join("manifest.json")).expect("manifest still exists"); + assert_eq!( + manifest_after, manifest_before, + "the manifest must not be rewritten at all under --preserve-state" + ); + assert_eq!( + dir_entries(&fx.socket.join("blobs")), + { + let mut expected = vec![fx.before_hash.clone(), fx.after_hash.clone()]; + expected.sort(); + expected + }, + "both blobs must survive" + ); + assert_eq!( + dir_entries(&fx.socket.join("diffs")), + vec![format!("{}.tar.gz", fx.uuid)], + "the diff archive must survive" + ); + assert_eq!( + dir_entries(&fx.socket.join("packages")), + vec![format!("{}.tar.gz", fx.uuid)], + "the package archive must survive" + ); +} + +// --------------------------------------------------------------------------- +// 3. --ecosystems scoping never sweeps another ecosystem's revert data +// --------------------------------------------------------------------------- + +/// The data-loss regression pin: an eco-scoped rollback removes only the +/// in-scope entry, and the OUT-of-scope entry keeps both its manifest +/// record and its staged beforeHash blob — the revert data a later +/// (unscoped) rollback needs. Before the pinning rule, the GC reference +/// was the post-removal manifest alone, whose remaining entries only kept +/// afterHash blobs — the pypi before-blob was swept. +#[test] +fn eco_scoped_run_pins_other_ecosystems_revert_data() { + let npm_before: &[u8] = b"eco-npm-original\n"; + let npm_after: &[u8] = b"eco-npm-patched\n"; + let npm_before_hash = git_sha256(npm_before); + let npm_after_hash = git_sha256(npm_after); + let npm_purl = "pkg:npm/eco-npm-target@1.0.0"; + let pypi_before: &[u8] = b"eco-pypi-original\n"; + let pypi_before_hash = git_sha256(pypi_before); + let pypi_after_hash = git_sha256(b"eco-pypi-patched\n"); + let pypi_purl = "pkg:pypi/eco-pypi-ghost@2.0.0"; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + let pkg_dir = install_npm_pkg(tmp.path(), "node_modules", "eco-npm-target", npm_after); + let socket = write_socket_manifest( + tmp.path(), + &[ + manifest_entry( + npm_purl, + "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + &npm_before_hash, + &npm_after_hash, + ), + manifest_entry( + pypi_purl, + "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + &pypi_before_hash, + &pypi_after_hash, + ), + ], + false, + ); + stage_blob(&socket, &npm_before_hash, npm_before); + stage_blob(&socket, &npm_after_hash, npm_after); + // The pypi package is NOT installed; only its beforeHash blob is staged + // — the only local copy of its revert data. + stage_blob(&socket, &pypi_before_hash, pypi_before); + + let (code, stdout, stderr) = run( + tmp.path(), + &["--json", "--offline", "--yes", "--ecosystems", "npm"], + ); + assert_eq!( + code, 0, + "eco-scoped rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!([npm_purl]), + "only the in-scope npm entry is removed; stdout=\n{stdout}" + ); + + // npm: restored + removed. + let restored = std::fs::read(pkg_dir.join("index.js")).expect("read restored file"); + assert_eq!(git_sha256(&restored), npm_before_hash); + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).expect("manifest exists"), + ) + .expect("valid manifest JSON"); + assert!( + m["patches"].get(npm_purl).is_none(), + "npm entry must be removed; manifest={m}" + ); + // pypi: entry REMAINS... + assert!( + m["patches"].get(pypi_purl).is_some(), + "the out-of-scope pypi entry must remain in the manifest; manifest={m}" + ); + // ...and its beforeHash blob SURVIVES the sweep, while the npm blobs + // (referenced only by the removed entry) are gone. + assert_eq!( + dir_entries(&socket.join("blobs")), + vec![pypi_before_hash.clone()], + "the pypi revert blob must be pinned; the npm blobs must be swept" + ); +} + +// --------------------------------------------------------------------------- +// 4. Not-installed entry: removed from the manifest, revert blob pinned +// --------------------------------------------------------------------------- + +/// The crawler-miss pin (remove parity): a manifest-only entry with no +/// installed package is removed on a bare rollback (the tree is already +/// unpatched), but its beforeHash blob is KEPT — a crawler miss must not +/// destroy the only local revert data. +#[test] +fn not_installed_entry_is_removed_with_pinned_blobs() { + let before: &[u8] = b"ghost-original\n"; + let before_hash = git_sha256(before); + let after_hash = git_sha256(b"ghost-patched\n"); + let purl = "pkg:npm/duality-ghost@3.0.0"; + + let tmp = tempfile::tempdir().expect("tempdir"); + // No node_modules at all — the entry has nothing installed. + let socket = write_socket_manifest( + tmp.path(), + &[manifest_entry( + purl, + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + &before_hash, + &after_hash, + )], + false, + ); + stage_blob(&socket, &before_hash, before); + + let (code, stdout, stderr) = run(tmp.path(), &["--json", "--offline", "--yes"]); + assert_eq!( + code, 0, + "all-not-installed exits 0 (the tree is already unpatched); \ + stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!(v["failed"], 0); + + // The entry surfaces as the skipped marker, never a failure. + let results = v["results"].as_array().expect("results array"); + assert_eq!(results.len(), 1, "stdout=\n{stdout}"); + assert_eq!(results[0]["purl"], purl); + assert_eq!(results[0]["skipped"], "package_not_installed"); + assert!(results[0]["path"].is_null()); + + // Removed from the manifest... + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!([purl]), + "stdout=\n{stdout}" + ); + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).expect("manifest exists"), + ) + .expect("valid manifest JSON"); + assert_eq!(m["patches"], serde_json::json!({}), "manifest={m}"); + + // ...but the beforeHash blob is pinned, not swept. + assert_eq!( + v["gc"]["removedBlobs"], 0, + "the pinned revert blob is not sweepable; stdout=\n{stdout}" + ); + assert!( + socket.join("blobs").join(&before_hash).exists(), + "the not-installed entry's beforeHash blob must survive on disk" + ); +} + +// --------------------------------------------------------------------------- +// 5-6. Target classification and no-match errors leave state untouched +// --------------------------------------------------------------------------- + +/// A bare word is NEVER silently reinterpreted as a path scope: it keeps +/// identifier semantics and fails with the familiar exit-1 error, plus a +/// hint showing the path spellings. +#[test] +fn bare_word_target_stays_identifier_error() { + let before_hash = git_sha256(b"bare-original\n"); + let after_hash = git_sha256(b"bare-patched\n"); + let tmp = tempfile::tempdir().expect("tempdir"); + let socket = write_socket_manifest( + tmp.path(), + &[manifest_entry( + "pkg:npm/bare-word-sibling@1.0.0", + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + &before_hash, + &after_hash, + )], + false, + ); + let manifest_before = + std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); + + let (code, stdout, stderr) = run(tmp.path(), &["--offline", "lodash"]); + assert_eq!( + code, 1, + "a bare word matching nothing must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stderr.contains("No patch found matching identifier: lodash"), + "the identifier error must fire, never a path scope; stderr=\n{stderr}" + ); + assert!( + stderr.contains("./lodash"), + "the error must hint the path spelling; stderr=\n{stderr}" + ); + + let manifest_after = + std::fs::read(socket.join("manifest.json")).expect("manifest still exists"); + assert_eq!( + manifest_after, manifest_before, + "a no-match error must leave the manifest byte-identical" + ); +} + +/// A path-shaped target that selects no patched package is an error (not a +/// silent empty scope), naming the pattern — and mutates nothing. +#[test] +fn path_target_matching_nothing_errors() { + let fx = default_fixture(); + let manifest_before = + std::fs::read(fx.socket.join("manifest.json")).expect("read manifest bytes"); + + let (code, stdout, stderr) = run(fx.root.path(), &["--offline", "no/such/dir"]); + assert_eq!( + code, 1, + "a no-match path pattern must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stderr.contains("path pattern matched no patched packages") + && stderr.contains("no/such/dir"), + "the error must name the pattern; stderr=\n{stderr}" + ); + + let manifest_after = + std::fs::read(fx.socket.join("manifest.json")).expect("manifest still exists"); + assert_eq!( + manifest_after, manifest_before, + "a no-match error must leave the manifest byte-identical" + ); + let content = std::fs::read(fx.pkg_dir.join("index.js")).expect("read installed file"); + assert_eq!(content, fx.after, "the installed file must stay patched"); + assert!( + fx.socket.join("blobs").join(&fx.before_hash).exists() + && fx.socket.join("blobs").join(&fx.after_hash).exists(), + "blobs must be untouched" + ); +} + +// --------------------------------------------------------------------------- +// 7. Path-scoped rollback: select by installed path, leave siblings alone +// --------------------------------------------------------------------------- + +#[test] +fn path_scoped_rollback_selects_by_installed_path() { + let root_before: &[u8] = b"path-root-original\n"; + let root_after: &[u8] = b"path-root-patched\n"; + let root_before_hash = git_sha256(root_before); + let root_after_hash = git_sha256(root_after); + let root_purl = "pkg:npm/path-root-pkg@1.0.0"; + let app_before: &[u8] = b"path-app-original\n"; + let app_after: &[u8] = b"path-app-patched\n"; + let app_before_hash = git_sha256(app_before); + let app_after_hash = git_sha256(app_after); + let app_purl = "pkg:npm/path-app-pkg@1.0.0"; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + let root_pkg = install_npm_pkg(tmp.path(), "node_modules", "path-root-pkg", root_after); + let app_pkg = install_npm_pkg( + tmp.path(), + "packages/app/node_modules", + "path-app-pkg", + app_after, + ); + let socket = write_socket_manifest( + tmp.path(), + &[ + manifest_entry( + root_purl, + "11111111-1111-4111-8111-111111111111", + &root_before_hash, + &root_after_hash, + ), + manifest_entry( + app_purl, + "22222222-2222-4222-8222-222222222222", + &app_before_hash, + &app_after_hash, + ), + ], + false, + ); + stage_blob(&socket, &root_before_hash, root_before); + stage_blob(&socket, &root_after_hash, root_after); + stage_blob(&socket, &app_before_hash, app_before); + stage_blob(&socket, &app_after_hash, app_after); + + let (code, stdout, stderr) = run( + tmp.path(), + &["--json", "--offline", "--yes", "packages/app"], + ); + assert_eq!( + code, 0, + "path-scoped rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!( + v["paths"], + serde_json::json!(["packages/app"]), + "the envelope echoes the pattern verbatim; stdout=\n{stdout}" + ); + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!([app_purl]), + "only the in-scope entry is removed; stdout=\n{stdout}" + ); + assert_eq!( + v["warnings"], + serde_json::json!([]), + "the restored copy is inside the pattern — no out_of_scope warning; \ + stdout=\n{stdout}" + ); + + // The in-scope package is restored; the out-of-scope one stays patched. + let app_content = std::fs::read(app_pkg.join("index.js")).expect("read app file"); + assert_eq!(git_sha256(&app_content), app_before_hash, "app restored"); + let root_content = std::fs::read(root_pkg.join("index.js")).expect("read root file"); + assert_eq!( + root_content, root_after, + "the out-of-scope package must stay patched" + ); + + // Manifest: out-of-scope entry stays, in-scope entry gone. + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).expect("manifest exists"), + ) + .expect("valid manifest JSON"); + assert!( + m["patches"].get(root_purl).is_some(), + "out-of-scope entry must remain; manifest={m}" + ); + assert!( + m["patches"].get(app_purl).is_none(), + "in-scope entry must be removed; manifest={m}" + ); + + // Blobs: the surviving entry keeps BOTH its blobs (afterHash via the + // reference manifest, beforeHash via the still-active-entry pin); the + // removed entry's blobs are swept. + assert_eq!( + dir_entries(&socket.join("blobs")), + { + let mut expected = vec![root_before_hash.clone(), root_after_hash.clone()]; + expected.sort(); + expected + }, + "only the removed entry's blobs may be swept" + ); +} + +// --------------------------------------------------------------------------- +// 8. Invalid glob = usage error +// --------------------------------------------------------------------------- + +#[test] +fn invalid_glob_is_usage_error() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (code, stdout, stderr) = run(tmp.path(), &["packages/["]); + assert_eq!( + code, 2, + "an unparseable glob is a usage error (exit 2, before any state \ + discovery); stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stderr.contains("invalid path pattern"), + "stderr must name the problem; stderr=\n{stderr}" + ); +} + +// --------------------------------------------------------------------------- +// 9. Dry-run previews everything, mutates nothing +// --------------------------------------------------------------------------- + +#[test] +fn dry_run_mutates_nothing() { + let fx = default_fixture(); + let manifest_before = + std::fs::read(fx.socket.join("manifest.json")).expect("read manifest bytes"); + + let (code, stdout, stderr) = run(fx.root.path(), &["--json", "--offline", "--dry-run"]); + assert_eq!( + code, 0, + "dry-run must exit 0; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!(v["dryRun"], true); + assert_eq!(v["rolledBack"], 0, "a dry run mutates nothing"); + assert_eq!(v["failed"], 0); + + // The preview REPORTS the full plan: would-be manifest removal and + // would-be GC counts... + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!([fx.purl]), + "dry-run previews the would-be removal; stdout=\n{stdout}" + ); + assert!( + v["gc"].get("skipped").is_none(), + "dry-run GC is a preview, not a skip; stdout=\n{stdout}" + ); + assert_eq!(v["gc"]["removedBlobs"], 2, "stdout=\n{stdout}"); + assert_eq!(v["gc"]["removedDiffArchives"], 1, "stdout=\n{stdout}"); + assert_eq!(v["gc"]["removedPackageArchives"], 1, "stdout=\n{stdout}"); + + // ...while the disk is untouched: file still PATCHED, manifest + // byte-identical, blobs and archives all present. + let content = std::fs::read(fx.pkg_dir.join("index.js")).expect("read installed file"); + assert_eq!(content, fx.after, "dry-run must not restore the file"); + let manifest_after = + std::fs::read(fx.socket.join("manifest.json")).expect("manifest still exists"); + assert_eq!( + manifest_after, manifest_before, + "dry-run must leave the manifest byte-identical" + ); + assert_eq!( + dir_entries(&fx.socket.join("blobs")), + { + let mut expected = vec![fx.before_hash.clone(), fx.after_hash.clone()]; + expected.sort(); + expected + }, + "dry-run must not sweep blobs" + ); + assert_eq!( + dir_entries(&fx.socket.join("diffs")), + vec![format!("{}.tar.gz", fx.uuid)], + "dry-run must not sweep the diff archive" + ); + assert_eq!( + dir_entries(&fx.socket.join("packages")), + vec![format!("{}.tar.gz", fx.uuid)], + "dry-run must not sweep the package archive" + ); + + // No `.socket-stage-*` litter (the dry-run blob stage is a throwaway + // tempdir that must be gone when the process exits). + let stage_litter: Vec = dir_entries(&fx.socket) + .into_iter() + .filter(|name| name.starts_with(".socket-stage")) + .collect(); + assert!( + stage_litter.is_empty(), + "dry-run must clean up its blob stage; found: {stage_litter:?}" + ); +} + +// --------------------------------------------------------------------------- +// 10. UUID and PURL targets keep their exact single-entry semantics +// --------------------------------------------------------------------------- + +/// Build the two-entry fixture shared by both identifier sub-cases: +/// `dual-a` + `dual-b`, both installed and patched, all four blobs staged. +/// Returns (tempdir, socket, pkg_a_dir, pkg_b_dir). +fn two_entry_fixture( + a_before: &[u8], + a_after: &[u8], + b_before: &[u8], + b_after: &[u8], +) -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + let pkg_a = install_npm_pkg(tmp.path(), "node_modules", "dual-a", a_after); + let pkg_b = install_npm_pkg(tmp.path(), "node_modules", "dual-b", b_after); + let socket = write_socket_manifest( + tmp.path(), + &[ + manifest_entry( + "pkg:npm/dual-a@1.0.0", + "33333333-3333-4333-8333-333333333333", + &git_sha256(a_before), + &git_sha256(a_after), + ), + manifest_entry( + "pkg:npm/dual-b@1.0.0", + "44444444-4444-4444-8444-444444444444", + &git_sha256(b_before), + &git_sha256(b_after), + ), + ], + false, + ); + stage_blob(&socket, &git_sha256(a_before), a_before); + stage_blob(&socket, &git_sha256(a_after), a_after); + stage_blob(&socket, &git_sha256(b_before), b_before); + stage_blob(&socket, &git_sha256(b_after), b_after); + (tmp, socket, pkg_a, pkg_b) +} + +#[test] +fn uuid_and_purl_targets_still_work() { + let a_before: &[u8] = b"dual-a-original\n"; + let a_after: &[u8] = b"dual-a-patched\n"; + let b_before: &[u8] = b"dual-b-original\n"; + let b_after: &[u8] = b"dual-b-patched\n"; + + // ── UUID target removes exactly entry A ───────────────────────────── + let (tmp, socket, pkg_a, pkg_b) = two_entry_fixture(a_before, a_after, b_before, b_after); + let (code, stdout, stderr) = run( + tmp.path(), + &[ + "--json", + "--offline", + "--yes", + "33333333-3333-4333-8333-333333333333", + ], + ); + assert_eq!( + code, 0, + "uuid-targeted rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!(["pkg:npm/dual-a@1.0.0"]), + "exactly the uuid's entry is removed; stdout=\n{stdout}" + ); + let a_content = std::fs::read(pkg_a.join("index.js")).expect("read a"); + assert_eq!(a_content, a_before, "dual-a restored"); + let b_content = std::fs::read(pkg_b.join("index.js")).expect("read b"); + assert_eq!(b_content, b_after, "dual-b must stay patched"); + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).expect("manifest exists"), + ) + .expect("valid manifest JSON"); + assert!(m["patches"].get("pkg:npm/dual-a@1.0.0").is_none(), "{m}"); + assert!(m["patches"].get("pkg:npm/dual-b@1.0.0").is_some(), "{m}"); + // B's blobs survive (afterHash via the reference, beforeHash via the + // still-active pin); A's blobs are swept. + assert_eq!( + dir_entries(&socket.join("blobs")), + { + let mut expected = vec![git_sha256(b_before), git_sha256(b_after)]; + expected.sort(); + expected + }, + "only the removed entry's blobs may be swept" + ); + + // ── PURL target removes exactly entry B (fresh fixture) ───────────── + let (tmp, socket, pkg_a, pkg_b) = two_entry_fixture(a_before, a_after, b_before, b_after); + let (code, stdout, stderr) = run( + tmp.path(), + &["--json", "--offline", "--yes", "pkg:npm/dual-b@1.0.0"], + ); + assert_eq!( + code, 0, + "purl-targeted rollback must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!( + v["manifest"]["removedEntries"], + serde_json::json!(["pkg:npm/dual-b@1.0.0"]), + "exactly the purl's entry is removed; stdout=\n{stdout}" + ); + let b_content = std::fs::read(pkg_b.join("index.js")).expect("read b"); + assert_eq!(b_content, b_before, "dual-b restored"); + let a_content = std::fs::read(pkg_a.join("index.js")).expect("read a"); + assert_eq!(a_content, a_after, "dual-a must stay patched"); + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).expect("manifest exists"), + ) + .expect("valid manifest JSON"); + assert!(m["patches"].get("pkg:npm/dual-b@1.0.0").is_none(), "{m}"); + assert!(m["patches"].get("pkg:npm/dual-a@1.0.0").is_some(), "{m}"); +} diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index 830f5c03..af9d4920 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -759,6 +759,9 @@ fn rollback_json_shape_has_documented_keys() { // These keys are documented in CLI_CONTRACT.md as the rollback shape // (not yet migrated to the unified envelope). Pin them so a future // migration trips this test instead of breaking wrappers silently. + // The v4 duality rework added the always-present additive keys from + // `vendored` onward (vendoredReverted/vendoredPreserved/vendoredKept, + // hosted, manifest, gc, paths). for key in [ "status", "rolledBack", @@ -767,9 +770,25 @@ fn rollback_json_shape_has_documented_keys() { "dryRun", "warnings", "results", + "vendored", + "vendoredReverted", + "vendoredPreserved", + "vendoredKept", + "vendoredFailed", + "hosted", + "manifest", + "gc", + "paths", ] { assert!(keys.contains(key), "rollback JSON missing key: {key}"); } + // The hosted/manifest sub-objects carry their documented keys. + assert!(v["hosted"]["reverted"].is_array()); + assert!(v["hosted"]["failed"].is_array()); + assert!(v["hosted"]["unsupported"].is_array()); + assert!(v["hosted"]["editedFiles"].is_number()); + assert!(v["manifest"]["removedEntries"].is_array()); + assert!(v["manifest"]["preserved"].is_boolean()); // Not-installed entries surface as per-entry `skipped` markers inside // `results[]` — there is deliberately NO top-level `notInstalled` key. assert!( diff --git a/crates/socket-patch-cli/tests/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan_paths_e2e.rs new file mode 100644 index 00000000..16436009 --- /dev/null +++ b/crates/socket-patch-cli/tests/scan_paths_e2e.rs @@ -0,0 +1,627 @@ +//! End-to-end tests for `scan [PATHS]...` path-glob scoping (v5.0) against +//! a local `wiremock` server. +//! +//! Spawns the real `socket-patch` binary (same recipe as +//! `scan_invariants.rs`, with `rollback_invariants.rs`'s SOCKET_* env +//! scrub) and pins the CONTRACT of path-scoped scans: +//! +//! * scoping narrows the API QUERY (batch-POST body oracle) and the +//! envelope counters, echoing the patterns in an always-present `paths` +//! key; +//! * scoping NEVER narrows the prune universe — `scan PATHS --prune` +//! prunes exactly what an unscoped `scan --prune` would (the data-loss +//! pin); +//! * an empty match is a normal empty scan (exit 0, no GC, no API calls); +//! * lockfile-only supplements are excluded from a scoped scan with the +//! `path_scope_excluded_supplements` run-level warning; +//! * PATHS with `--mode hosted`/`--mode vendored`, and unparseable globs, +//! are usage errors (exit 2). + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() +} + +const ORG: &str = "test-org"; +const ROOT_PURL: &str = "pkg:npm/root-dep@1.0.0"; +const APP_PURL: &str = "pkg:npm/app-dep@1.0.0"; + +/// A `scan` command with the full `SOCKET_*` environment scrubbed (except +/// the workspace-pinned `SOCKET_NO_CONFIG`) and the working directory +/// pinned — the `rollback_invariants.rs` recipe, so no test can be +/// satisfied (or broken) by ambient environment instead of its flags. +/// `VIRTUAL_ENV` is scrubbed too: the python crawler honors it FIRST, so +/// an activated venv would inject its site-packages into every scan and +/// break the exact-batch-body oracles (see `in_process_scan.rs`). +/// Telemetry is disabled so the request log holds ONLY patch-API traffic. +fn scan_cmd(cwd: &Path) -> Command { + let mut cmd = Command::new(binary()); + cmd.arg("scan").current_dir(cwd); + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("SOCKET_") + && key.to_string_lossy() != "SOCKET_NO_CONFIG" + { + cmd.env_remove(&key); + } + } + cmd.env_remove("VIRTUAL_ENV"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd +} + +/// Run `socket-patch scan --json ` against the given API URL. +fn run_scan(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { + let out = scan_cmd(cwd) + .args([ + "--json", + "--api-url", + api_url, + "--api-token", + "fake-token-for-test", + "--org", + ORG, + ]) + .args(extra) + .output() + .expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +fn parse_envelope(stdout: &str) -> serde_json::Value { + serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout must be a JSON envelope ({e}); got: {stdout}")) +} + +// --- Fixtures --------------------------------------------------------------- + +fn write_root_package_json(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "scan-paths-root", "version": "0.0.0" }"#, + ) + .unwrap(); +} + +/// Install a fake npm package under `//node_modules//` +/// (`prefix = ""` for the root tree). The crawler walks the project tree +/// for `node_modules` dirs — including workspace subtrees — and derives +/// the PURL from each package.json. +fn write_npm_package_at(root: &Path, prefix: &str, name: &str, version: &str) { + let base = if prefix.is_empty() { + root.to_path_buf() + } else { + root.join(prefix) + }; + let pkg = base.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), + ) + .unwrap(); +} + +/// The two-subtree fixture every scoping test uses: `root-dep` installed in +/// the root `node_modules/`, `app-dep` installed under +/// `packages/app/node_modules/` (with a workspace-member package.json so +/// the layout is a realistic monorepo). +fn write_two_subtree_project(root: &Path) { + write_root_package_json(root); + write_npm_package_at(root, "", "root-dep", "1.0.0"); + std::fs::create_dir_all(root.join("packages/app")).unwrap(); + std::fs::write( + root.join("packages/app/package.json"), + r#"{ "name": "app", "version": "0.0.0" }"#, + ) + .unwrap(); + write_npm_package_at(root, "packages/app", "app-dep", "1.0.0"); +} + +/// One hand-written camelCase manifest entry (the TS-compat wire shape the +/// repo's suites hand-write everywhere). +fn manifest_entry(uuid: &str, after_hash: &str) -> serde_json::Value { + serde_json::json!({ + "uuid": uuid, + "exportedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": "0".repeat(64), + "afterHash": after_hash, + } + }, + "vulnerabilities": {}, + "description": "scan-paths fixture", + "license": "MIT", + "tier": "free", + }) +} + +/// Stage a blob file named `` under `.socket/blobs/`. +fn stage_blob(root: &Path, hash: &str) -> PathBuf { + let blobs = root.join(".socket/blobs"); + std::fs::create_dir_all(&blobs).unwrap(); + let p = blobs.join(hash); + std::fs::write(&p, vec![0u8; 64]).unwrap(); + p +} + +async fn mock_batch_empty(server: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], "canAccessPaidPatches": false, + }))) + .mount(server) + .await; +} + +// --- Request-inspection helpers (the "what did scan actually send" oracle) -- + +async fn recorded(server: &MockServer) -> Vec { + server.received_requests().await.unwrap_or_default() +} + +fn batch_posts(reqs: &[wiremock::Request]) -> Vec<&wiremock::Request> { + reqs.iter() + .filter(|r| format!("{}", r.method) == "POST" && r.url.path().ends_with("/patches/batch")) + .collect() +} + +fn by_package_gets(reqs: &[wiremock::Request]) -> usize { + reqs.iter() + .filter(|r| { + format!("{}", r.method) == "GET" && r.url.path().contains("/patches/by-package/") + }) + .count() +} + +fn req_body(req: &wiremock::Request) -> String { + String::from_utf8_lossy(&req.body).into_owned() +} + +/// The run-level `warnings[]` codes carried by the envelope (empty when the +/// additive key is absent). +fn warning_codes(v: &serde_json::Value) -> Vec { + v.get("warnings") + .and_then(|w| w.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|w| w["code"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// 1. Path scoping narrows the API query to in-scope purls only. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn paths_scope_narrows_the_query() { + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_two_subtree_project(tmp.path()); + + let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &["packages/app"]); + assert_eq!( + code, 0, + "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" + ); + + // Request oracle: exactly one batch POST carrying ONLY the in-scope + // purl. A regression that ignored the scope would send root-dep too; + // one that over-filtered would send nothing (zero POSTs). + let reqs = recorded(&server).await; + let posts = batch_posts(&reqs); + assert_eq!( + posts.len(), + 1, + "scoped scan must query the batch API exactly once; saw {}", + posts.len() + ); + let body = req_body(posts[0]); + assert!( + body.contains(APP_PURL), + "batch body must carry the in-scope purl {APP_PURL}; body: {body}" + ); + assert!( + !body.contains("root-dep"), + "batch body must NOT carry the out-of-scope purl {ROOT_PURL}; body: {body}" + ); + + // Envelope: patterns echoed verbatim, counter reflects the scope. + let v = parse_envelope(&stdout); + assert_eq!(v["status"], "success"); + assert_eq!( + v["paths"], + serde_json::json!(["packages/app"]), + "envelope must echo the path patterns verbatim; got {v}" + ); + assert_eq!( + v["scannedPackages"], 1, + "only the in-scope package counts as scanned; got {v}" + ); +} + +// --------------------------------------------------------------------------- +// 2. The prune universe is NEVER narrowed by path scoping (data-loss pin). +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn paths_never_narrow_the_prune_universe() { + // Manifest holds THREE entries: both installed packages (one in scope, + // one out of scope) and one genuinely-uninstalled orphan. A scoped + // `scan packages/app --prune` must prune exactly the orphan — the + // out-of-scope-but-installed entry and its blob MUST survive. The + // orphan is what makes this discriminate "prune keyed off the full + // crawl" from "prune didn't run at all"; the out-of-scope entry is + // what makes it discriminate full-crawl from scope-narrowed (the bug + // this pins would silently delete root-dep's patch + blob). + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_two_subtree_project(tmp.path()); + + let root_hash = "a".repeat(64); + let app_hash = "b".repeat(64); + let orphan_hash = "c".repeat(64); + let root_blob = stage_blob(tmp.path(), &root_hash); + let app_blob = stage_blob(tmp.path(), &app_hash); + let orphan_blob = stage_blob(tmp.path(), &orphan_hash); + + let manifest = serde_json::json!({ + "patches": { + ROOT_PURL: manifest_entry("11111111-1111-4111-8111-111111111111", &root_hash), + APP_PURL: manifest_entry("22222222-2222-4222-8222-222222222222", &app_hash), + "pkg:npm/gone@9.9.9": + manifest_entry("33333333-3333-4333-8333-333333333333", &orphan_hash), + } + }); + std::fs::write( + tmp.path().join(".socket/manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &server.uri(), + &["packages/app", "--prune", "--yes"], + ); + assert_eq!( + code, 0, + "scoped prune scan must exit 0; stdout={stdout}; stderr={stderr}" + ); + + // On-disk post-state: the out-of-scope INSTALLED entry survives with + // its blob; only the genuinely-uninstalled orphan was pruned. + let m: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + ) + .unwrap(); + let patches = m["patches"].as_object().unwrap(); + assert!( + patches.contains_key(ROOT_PURL), + "OUT-of-scope installed entry must survive a scoped --prune \ + (path scoping must never narrow the prune universe); got {m}" + ); + assert!( + patches.contains_key(APP_PURL), + "in-scope installed entry must survive; got {m}" + ); + assert!( + !patches.contains_key("pkg:npm/gone@9.9.9"), + "the genuinely-uninstalled orphan must still be pruned \ + (proves the prune pass actually ran); got {m}" + ); + assert!( + root_blob.exists(), + "the out-of-scope entry's blob must survive the sweep" + ); + assert!(app_blob.exists(), "the in-scope entry's blob must survive"); + assert!( + !orphan_blob.exists(), + "the orphan's blob must be swept with its entry" + ); + + // Envelope gc block agrees with the on-disk outcome. + let v = parse_envelope(&stdout); + assert_eq!( + v["gc"]["prunedManifestEntries"], + serde_json::json!(["pkg:npm/gone@9.9.9"]), + "gc must report exactly the orphan as pruned; got {v}" + ); + assert_eq!( + v["gc"]["removedBlobs"], 1, + "gc must report exactly the orphan's blob removed; got {v}" + ); + assert_eq!(v["paths"], serde_json::json!(["packages/app"])); +} + +// --------------------------------------------------------------------------- +// 3. A scope matching nothing is a normal empty scan. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn empty_match_is_empty_scan() { + // A package IS installed and the manifest holds a prunable orphan — + // but the scope matches nothing, so the run must hit the zero-package + // early return: exit 0, scannedPackages 0, NO gc key even with + // --prune, no API traffic, manifest + blob byte-untouched. + let server = MockServer::start().await; + mock_batch_empty(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package_at(tmp.path(), "", "root-dep", "1.0.0"); + + let orphan_hash = "d".repeat(64); + let orphan_blob = stage_blob(tmp.path(), &orphan_hash); + let manifest = serde_json::json!({ + "patches": { + "pkg:npm/gone@9.9.9": + manifest_entry("33333333-3333-4333-8333-333333333333", &orphan_hash), + } + }); + let manifest_bytes = serde_json::to_string_pretty(&manifest).unwrap(); + std::fs::write(tmp.path().join(".socket/manifest.json"), &manifest_bytes).unwrap(); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &server.uri(), + &["no/such/path", "--prune", "--yes"], + ); + assert_eq!( + code, 0, + "empty-match scoped scan must exit 0; stdout={stdout}; stderr={stderr}" + ); + + let v = parse_envelope(&stdout); + assert_eq!(v["status"], "success"); + assert_eq!(v["scannedPackages"], 0, "nothing is in scope; got {v}"); + assert_eq!(v["paths"], serde_json::json!(["no/such/path"])); + assert!( + v.get("gc").is_none(), + "the zero-package early return fires before any GC — no gc key \ + even with --prune; got {v}" + ); + + // No patch-API traffic at all: the early return fires before the + // batch loop. + let reqs = recorded(&server).await; + assert!( + batch_posts(&reqs).is_empty() && by_package_gets(&reqs) == 0, + "empty-match scan must not touch the API; saw {} batch POST(s), \ + {} by-package GET(s)", + batch_posts(&reqs).len(), + by_package_gets(&reqs), + ); + + // And no GC ran: the prunable orphan (entry + blob) is untouched. + assert_eq!( + std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(), + manifest_bytes, + "empty-match scan must leave the manifest byte-identical" + ); + assert!( + orphan_blob.exists(), + "empty-match scan must not sweep any blobs" + ); +} + +// --------------------------------------------------------------------------- +// 4. Lockfile-only supplements are excluded from a scoped scan (warned), +// but still included in an unscoped one. +// --------------------------------------------------------------------------- + +/// A v3 package-lock resolving `lock-only-dep` — which is never installed, +/// so it joins discovery only as a lockfile supplement (fabricated path). +fn write_npm_lock_with_lock_only_dep(root: &Path) { + let lock = serde_json::json!({ + "name": "scan-paths-root", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scan-paths-root", + "version": "0.0.0", + "dependencies": { "lock-only-dep": "^1.0.0" } + }, + "node_modules/lock-only-dep": { + "version": "1.0.0", + "resolved": + "https://registry.npmjs.org/lock-only-dep/-/lock-only-dep-1.0.0.tgz", + "integrity": "sha512-fake==", + "license": "MIT" + } + } + }); + let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); + bytes.push(b'\n'); + std::fs::write(root.join("package-lock.json"), bytes).unwrap(); +} + +#[tokio::test] +async fn supplements_excluded_with_warning() { + const LOCK_ONLY_PURL: &str = "pkg:npm/lock-only-dep@1.0.0"; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_lock_with_lock_only_dep(tmp.path()); + std::fs::create_dir_all(tmp.path().join("packages/app")).unwrap(); + std::fs::write( + tmp.path().join("packages/app/package.json"), + r#"{ "name": "app", "version": "0.0.0" }"#, + ) + .unwrap(); + write_npm_package_at(tmp.path(), "packages/app", "app-dep", "1.0.0"); + + // --- Scoped run: the supplement has no installed path → excluded, + // with the counted run-level warning; only the installed in-scope + // purl reaches the API. + let scoped_server = MockServer::start().await; + mock_batch_empty(&scoped_server).await; + let (code, stdout, stderr) = run_scan(tmp.path(), &scoped_server.uri(), &["packages/app"]); + assert_eq!( + code, 0, + "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" + ); + let v = parse_envelope(&stdout); + assert!( + warning_codes(&v).contains(&"path_scope_excluded_supplements".to_string()), + "scoped scan must warn that supplements were excluded; got {v}" + ); + assert_eq!( + v["scannedPackages"], 1, + "only the installed in-scope package counts; got {v}" + ); + // Pinning ACTUAL behavior: the `lockfileOnlyPackages` count reports the + // supplement inventory and is NOT narrowed by the path scope (the + // exclusion happens downstream, at the discovery filter). + assert_eq!(v["lockfileOnlyPackages"], 1, "got {v}"); + let reqs = recorded(&scoped_server).await; + let posts = batch_posts(&reqs); + assert_eq!(posts.len(), 1, "scoped scan must query the batch API once"); + let body = req_body(posts[0]); + assert!( + body.contains(APP_PURL), + "scoped batch body must carry the installed in-scope purl; body: {body}" + ); + assert!( + !body.contains("lock-only-dep"), + "scoped batch body must NOT carry the excluded supplement purl; body: {body}" + ); + + // --- Control (unscoped): the supplement joins discovery and the + // query; no path_scope warning fires. + let unscoped_server = MockServer::start().await; + mock_batch_empty(&unscoped_server).await; + let (code, stdout, stderr) = run_scan(tmp.path(), &unscoped_server.uri(), &[]); + assert_eq!( + code, 0, + "unscoped control scan must exit 0; stdout={stdout}; stderr={stderr}" + ); + let v = parse_envelope(&stdout); + assert!( + !warning_codes(&v).contains(&"path_scope_excluded_supplements".to_string()), + "unscoped scan must not emit the path-scope warning; got {v}" + ); + assert_eq!( + v["scannedPackages"], 2, + "unscoped scan counts installed + supplement; got {v}" + ); + assert_eq!(v["lockfileOnlyPackages"], 1, "got {v}"); + let reqs = recorded(&unscoped_server).await; + let posts = batch_posts(&reqs); + assert_eq!(posts.len(), 1); + let body = req_body(posts[0]); + assert!( + body.contains(APP_PURL) && body.contains(LOCK_ONLY_PURL), + "unscoped batch body must carry BOTH the installed purl and the \ + supplement purl; body: {body}" + ); +} + +// --------------------------------------------------------------------------- +// 5. Usage errors: PATHS with hosted/vendored modes, and invalid globs. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn paths_with_hosted_or_vendored_mode_exit_2() { + // All three refusals fire before any network I/O, so the unreachable + // API URL doubles as the no-network oracle (a connect attempt would + // surface as a different error, not the usage message). + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + + for mode in ["hosted", "vendored"] { + let (code, stdout, stderr) = run_scan( + tmp.path(), + "http://127.0.0.1:1", + &["packages/app", "--mode", mode], + ); + assert_eq!( + code, 2, + "PATHS + --mode {mode} must be a usage error (exit 2); \ + stdout={stdout}; stderr={stderr}" + ); + assert!( + stderr.contains("path targeting"), + "--mode {mode} refusal must name path targeting; stderr={stderr}" + ); + assert!( + stdout.trim().is_empty(), + "a usage error must not print a JSON envelope; stdout={stdout}" + ); + } + + // An unparseable glob is the same exit-2 usage-error shape. + let (code, stdout, stderr) = run_scan(tmp.path(), "http://127.0.0.1:1", &["x["]); + assert_eq!( + code, 2, + "an invalid glob must be a usage error (exit 2); stdout={stdout}; stderr={stderr}" + ); + assert!( + stderr.contains("invalid path pattern"), + "the error must name the invalid pattern; stderr={stderr}" + ); +} + +// --------------------------------------------------------------------------- +// 6. The `paths` echo key is always present (empty array when unscoped). +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn paths_echo_always_present() { + // ≥1-package envelope: unscoped scan of an installed package. + let server = MockServer::start().await; + mock_batch_empty(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package_at(tmp.path(), "", "root-dep", "1.0.0"); + + let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v = parse_envelope(&stdout); + assert_eq!( + v["scannedPackages"], 1, + "fixture must exercise the >=1-package envelope; got {v}" + ); + assert!( + v.as_object().unwrap().contains_key("paths"), + "the paths key must be present on every scan envelope; got {v}" + ); + assert_eq!( + v["paths"], + serde_json::json!([]), + "unscoped scan must echo an EMPTY paths array; got {v}" + ); + + // Zero-package envelope (empty project) carries the same empty echo. + let empty_server = MockServer::start().await; + mock_batch_empty(&empty_server).await; + let empty = tempfile::tempdir().unwrap(); + write_root_package_json(empty.path()); + let (code, stdout, stderr) = run_scan(empty.path(), &empty_server.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v = parse_envelope(&stdout); + assert_eq!(v["scannedPackages"], 0); + assert!( + v.as_object().unwrap().contains_key("paths"), + "the zero-package envelope must carry the paths key too; got {v}" + ); + assert_eq!(v["paths"], serde_json::json!([])); +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 65d98684..54d7497e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -25,8 +25,10 @@ use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; pub mod golang_local; +mod replay; mod state; mod takeover; +pub use replay::{revert_remaining_redirect_edits, GroupRefusal, ReplayOutcome}; pub use state::{ drop_superseded_purl, load_redirect_state, persist_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs new file mode 100644 index 00000000..8aa0a3b6 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/replay.rs @@ -0,0 +1,1378 @@ +//! Whole-ledger reverse replay of hosted-redirect edits. +//! +//! The per-purl reverts in [`super::takeover`] cover cargo and the +//! npm-family lock flavors. Everything else the hosted rewriters touch — +//! gem, golang, pypi, composer, bun, and the non-package rideshare edits +//! (the pnpm `trustLockfile` auto-config, the bun.lockb migration marker) — +//! has no per-purl revert: their unwind rides the ledger's designed +//! whole-list contract ("edits appended in write order, a revert walks +//! them in reverse", see [`super::state`]). +//! +//! [`revert_remaining_redirect_edits`] performs that walk over whatever +//! edits are still in the ledger (callers run the per-purl reverts first; +//! those drop the edits they claim). Each edit kind maps to an inverse in +//! a closed per-kind table; edits are grouped by the ecosystem that wrote +//! them and each GROUP is staged all-or-nothing — one drifted or +//! unhandled edit refuses the whole group byte-untouched (the same +//! fail-closed posture as the per-purl reverts), while other groups still +//! proceed. maven and nuget record structured metadata (not file +//! fragments), so their groups refuse with `hosted_revert_unsupported` +//! until bespoke reverts exist; their records and edits stay in the +//! ledger for a later `scan --mode hosted` normalize. +//! +//! Ledger accounting is per-outcome: successfully replayed (or +//! already-at-original) edits are dropped from `state.edits`; a record is +//! dropped only when every group its ecosystem writes ended clean, so a +//! refused group keeps both its edits and its records — the +//! intermediate-but-coherent ledger a retry needs. The caller persists. + +use super::state::RedirectState; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// The exact pnpm-workspace.yaml the trust auto-config CREATES when no +/// workspace file existed (see `plan_workspace_trust` in the hosted flow). +/// A `created` trust edit deletes the file only while it still carries +/// exactly this scaffold — anything else means the user built on it, and +/// the revert downgrades to removing the one line it owns. +const PNPM_TRUST_SCAFFOLD: &str = "packages:\n - '.'\ntrustLockfile: true\n"; + +/// The single line the trust auto-config APPENDS to an existing +/// pnpm-workspace.yaml (`action: "added"`); its `new` records the VALUE +/// (`"true"`), not the line, so the inverse is kind-specific. +const PNPM_TRUST_LINE: &str = "trustLockfile: true"; + +/// How one edit kind unwinds. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Inverse { + /// `original` and `new` are both file fragments (action `rewritten` / + /// `updated`): restore by replacing `new` with `original` once. + /// `contains(new)` is checked BEFORE `contains(original)` — several + /// writers record an `original` that is a substring of `new` (the + /// Cargo.toml insert variant, the maven version suffix). + ReplaceFragment, + /// action `added` with only `new` recorded: the redirect inserted the + /// fragment into a pre-existing file, so the inverse removes it once + /// (an absent fragment is the desired end state — no-op). + RemoveAddedFragment, + /// action `removed` with only `original` recorded: the redirect + /// pruned lines the pristine file needs back (go.sum entries of the + /// upstream module). Re-insert by appending — go.sum lines are + /// order-insensitive. + ReinsertRemoved, + /// Cleanup of PRIOR socket wiring performed during a redirect refresh + /// (`redirect_golang_stale_*`). The removal already moved the file + /// toward pristine; restoring it would re-create socket wiring, so + /// the inverse is a no-op and the edit is simply dropped. + NoopDrop, + /// The pnpm `trustLockfile` auto-config (kind-specific: `created` + /// deletes the scaffold, `added` removes exactly one line). + PnpmTrust, + /// bun.lockb was migrated to a text bun.lock; the binary original was + /// never captured (git history is the restore path). Warn and drop. + BunLockbMigrated, + /// Owned by a per-purl revert (npm JSON kinds). Present here only + /// when that revert failed — refuse the group rather than guess. + PerPurlOnly, + /// No revert implementation exists for the recorded shape (maven / + /// nuget structured metadata, unknown future kinds). + Unsupported, +} + +/// (group label, inverse) for one recorded edit. The group is the +/// all-or-nothing staging unit — every kind an ecosystem writes lands in +/// one group so correlated files (go.mod + go.sum, Gemfile + +/// Gemfile.lock) revert together or not at all. +fn classify(kind: &str, action: &str) -> (&'static str, Inverse) { + match kind { + "redirect_requirements_line" | "redirect_uv_lock_wheel" => ("pypi", Inverse::ReplaceFragment), + "redirect_composer_dist" => ("composer", Inverse::ReplaceFragment), + "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => ("cargo", Inverse::ReplaceFragment), + "redirect_cargo_registry" => ( + "cargo", + if action == "added" { + Inverse::RemoveAddedFragment + } else { + Inverse::ReplaceFragment + }, + ), + "redirect_pnpm_resolution" => ("pnpm", Inverse::ReplaceFragment), + "redirect_pnpm_workspace_trust" => ("pnpm", Inverse::PnpmTrust), + "redirect_yarn_classic_entry" | "redirect_yarn_berry_entry" => { + ("yarn", Inverse::ReplaceFragment) + } + "redirect_bun_lock_package" => ("bun", Inverse::ReplaceFragment), + "redirect_bun_lockb_migrated" => ("bun", Inverse::BunLockbMigrated), + "redirect_gemfile_lock_dependency_pin" + | "redirect_gemfile_lock_checksum" + | "redirect_gemfile_source_block" => ( + "gem", + if action == "added" { + Inverse::RemoveAddedFragment + } else { + Inverse::ReplaceFragment + }, + ), + "redirect_gemfile_lock_source_url" | "redirect_gemfile_source_url" => { + ("gem", Inverse::ReplaceFragment) + } + // The section-move record: the writer drained the spec (+ sublines) + // out of its upstream GEM section into a new socket GEM section but + // recorded only the bare remote URLs — not the moved block — so a + // URL swap would claim success while leaving the moved spec and the + // scaffold section in place. Refuse until the writer records enough + // to invert the move. + "redirect_gemfile_lock_gem_source" => ("gem", Inverse::Unsupported), + // "updated" carries the prior socket directive in `original`; + // the chain unwinds newest-first down to the first run's "added". + "redirect_golang_replace" => ( + "golang", + if action == "added" { + Inverse::RemoveAddedFragment + } else { + Inverse::ReplaceFragment + }, + ), + "redirect_golang_gosum" => ("golang", Inverse::RemoveAddedFragment), + "redirect_golang_gosum_prune" => ("golang", Inverse::ReinsertRemoved), + "redirect_golang_stale_replace_removed" | "redirect_golang_stale_gosum_removed" => { + ("golang", Inverse::NoopDrop) + } + "redirect_npm_lock_entry" | "redirect_npm_lock_dep" => ("npm", Inverse::PerPurlOnly), + "redirect_maven_repository" + | "redirect_maven_dep_management" + | "redirect_maven_config" + | "redirect_maven_trusted_checksums" => ("maven", Inverse::Unsupported), + "redirect_maven_dep_version" => ("maven", Inverse::ReplaceFragment), + "redirect_nuget_source" | "redirect_nuget_lock" => ("nuget", Inverse::Unsupported), + _ => ("unknown", Inverse::Unsupported), + } +} + +/// The replay groups a record's ecosystem can have written edits into — +/// the drop rule holds a record while ANY of its groups refused. npm +/// purls fan across every npm-family lock flavor. +fn groups_for_record_purl(purl: &str) -> &'static [&'static str] { + if purl.starts_with("pkg:npm/") { + &["npm", "yarn", "pnpm", "bun"] + } else if purl.starts_with("pkg:cargo/") { + &["cargo"] + } else if purl.starts_with("pkg:gem/") { + &["gem"] + } else if purl.starts_with("pkg:pypi/") { + &["pypi"] + } else if purl.starts_with("pkg:composer/") { + &["composer"] + } else if purl.starts_with("pkg:golang/") { + &["golang"] + } else if purl.starts_with("pkg:maven/") { + &["maven"] + } else if purl.starts_with("pkg:nuget/") { + &["nuget"] + } else { + // Unknown ecosystems fail closed: tie them to the reserved + // "unknown" group, which refuses whenever it holds edits. + &["unknown"] + } +} + +/// One refused group: its files were left byte-identical and its edits +/// and records stay in the ledger. +#[derive(Debug)] +pub struct GroupRefusal { + pub group: String, + pub files: BTreeSet, + pub reason: String, +} + +/// What one replay pass did (or, on dry-run, would do). +#[derive(Debug, Default)] +pub struct ReplayOutcome { + /// Files whose staged revert flushed (repo-relative), including files + /// staged for deletion. + pub reverted_files: BTreeSet, + /// Groups that refused fail-closed; their edits/records remain. + pub refusals: Vec, + /// Advisory (code, detail) pairs — unrestorable bun.lockb, modified + /// trust scaffold, and similar honest degradations. + pub warnings: Vec<(String, String)>, + /// Records dropped from the ledger (purls, sorted by BTreeMap walk). + pub dropped_records: Vec, + /// Edits dropped from the ledger. + pub dropped_edits: usize, +} + +impl ReplayOutcome { + /// True when every group replayed clean (a refusal-free pass). + pub fn fully_reverted(&self) -> bool { + self.refusals.is_empty() + } +} + +/// Ledger paths are written by this tool as plain repo-relative slash +/// paths; anything else (absolute, `..`, empty) refuses fail-closed +/// rather than letting a tampered ledger write outside the project. +fn safe_rel_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.starts_with('\\') + && !path.contains(':') + && !path.split(['/', '\\']).any(|c| c == "..") +} + +/// FIFO-guarded read: a planted FIFO squatting a lockfile path must fail +/// fast (`InvalidInput`) instead of wedging the replay on a blocking open +/// — the same posture as every other raw read in the patch engine. +async fn read_rel(project_root: &Path, rel: &str) -> Result, String> { + use tokio::io::AsyncReadExt; + let path = project_root.join(rel); + let (mut file, _) = match crate::utils::fs::open_regular_file(&path).await { + Ok(pair) => pair, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("read {rel}: {e}")), + }; + let mut content = String::new(); + file.read_to_string(&mut content) + .await + .map_err(|e| format!("read {rel}: {e}"))?; + Ok(Some(content)) +} + +/// Files the group's unwind has decided but not yet written: +/// `Some(content)` to write, `None` to delete. +type Staged = BTreeMap>; + +async fn staged_read( + staged: &Staged, + project_root: &Path, + rel: &str, +) -> Result, String> { + match staged.get(rel) { + Some(pending) => Ok(pending.clone()), + None => read_rel(project_root, rel).await, + } +} + +/// Remove one inserted fragment, eating the separators the writer added +/// around it. Position-based: several writers record the fragment WITHOUT +/// the indentation they inserted it with (the gem DEPENDENCIES pin and +/// CHECKSUMS line record `target.trim_start()`), so when everything +/// between the fragment and its line start is whitespace the whole line +/// is removed — a bare `replacen` would strand the orphaned indent onto +/// the NEXT line and corrupt indentation-sensitive locks. An EOF-removed +/// fragment additionally collapses the trailing blank run to the +/// canonical single newline: the append shape (maybe-a-blank-separator + +/// fragment + newline) is byte-AMBIGUOUS to invert — `"m\n\n" + "F\n"` +/// and `"m\n" + "\nF\n"` produce identical files — so the tidy form (the +/// one `go mod tidy` itself emits) is chosen. +fn remove_fragment_once(content: &str, fragment: &str) -> String { + let Some(pos) = content.find(fragment) else { + return content.to_string(); + }; + let mut end = pos + fragment.len(); + // The fragment's own indentation, when the writer recorded it stripped. + let line_start = content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0); + let start = if content[line_start..pos] + .chars() + .all(|c| c == ' ' || c == '\t') + { + line_start + } else { + pos + }; + // The removed line's own newline goes with it. + if content[end..].starts_with('\n') { + end += 1; + } + if end >= content.len() { + // EOF removal: collapse the (ambiguous) trailing separator run. + let trimmed = content[..start].trim_end_matches('\n'); + if trimmed.is_empty() { + return String::new(); + } + return format!("{trimmed}\n"); + } + format!("{}{}", &content[..start], &content[end..]) +} + +/// The string payloads of an edit, or `None` when a payload is missing or +/// not a string (a shape the inverse table said must be there). +fn str_payload(v: &Option) -> Option<&str> { + v.as_ref().and_then(Value::as_str) +} + +/// Walk every edit still in `state` in reverse write order, grouped per +/// ecosystem, staging each group's inverse and flushing it all-or-nothing. +/// Mutates `state` (drops replayed edits and fully-unwound records) — +/// the CALLER persists via `persist_redirect_state`. With `dry_run` the +/// staging and every drift check run identically, but nothing is written +/// and `state` is left untouched; the outcome reports what a wet run +/// would do. +pub async fn revert_remaining_redirect_edits( + project_root: &Path, + state: &mut RedirectState, + dry_run: bool, +) -> ReplayOutcome { + let mut outcome = ReplayOutcome::default(); + + // Group edit indices by ecosystem, keeping ledger order within each. + let mut groups: BTreeMap<&'static str, Vec> = BTreeMap::new(); + for (idx, edit) in state.edits.iter().enumerate() { + let (group, _) = classify(&edit.kind, &edit.action); + groups.entry(group).or_default().push(idx); + } + + let mut drop_indices: BTreeSet = BTreeSet::new(); + let mut refused_groups: BTreeSet<&'static str> = BTreeSet::new(); + let mut pending_warnings: Vec<(String, String)> = Vec::new(); + + 'group: for (group, indices) in &groups { + let mut staged: Staged = BTreeMap::new(); + let mut group_drops: BTreeSet = BTreeSet::new(); + let mut group_warnings: Vec<(String, String)> = Vec::new(); + let files: BTreeSet = indices + .iter() + .map(|&i| state.edits[i].path.clone()) + .collect(); + + let refuse = |reason: String, out: &mut ReplayOutcome| { + out.refusals.push(GroupRefusal { + group: (*group).to_string(), + files: files.clone(), + reason, + }); + }; + + // Newest-first: chained re-redirects unwind through each step's + // `new` -> `original` until the first run's insertion is removed. + for &idx in indices.iter().rev() { + let edit = state.edits[idx].clone(); + let (_, inverse) = classify(&edit.kind, &edit.action); + if !matches!( + inverse, + Inverse::NoopDrop | Inverse::BunLockbMigrated | Inverse::Unsupported + ) && !safe_rel_path(&edit.path) + { + refuse( + format!("ledger edit for {} has an unsafe path", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + match inverse { + Inverse::NoopDrop => { + // Removal of prior socket wiring — already pristine-ward. + group_drops.insert(idx); + } + Inverse::BunLockbMigrated => { + group_warnings.push(( + "redirect_bun_lockb_unrestorable".into(), + "bun.lockb was migrated to a text bun.lock during the redirect and \ + its binary content was not captured — restore bun.lockb from git \ + history if the binary format is required" + .into(), + )); + group_drops.insert(idx); + } + Inverse::PerPurlOnly => { + refuse( + format!( + "{} is owned by the per-purl npm revert, which did not claim it \ + (a prior per-purl refusal) — re-run `scan --mode hosted` to \ + normalize, then roll back again", + edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + Inverse::Unsupported => { + refuse( + format!( + "no hosted-redirect revert implementation for {} — re-run \ + `scan --mode hosted` to normalize, or restore the file from \ + version control", + edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + Inverse::ReplaceFragment => { + let (Some(original), Some(new)) = + (str_payload(&edit.original), str_payload(&edit.new)) + else { + refuse( + format!("{} edit is missing its recorded fragments", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + }; + let content = match staged_read(&staged, project_root, &edit.path).await { + Ok(Some(c)) => c, + Ok(None) => { + refuse( + format!("{} no longer exists", edit.path), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + }; + // `new` before `original`: original may be a substring + // of new (Cargo.toml insert, maven version suffix). + if content.contains(new) { + if content.matches(new).count() > 1 { + refuse( + format!( + "{}: the redirected fragment appears more than once — \ + ambiguous, refusing to guess", + edit.path + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + staged.insert( + edit.path.clone(), + Some(content.replacen(new, original, 1)), + ); + group_drops.insert(idx); + } else if content.contains(original) && !new.contains(original) { + // Already at the pre-edit state (an interrupted + // earlier revert, or a hand-fix) — nothing to do. + // The `!new.contains(original)` guard matters: + // several writers record an `original` that is a + // SUBSTRING of `new` (the Cargo.toml insert variant + // records the always-present table header), so its + // presence proves nothing about the inserted part — + // a drifted insert must refuse, not silently drop + // the edit as reverted. + group_drops.insert(idx); + } else { + refuse( + format!( + "{}: content matches neither the redirected nor the \ + original fragment for {} — the file drifted; re-run \ + `scan --mode hosted` to normalize", + edit.path, edit.kind + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + } + Inverse::RemoveAddedFragment => { + let Some(new) = str_payload(&edit.new) else { + refuse( + format!("{} edit is missing its recorded fragment", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + }; + match staged_read(&staged, project_root, &edit.path).await { + // File gone entirely: the fragment is gone with it. + Ok(None) => { + group_drops.insert(idx); + } + Ok(Some(content)) => { + if content.contains(new) { + if content.matches(new).count() > 1 { + refuse( + format!( + "{}: the added fragment appears more than once — \ + ambiguous, refusing to guess", + edit.path + ), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + } + staged.insert( + edit.path.clone(), + Some(remove_fragment_once(&content, new)), + ); + } + // Absent fragment == already clean. + group_drops.insert(idx); + } + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + } + Inverse::ReinsertRemoved => { + let Some(original) = str_payload(&edit.original) else { + refuse( + format!("{} edit is missing its recorded lines", edit.kind), + &mut outcome, + ); + refused_groups.insert(group); + continue 'group; + }; + let content = match staged_read(&staged, project_root, &edit.path).await { + Ok(c) => c.unwrap_or_default(), + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + }; + if content.contains(original) { + group_drops.insert(idx); + } else { + let mut restored = content; + if !restored.is_empty() && !restored.ends_with('\n') { + restored.push('\n'); + } + restored.push_str(original); + restored.push('\n'); + staged.insert(edit.path.clone(), Some(restored)); + group_drops.insert(idx); + } + } + Inverse::PnpmTrust => { + let content = match staged_read(&staged, project_root, &edit.path).await { + Ok(c) => c, + Err(e) => { + refuse(e, &mut outcome); + refused_groups.insert(group); + continue 'group; + } + }; + match (edit.action.as_str(), content) { + // Whatever created it is already gone. + (_, None) => { + group_drops.insert(idx); + } + ("created", Some(c)) if c == PNPM_TRUST_SCAFFOLD => { + staged.insert(edit.path.clone(), None); + group_drops.insert(idx); + } + // Scaffold grew user content — keep the file, drop + // only the line the redirect owns, and say so. + (_, Some(c)) => { + if c.contains(PNPM_TRUST_LINE) { + staged.insert( + edit.path.clone(), + Some(remove_fragment_once(&c, PNPM_TRUST_LINE)), + ); + if edit.action == "created" { + group_warnings.push(( + "redirect_pnpm_trust_scaffold_modified".into(), + format!( + "{} was created by the hosted redirect but has \ + been modified since — kept the file and removed \ + only the `trustLockfile: true` line", + edit.path + ), + )); + } + } + group_drops.insert(idx); + } + } + } + } + } + + // Commit the group: flush staged files (unless dry-run), then mark + // its edits for dropping. A flush error refuses the group late — + // some files may already have landed (the same residual exposure + // the per-purl reverts document) — and keeps its ledger entries. + if !dry_run { + for (rel, pending) in &staged { + let path = project_root.join(rel); + // FIFO/device guard on the write side too: writing to a + // planted FIFO blocks forever. Refuse the group instead. + if let Ok(meta) = tokio::fs::symlink_metadata(&path).await { + if !meta.is_file() { + refuse(format!("{rel} is not a regular file"), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + let write_result = match pending { + Some(content) => tokio::fs::write(&path, content).await, + None => match tokio::fs::remove_file(&path).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + other => other, + }, + }; + if let Err(e) = write_result { + refuse(format!("write {rel}: {e}"), &mut outcome); + refused_groups.insert(group); + continue 'group; + } + } + } + outcome + .reverted_files + .extend(staged.keys().cloned()); + pending_warnings.extend(group_warnings); + drop_indices.extend(group_drops); + } + + outcome.warnings.append(&mut pending_warnings); + + if !dry_run { + // Drop replayed edits (reverse index order keeps indices valid). + for &idx in drop_indices.iter().rev() { + state.edits.remove(idx); + outcome.dropped_edits += 1; + } + // Drop each record whose every possible group ended clean. + let record_purls: Vec = state.records.keys().cloned().collect(); + for purl in record_purls { + let held = groups_for_record_purl(&purl) + .iter() + .any(|g| refused_groups.contains(g)); + if !held { + state.records.remove(&purl); + outcome.dropped_records.push(purl); + } + } + } else { + outcome.dropped_edits = drop_indices.len(); + for purl in state.records.keys() { + let held = groups_for_record_purl(purl) + .iter() + .any(|g| refused_groups.contains(g)); + if !held { + outcome.dropped_records.push(purl.clone()); + } + } + } + + outcome +} + +#[cfg(test)] +mod tests { + use super::super::FileEdit; + use super::*; + use serde_json::json; + use tempfile::TempDir; + + fn edit(path: &str, kind: &str, action: &str, original: Option<&str>, new: Option<&str>) -> FileEdit { + FileEdit { + path: path.into(), + kind: kind.into(), + action: action.into(), + key: Some("k".into()), + original: original.map(|s| Value::String(s.into())), + new: new.map(|s| Value::String(s.into())), + } + } + + fn state_with(edits: Vec, record_purls: &[&str]) -> RedirectState { + let mut state = RedirectState::new(); + state.edits = edits; + for p in record_purls { + state + .records + .insert((*p).to_string(), crate::manifest::schema::PatchRecord { + uuid: "u".into(), + exported_at: "now".into(), + files: Default::default(), + vulnerabilities: Default::default(), + description: String::new(), + license: String::new(), + tier: "free".into(), + }); + } + state + } + + async fn write(root: &Path, rel: &str, content: &str) { + let p = root.join(rel); + if let Some(parent) = p.parent() { + tokio::fs::create_dir_all(parent).await.unwrap(); + } + tokio::fs::write(p, content).await.unwrap(); + } + + async fn read(root: &Path, rel: &str) -> String { + tokio::fs::read_to_string(root.join(rel)).await.unwrap() + } + + // ---------- ReplaceFragment ---------- + + #[tokio::test] + async fn rewritten_fragment_replays_to_original() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "requirements.txt", "left-pad @ https://patch.example/x.whl\n").await; + let mut state = state_with( + vec![edit( + "requirements.txt", + "redirect_requirements_line", + "rewritten", + Some("left-pad==1.3.0"), + Some("left-pad @ https://patch.example/x.whl"), + )], + &["pkg:pypi/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!(read(dir.path(), "requirements.txt").await, "left-pad==1.3.0\n"); + assert!(state.edits.is_empty()); + assert!(state.records.is_empty()); + assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]); + } + + #[tokio::test] + async fn substring_original_checks_new_first() { + // The maven version-suffix shape: original is a substring of new. + let dir = TempDir::new().unwrap(); + write(dir.path(), "pom.xml", "2.17.1-socket-abc\n").await; + let mut state = state_with( + vec![edit( + "pom.xml", + "redirect_maven_dep_version", + "rewritten", + Some("2.17.1"), + Some("2.17.1-socket-abc"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!(read(dir.path(), "pom.xml").await, "2.17.1\n"); + } + + #[tokio::test] + async fn drifted_fragment_refuses_the_whole_group_untouched() { + let dir = TempDir::new().unwrap(); + // go.mod drifted; go.sum is revertable — but the golang group is + // all-or-nothing, so BOTH files stay byte-identical. + write(dir.path(), "go.mod", "module m\n").await; + write(dir.path(), "go.sum", "gopatch.socket.dev/x v1 h1:a\n").await; + let mut state = state_with( + vec![ + edit( + "go.mod", + "redirect_golang_replace", + "added", + None, + Some("replace x => gopatch.socket.dev/x v1"), + ), + edit( + "go.mod", + "redirect_golang_replace", + "updated", + Some("replace x => gopatch.socket.dev/x v0"), + Some("replace x => WHAT-THE-FILE-NO-LONGER-HAS"), + ), + edit( + "go.sum", + "redirect_golang_gosum", + "added", + None, + Some("gopatch.socket.dev/x v1 h1:a"), + ), + ], + &["pkg:golang/x@1"], + ); + let before_mod = read(dir.path(), "go.mod").await; + let before_sum = read(dir.path(), "go.sum").await; + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert_eq!(out.refusals[0].group, "golang"); + assert_eq!(read(dir.path(), "go.mod").await, before_mod); + assert_eq!(read(dir.path(), "go.sum").await, before_sum); + assert_eq!(state.edits.len(), 3, "refused group keeps its edits"); + assert!( + state.records.contains_key("pkg:golang/x@1"), + "refused group keeps its records" + ); + } + + #[tokio::test] + async fn ambiguous_duplicate_fragment_refuses() { + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "composer.lock", + "https://patch.example/a\nhttps://patch.example/a\n", + ) + .await; + let mut state = state_with( + vec![edit( + "composer.lock", + "redirect_composer_dist", + "rewritten", + Some("https://upstream.example/a"), + Some("https://patch.example/a"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert!(out.refusals[0].reason.contains("more than once")); + } + + #[tokio::test] + async fn already_original_content_is_a_noop_drop() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "composer.lock", "https://upstream.example/a\n").await; + let mut state = state_with( + vec![edit( + "composer.lock", + "redirect_composer_dist", + "rewritten", + Some("https://upstream.example/a"), + Some("https://patch.example/a"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert!(state.edits.is_empty()); + assert!(out.reverted_files.is_empty(), "nothing was written"); + } + + // ---------- chained re-redirects ---------- + + #[tokio::test] + async fn chained_reredirect_unwinds_newest_first_to_pristine() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "go.mod", "module m\n\nreplace x => gopatch.socket.dev/x v2\n").await; + let mut state = state_with( + vec![ + edit( + "go.mod", + "redirect_golang_replace", + "added", + None, + Some("replace x => gopatch.socket.dev/x v1"), + ), + edit( + "go.mod", + "redirect_golang_replace", + "updated", + Some("replace x => gopatch.socket.dev/x v1"), + Some("replace x => gopatch.socket.dev/x v2"), + ), + ], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!(read(dir.path(), "go.mod").await, "module m\n"); + } + + // ---------- RemoveAddedFragment / ReinsertRemoved ---------- + + #[tokio::test] + async fn golang_round_trip_removes_added_and_reinserts_pruned() { + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "go.mod", + "module m\n\nreplace x => gopatch.socket.dev/x v1\n", + ) + .await; + write( + dir.path(), + "go.sum", + "gopatch.socket.dev/x v1 h1:a\ngopatch.socket.dev/x v1/go.mod h1:b\n", + ) + .await; + let mut state = state_with( + vec![ + edit( + "go.mod", + "redirect_golang_replace", + "added", + None, + Some("replace x => gopatch.socket.dev/x v1"), + ), + edit( + "go.sum", + "redirect_golang_gosum", + "added", + None, + Some("gopatch.socket.dev/x v1 h1:a\ngopatch.socket.dev/x v1/go.mod h1:b"), + ), + edit( + "go.sum", + "redirect_golang_gosum_prune", + "removed", + Some("x v0.9 h1:orig\nx v0.9/go.mod h1:origmod"), + None, + ), + edit( + "go.mod", + "redirect_golang_stale_replace_removed", + "removed", + Some("replace x => gopatch.socket.dev/x v0"), + None, + ), + ], + &["pkg:golang/x@0.9"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + // The added replace line is gone (its blank separator too — the + // fragment+newline heuristic), and NOT the stale socket directive. + let go_mod = read(dir.path(), "go.mod").await; + assert!(!go_mod.contains("gopatch.socket.dev"), "{go_mod}"); + // Pruned upstream sums are back; the fork's sums are gone. + let go_sum = read(dir.path(), "go.sum").await; + assert!(go_sum.contains("x v0.9 h1:orig")); + assert!(!go_sum.contains("gopatch.socket.dev")); + assert!(state.edits.is_empty()); + assert!(state.records.is_empty()); + } + + #[tokio::test] + async fn reinsert_is_idempotent_when_lines_are_already_back() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "go.sum", "x v0.9 h1:orig\n").await; + let mut state = state_with( + vec![edit( + "go.sum", + "redirect_golang_gosum_prune", + "removed", + Some("x v0.9 h1:orig"), + None, + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert_eq!(read(dir.path(), "go.sum").await, "x v0.9 h1:orig\n"); + } + + #[tokio::test] + async fn gem_added_pin_removal_preserves_sibling_indentation() { + // The gem writer records the DEPENDENCIES pin / CHECKSUMS line + // STRIPPED of its two-space indent; removal must take the whole + // line, never strand the indent onto the next line (which bundler + // then misparses). + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "Gemfile.lock", + "DEPENDENCIES\n rack\n rex (= 1.0.0)!\n rspec\n", + ) + .await; + let mut state = state_with( + vec![edit( + "Gemfile.lock", + "redirect_gemfile_lock_dependency_pin", + "added", + None, + Some("rex (= 1.0.0)!"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "Gemfile.lock").await, + "DEPENDENCIES\n rack\n rspec\n", + "sibling lines keep their exact indentation" + ); + } + + #[tokio::test] + async fn anchor_shaped_original_never_reads_as_already_reverted() { + // The Cargo.toml insert variant records the always-present table + // header as `original` and header+insert as `new`. With the insert + // drifted, contains(original) is vacuously true — the edit must + // REFUSE, not silently drop as already-reverted. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "Cargo.toml", + "[dependencies.cfg-if]\nregistry = \"socket-patch-u\"\n", + ) + .await; + let mut state = state_with( + vec![edit( + "Cargo.toml", + "redirect_cargo_toml_dep", + "rewritten", + Some("[dependencies.cfg-if]"), + Some("[dependencies.cfg-if]\nregistry = \"socket-patch-u\""), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "{out:?}"); + assert!(out.refusals[0].reason.contains("drifted")); + assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); + } + + #[tokio::test] + async fn gem_section_move_record_fails_closed() { + // redirect_gemfile_lock_gem_source records only the bare URLs of a + // SECTION MOVE — not enough to invert it. Must refuse, never swap + // the URL and claim success. + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "Gemfile.lock", + "GEM\n remote: https://patch.example/\n specs:\n rex (1.0.0)\n", + ) + .await; + let mut state = state_with( + vec![edit( + "Gemfile.lock", + "redirect_gemfile_lock_gem_source", + "rewritten", + Some("https://rubygems.org/"), + Some("https://patch.example/"), + )], + &["pkg:gem/rex@1.0.0"], + ); + let before = read(dir.path(), "Gemfile.lock").await; + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert!(out.refusals[0] + .reason + .contains("no hosted-redirect revert implementation")); + assert_eq!(read(dir.path(), "Gemfile.lock").await, before); + assert!(state.records.contains_key("pkg:gem/rex@1.0.0")); + } + + #[tokio::test] + async fn gem_added_fragments_are_removed() { + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "Gemfile", + "source 'https://rubygems.org'\nsource 'https://patch.example' do\n gem 'rex'\nend\n", + ) + .await; + let mut state = state_with( + vec![edit( + "Gemfile", + "redirect_gemfile_source_block", + "added", + None, + Some("source 'https://patch.example' do\n gem 'rex'\nend"), + )], + &["pkg:gem/rex@1.0.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert_eq!( + read(dir.path(), "Gemfile").await, + "source 'https://rubygems.org'\n" + ); + assert!(state.records.is_empty()); + } + + // ---------- unsupported / per-purl-only ---------- + + #[tokio::test] + async fn maven_structured_edits_refuse_and_keep_the_record() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "pom.xml", "\n").await; + let mut state = state_with( + vec![FileEdit { + path: "pom.xml".into(), + kind: "redirect_maven_repository".into(), + action: "added".into(), + key: Some("socket-patch".into()), + original: None, + new: Some(json!({ "id": "socket-patch", "url": "https://patch.example" })), + }], + &["pkg:maven/g/a@1"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert!(out.refusals[0] + .reason + .contains("no hosted-redirect revert implementation")); + assert_eq!(state.edits.len(), 1); + assert!(state.records.contains_key("pkg:maven/g/a@1")); + } + + #[tokio::test] + async fn unknown_kind_fails_closed() { + let dir = TempDir::new().unwrap(); + let mut state = state_with( + vec![edit("f", "redirect_future_thing", "rewritten", Some("a"), Some("b"))], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert_eq!(out.refusals[0].group, "unknown"); + assert_eq!(state.edits.len(), 1); + } + + #[tokio::test] + async fn leftover_npm_json_edit_refuses_and_holds_every_npm_family_record() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "package-lock.json", "{}\n").await; + write(dir.path(), "bun.lock", "\"pkg\": [\"https://patch.example/t.tgz\"]\n").await; + let mut state = state_with( + vec![ + FileEdit { + path: "package-lock.json".into(), + kind: "redirect_npm_lock_entry".into(), + action: "rewritten".into(), + key: Some("node_modules/a".into()), + original: Some(json!({ "resolved": "u", "integrity": "i" })), + new: Some(json!({ "resolved": "p", "integrity": "j" })), + }, + edit( + "bun.lock", + "redirect_bun_lock_package", + "rewritten", + Some("\"pkg\": [\"https://upstream.example/t.tgz\"]"), + Some("\"pkg\": [\"https://patch.example/t.tgz\"]"), + ), + ], + &["pkg:npm/a@1"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + // The npm group refused; the bun group replayed. + assert_eq!(out.refusals.len(), 1); + assert_eq!(out.refusals[0].group, "npm"); + assert!(read(dir.path(), "bun.lock").await.contains("upstream.example")); + // npm-family records are held while ANY npm-family group refused. + assert!(state.records.contains_key("pkg:npm/a@1")); + assert_eq!(state.edits.len(), 1, "only the refused npm edit remains"); + } + + // ---------- pnpm trust ---------- + + #[tokio::test] + async fn trust_scaffold_is_deleted_when_unmodified() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "pnpm-workspace.yaml", PNPM_TRUST_SCAFFOLD).await; + let mut state = state_with( + vec![FileEdit { + path: "pnpm-workspace.yaml".into(), + kind: "redirect_pnpm_workspace_trust".into(), + action: "created".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(json!("true")), + }], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted(), "{:?}", out.refusals); + assert!(!dir.path().join("pnpm-workspace.yaml").exists()); + } + + #[tokio::test] + async fn modified_trust_scaffold_keeps_the_file_and_drops_the_line() { + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "pnpm-workspace.yaml", + "packages:\n - '.'\n - 'packages/*'\ntrustLockfile: true\n", + ) + .await; + let mut state = state_with( + vec![FileEdit { + path: "pnpm-workspace.yaml".into(), + kind: "redirect_pnpm_workspace_trust".into(), + action: "created".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(json!("true")), + }], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert_eq!( + read(dir.path(), "pnpm-workspace.yaml").await, + "packages:\n - '.'\n - 'packages/*'\n" + ); + assert!(out + .warnings + .iter() + .any(|(code, _)| code == "redirect_pnpm_trust_scaffold_modified")); + } + + #[tokio::test] + async fn appended_trust_line_is_removed_exactly() { + let dir = TempDir::new().unwrap(); + write( + dir.path(), + "pnpm-workspace.yaml", + "packages:\n - 'apps/*'\ntrustLockfile: true\n", + ) + .await; + let mut state = state_with( + vec![FileEdit { + path: "pnpm-workspace.yaml".into(), + kind: "redirect_pnpm_workspace_trust".into(), + action: "added".into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(json!("true")), + }], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert_eq!( + read(dir.path(), "pnpm-workspace.yaml").await, + "packages:\n - 'apps/*'\n" + ); + } + + // ---------- dry-run ---------- + + #[tokio::test] + async fn dry_run_reports_without_touching_disk_or_ledger() { + let dir = TempDir::new().unwrap(); + write(dir.path(), "requirements.txt", "left-pad @ https://patch.example/x.whl\n").await; + let mut state = state_with( + vec![edit( + "requirements.txt", + "redirect_requirements_line", + "rewritten", + Some("left-pad==1.3.0"), + Some("left-pad @ https://patch.example/x.whl"), + )], + &["pkg:pypi/left-pad@1.3.0"], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, true).await; + assert!(out.fully_reverted()); + assert_eq!(out.dropped_edits, 1); + assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]); + assert!(out.reverted_files.contains("requirements.txt")); + // Disk and ledger untouched. + assert!(read(dir.path(), "requirements.txt").await.contains("patch.example")); + assert_eq!(state.edits.len(), 1); + assert_eq!(state.records.len(), 1); + } + + // ---------- safety ---------- + + #[tokio::test] + async fn unsafe_ledger_path_refuses() { + let dir = TempDir::new().unwrap(); + for bad in ["/etc/passwd", "../outside", "a/../../b", "c:\\windows\\x"] { + let mut state = state_with( + vec![edit(bad, "redirect_requirements_line", "rewritten", Some("a"), Some("b"))], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1, "path {bad:?} must refuse"); + assert!(out.refusals[0].reason.contains("unsafe path"), "{bad:?}"); + } + } + + #[tokio::test] + async fn missing_file_for_rewritten_edit_is_a_drift_refusal() { + let dir = TempDir::new().unwrap(); + let mut state = state_with( + vec![edit( + "composer.lock", + "redirect_composer_dist", + "rewritten", + Some("a"), + Some("b"), + )], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert_eq!(out.refusals.len(), 1); + assert!(out.refusals[0].reason.contains("no longer exists")); + } + + #[tokio::test] + async fn bun_lockb_migration_warns_and_drops() { + let dir = TempDir::new().unwrap(); + let mut state = state_with( + vec![FileEdit { + path: "bun.lockb".into(), + kind: "redirect_bun_lockb_migrated".into(), + action: "removed".into(), + key: None, + original: None, + new: None, + }], + &[], + ); + let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; + assert!(out.fully_reverted()); + assert!(out + .warnings + .iter() + .any(|(code, _)| code == "redirect_bun_lockb_unrestorable")); + assert!(state.edits.is_empty()); + } + + /// Every kind the hosted writers emit today must have a deliberate + /// classification — a new writer kind landing without a replay arm + /// falls to the "unknown" group, which fails closed at runtime; this + /// pin makes the gap loud at test time instead. + #[test] + fn every_known_writer_kind_is_classified() { + let known = [ + ("redirect_requirements_line", "rewritten"), + ("redirect_uv_lock_wheel", "rewritten"), + ("redirect_composer_dist", "rewritten"), + ("redirect_cargo_toml_dep", "rewritten"), + ("redirect_cargo_lock_entry", "rewritten"), + ("redirect_cargo_registry", "rewritten"), + ("redirect_cargo_registry", "added"), + ("redirect_pnpm_resolution", "rewritten"), + ("redirect_pnpm_workspace_trust", "created"), + ("redirect_pnpm_workspace_trust", "added"), + ("redirect_yarn_classic_entry", "rewritten"), + ("redirect_yarn_berry_entry", "rewritten"), + ("redirect_bun_lock_package", "rewritten"), + ("redirect_bun_lockb_migrated", "removed"), + ("redirect_gemfile_lock_dependency_pin", "rewritten"), + ("redirect_gemfile_lock_dependency_pin", "added"), + ("redirect_gemfile_lock_checksum", "rewritten"), + ("redirect_gemfile_lock_checksum", "added"), + ("redirect_gemfile_source_block", "rewritten"), + ("redirect_gemfile_source_block", "added"), + ("redirect_gemfile_lock_source_url", "rewritten"), + ("redirect_gemfile_lock_gem_source", "rewritten"), + ("redirect_gemfile_source_url", "rewritten"), + ("redirect_golang_replace", "added"), + ("redirect_golang_replace", "updated"), + ("redirect_golang_gosum", "added"), + ("redirect_golang_gosum_prune", "removed"), + ("redirect_golang_stale_replace_removed", "removed"), + ("redirect_golang_stale_gosum_removed", "removed"), + ("redirect_npm_lock_entry", "rewritten"), + ("redirect_npm_lock_dep", "rewritten"), + ("redirect_maven_repository", "added"), + ("redirect_maven_dep_management", "added"), + ("redirect_maven_dep_version", "rewritten"), + ("redirect_maven_config", "created"), + ("redirect_maven_trusted_checksums", "created"), + ("redirect_nuget_source", "rewritten"), + ("redirect_nuget_lock", "rewritten"), + ]; + for (kind, action) in known { + let (group, _) = classify(kind, action); + assert_ne!( + group, "unknown", + "writer kind {kind}/{action} has no replay classification" + ); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs index 83b18a72..a7e344a8 100644 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs @@ -66,15 +66,18 @@ pub fn redirect_revert_supported(purl: &str) -> bool { /// drop that purl's record and edits from `state`. The caller persists the /// mutated ledger (see `persist_redirect_state`). Dispatches per ecosystem; /// purls outside [`redirect_revert_supported`] are refused (fail closed). +/// `dry_run` resolves every inverse and drift check exactly like a wet run +/// but writes nothing and leaves `state` untouched. pub async fn revert_redirect_purl( project_root: &Path, state: &mut RedirectState, purl: &str, + dry_run: bool, ) -> Result { if purl.starts_with("pkg:cargo/") { - revert_cargo_redirect_purl(project_root, state, purl).await + revert_cargo_redirect_purl(project_root, state, purl, dry_run).await } else if purl.starts_with("pkg:npm/") { - revert_npm_redirect_purl(project_root, state, purl).await + revert_npm_redirect_purl(project_root, state, purl, dry_run).await } else { Err(format!( "no hosted-redirect revert implementation for {purl}" @@ -150,10 +153,13 @@ async fn flush_staged(project_root: &Path, staged: &Staged) -> Result<(), String /// `original`, and an intermediate edit whose `original` is already live is a /// no-op. `[registries.socket-patch-…]` blocks tied to this purl's uuids are /// removed only when nothing in Cargo.toml / Cargo.lock still references them. +/// `dry_run` resolves every inverse and drift check exactly like a wet run +/// but writes nothing and leaves `state` untouched. pub async fn revert_cargo_redirect_purl( project_root: &Path, state: &mut RedirectState, purl: &str, + dry_run: bool, ) -> Result { let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let target = canon(purl); @@ -311,8 +317,15 @@ pub async fn revert_cargo_redirect_purl( } // Every inverse resolved — only now does any of it reach disk, so a - // refusal above left the project exactly as it was found. - flush_staged(project_root, &staged).await?; + // refusal above left the project exactly as it was found. A dry run + // skips ONLY the disk flush: the in-memory ledger mutation below still + // happens, so composed previews (the whole-ledger replay running after + // the per-purl reverts inside one rollback) see exactly the state a + // wet run would hand them. The caller owns the state clone and never + // persists it on a dry run, so nothing durable changes. + if !dry_run { + flush_staged(project_root, &staged).await?; + } // Only after every inverse applied cleanly: drop this purl's edits and // record from the ledger (the caller persists it). @@ -350,11 +363,14 @@ const NPM_TEXT_KINDS: [&str; 3] = [ /// Same fail-closed contract as [`revert_cargo_redirect_purl`]: every inverse /// is resolved against a staged view and NOTHING reaches disk until all of /// them have resolved, so a drift refusal leaves the project byte-identical -/// across ALL the files the ledger claims. +/// across ALL the files the ledger claims. `dry_run` resolves every inverse +/// and drift check exactly like a wet run but writes nothing and leaves +/// `state` untouched. pub async fn revert_npm_redirect_purl( project_root: &Path, state: &mut RedirectState, purl: &str, + dry_run: bool, ) -> Result { let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let target = canon(purl); @@ -523,8 +539,15 @@ pub async fn revert_npm_redirect_purl( } // Every inverse resolved — only now does any of it reach disk, so a - // refusal above left the project exactly as it was found. - flush_staged(project_root, &staged).await?; + // refusal above left the project exactly as it was found. A dry run + // skips ONLY the disk flush: the in-memory ledger mutation below still + // happens, so composed previews (the whole-ledger replay running after + // the per-purl reverts inside one rollback) see exactly the state a + // wet run would hand them. The caller owns the state clone and never + // persists it on a dry run, so nothing durable changes. + if !dry_run { + flush_staged(project_root, &staged).await?; + } // Only after every inverse applied cleanly: drop this purl's edits and // record from the ledger (the caller persists it). @@ -816,7 +839,7 @@ mod tests { .unwrap(); assert!(toml.contains("socket-patch-"), "{toml}"); - let out = revert_cargo_redirect_purl(root, &mut state, PURL) + let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) .await .expect("revert succeeds"); assert!(!out.reverted_files.is_empty()); @@ -852,7 +875,7 @@ mod tests { .await .unwrap(); - revert_cargo_redirect_purl(root, &mut state, PURL) + revert_cargo_redirect_purl(root, &mut state, PURL, false) .await .expect("revert succeeds"); let cfg = tokio::fs::read_to_string(&cfg_path).await.unwrap(); @@ -874,7 +897,7 @@ mod tests { let records_before = state.records.len(); let edits_before = state.edits.len(); - let err = revert_cargo_redirect_purl(root, &mut state, PURL) + let err = revert_cargo_redirect_purl(root, &mut state, PURL, false) .await .expect_err("drifted lock must refuse"); assert!(err.contains("drifted"), "{err}"); @@ -910,7 +933,7 @@ mod tests { "fixture is hosted-wired: {lock_before}" ); - let err = revert_cargo_redirect_purl(root, &mut state, PURL) + let err = revert_cargo_redirect_purl(root, &mut state, PURL, false) .await .expect_err("drifted manifest must refuse"); assert!(err.contains("drifted"), "{err}"); @@ -944,12 +967,95 @@ mod tests { async fn missing_record_is_an_error() { let tmp = tempfile::tempdir().unwrap(); let mut state = RedirectState::new(); - let err = revert_cargo_redirect_purl(tmp.path(), &mut state, PURL) + let err = revert_cargo_redirect_purl(tmp.path(), &mut state, PURL, false) .await .expect_err("no record"); assert!(err.contains("records no hosted redirect"), "{err}"); } + #[tokio::test] + async fn dry_run_previews_the_wet_summary_without_touching_disk_or_ledger() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + let toml_before = tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(); + let lock_before = tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(); + let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(); + let records_before = state.records.len(); + let edits_before = state.edits.len(); + + let dry = revert_cargo_redirect_purl(root, &mut state, PURL, true) + .await + .expect("dry-run revert succeeds"); + + // Nothing reached disk and the ledger still claims everything. + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.toml")) + .await + .unwrap(), + toml_before, + "Cargo.toml untouched" + ); + assert_eq!( + tokio::fs::read_to_string(root.join("Cargo.lock")) + .await + .unwrap(), + lock_before, + "Cargo.lock untouched" + ); + assert_eq!( + tokio::fs::read_to_string(root.join(".cargo/config.toml")) + .await + .unwrap(), + cfg_before, + ".cargo/config.toml untouched" + ); + // The IN-MEMORY ledger is claimed exactly like a wet run (composed + // previews — the whole-ledger replay running after per-purl + // reverts — must see the post-claim state); the caller owns the + // clone and never persists it on a dry run. + assert!(state.records.len() < records_before, "record claimed in memory"); + assert!(state.edits.len() < edits_before, "edits claimed in memory"); + + // The preview names exactly the files a wet run then reverts — + // re-run wet on a FRESH state clone of the same fixture. + let (tmp2, mut state2) = redirected_fixture().await; + let root = tmp2.path(); + let wet = revert_cargo_redirect_purl(root, &mut state2, PURL, false) + .await + .expect("wet revert succeeds"); + assert_eq!(dry.reverted_files, wet.reverted_files); + } + + #[tokio::test] + async fn dry_run_still_fail_closes_on_drift() { + let (tmp, mut state) = redirected_fixture().await; + let root = tmp.path(); + // Same drift as the wet refusal above: a third party re-resolved the + // lock to a shape the ledger never saw. + tokio::fs::write( + root.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"registry+https://corp.example/index\"\n", + ) + .await + .unwrap(); + let records_before = state.records.len(); + let edits_before = state.edits.len(); + + let err = revert_cargo_redirect_purl(root, &mut state, PURL, true) + .await + .expect_err("drifted lock must refuse on a dry run too"); + assert!(err.contains("drifted"), "{err}"); + // The ledger keeps everything on refusal. + assert_eq!(state.records.len(), records_before); + assert_eq!(state.edits.len(), edits_before); + } + // ── npm family ─────────────────────────────────────────────────────── const NPM_PURL: &str = "pkg:npm/left-pad@1.3.0"; @@ -1078,7 +1184,7 @@ mod tests { .unwrap(); assert!(wired.contains(NPM_URL), "fixture is hosted-wired: {wired}"); - let out = revert_redirect_purl(root, &mut state, NPM_PURL) + let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("revert succeeds"); assert_eq!(out.reverted_files, vec!["yarn.lock".to_string()]); @@ -1105,7 +1211,7 @@ mod tests { "fixture is hosted-wired: {wired}" ); - revert_redirect_purl(root, &mut state, NPM_PURL) + revert_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("revert succeeds"); assert_eq!( @@ -1130,7 +1236,7 @@ mod tests { .unwrap(); assert!(wired.contains(NPM_URL), "fixture is hosted-wired: {wired}"); - revert_npm_redirect_purl(root, &mut state, NPM_PURL) + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("revert succeeds"); assert_eq!( @@ -1222,7 +1328,7 @@ mod tests { "both instances hosted-wired: {wired}" ); - revert_npm_redirect_purl(root, &mut state, NPM_PURL) + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("revert succeeds"); assert_eq!( @@ -1253,7 +1359,7 @@ mod tests { state.edits ); - revert_npm_redirect_purl(root, &mut state, NPM_PURL) + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("revert succeeds"); assert_eq!( @@ -1316,7 +1422,7 @@ mod tests { ))), }); - revert_npm_redirect_purl(root, &mut state, NPM_PURL) + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("takeover of 1.3.0 succeeds without touching 1.3.0-rc1"); @@ -1411,7 +1517,7 @@ mod tests { .unwrap(); assert!(wired.contains(NPM_URL) && wired.contains(&sibling_url)); - revert_npm_redirect_purl(root, &mut state, NPM_PURL) + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("takeover of 1.3.0 succeeds without touching 1.2.0"); @@ -1441,7 +1547,7 @@ mod tests { ); // The sibling's own takeover still round-trips the file to pristine. - revert_npm_redirect_purl(root, &mut state, sibling_purl) + revert_npm_redirect_purl(root, &mut state, sibling_purl, false) .await .expect("takeover of 1.2.0 succeeds"); assert_eq!( @@ -1506,7 +1612,7 @@ mod tests { assert_eq!(state.edits.len(), 3, "{:?}", state.edits); let other_url = npm_dep_for("other", "1.3.0").artifact_url.clone(); - revert_npm_redirect_purl(root, &mut state, NPM_PURL) + revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect("takeover of left-pad succeeds without touching `other`"); @@ -1533,7 +1639,7 @@ mod tests { state.edits ); - revert_npm_redirect_purl(root, &mut state, other_purl) + revert_npm_redirect_purl(root, &mut state, other_purl, false) .await .expect("takeover of other succeeds"); assert_eq!( @@ -1590,7 +1696,7 @@ mod tests { .unwrap(); let edits_before = state.edits.len(); - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL) + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect_err("vanished entry must refuse"); assert!(err.contains("no longer exists"), "{err}"); @@ -1613,7 +1719,7 @@ mod tests { let records_before = state.records.len(); let edits_before = state.edits.len(); - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL) + let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) .await .expect_err("drifted lock must refuse"); assert!(err.contains("drifted"), "{err}"); @@ -1632,7 +1738,7 @@ mod tests { async fn npm_missing_record_is_an_error() { let tmp = tempfile::tempdir().unwrap(); let mut state = RedirectState::new(); - let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL) + let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL, false) .await .expect_err("no record"); assert!(err.contains("records no hosted redirect"), "{err}"); @@ -1658,7 +1764,7 @@ mod tests { " \"left-pad\": [\"left-pad@{NPM_URL}\", {{}}, \"sha512-h==\"]," ))), }); - let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL) + let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL, false) .await .expect_err("bun edits must refuse"); assert!(err.contains("bun.lock"), "{err}"); diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index ad790994..eef14181 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -48,7 +48,7 @@ use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; const BUN_LOCK: &str = "bun.lock"; @@ -281,11 +281,29 @@ pub(crate) async fn vendor_bun( /// Undo one bun-vendored package: restore the recorded entry lines and /// remove the artifact dir. Reverse application order; per-record ownership /// is re-checked against the live line (drift ⇒ warning, left alone). +/// Test-only shorthand — production routes through [`revert_bun_opts`] +/// (via [`super::npm_flavor::revert_npm_any_opts`]). +#[cfg(test)] pub(crate) async fn revert_bun( entry: &VendorEntry, project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_bun_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_bun`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion — and the refusals that exist only to protect it — +/// while the wiring restore runs unchanged. +pub(crate) async fn revert_bun_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: `entry.uuid` comes from the committed, tamper-able // state.json and names the directory tree we are about to DELETE. // Validate through the same fail-closed grammar vendor used. @@ -297,7 +315,9 @@ pub(crate) async fn revert_bun( // only be removed when bun.lock provably no longer resolves through it // — otherwise refuse, fail-closed, instead of silently bricking // installs. Runs before the dry-run return so a preview never - // advertises a revert the wet run refuses. + // advertises a revert the wet run refuses. Skipped under + // `keep_artifact`: the refusal exists only to protect the deletion, + // which a preserve-state revert never performs. if entry.wiring.is_empty() { if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( project_root, @@ -375,8 +395,13 @@ pub(crate) async fn revert_bun( return outcome; } - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { - return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so only the deletion is skipped. + if !keep_artifact { + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } } outcome } diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 6656edf1..647aa778 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -34,7 +34,7 @@ use super::state::{ write_marker, CargoLockOriginal, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, VENDOR_MARKER_FILE, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// True if a crate is vendored under `/vendor/` (in either the /// `-/` or bare `/` layout the cargo crawler probes). A @@ -916,6 +916,20 @@ pub async fn revert_cargo_vendor( project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_cargo_vendor_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_cargo_vendor`] with full [`RevertOpts`]: `keep_artifact` skips +/// the artifact deletion while the wiring restore runs unchanged. +pub async fn revert_cargo_vendor_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: the coordinates and uuid come from a committed, tamper-able // state.json and key a directory we are about to delete — re-validate // fail-closed before any disk access (mirrors the vendor-side guard). @@ -973,7 +987,10 @@ pub async fn revert_cargo_vendor( }; } - if !dry_run { + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if !dry_run && !keep_artifact { let uuid_dir = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound // Best-effort: prune the now-empty `.socket/vendor/cargo/` level so a diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 454f3292..45970f27 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -50,7 +50,7 @@ use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// Project-relative lockfile this backend wires. const COMPOSER_LOCK: &str = "composer.lock"; @@ -390,6 +390,21 @@ pub async fn revert_composer( project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_composer_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_composer`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion — and the stranded-wiring refusal that exists only to +/// protect it — while the wiring restore runs unchanged. +pub async fn revert_composer_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid // grammar is rejected fail-closed before any disk access. @@ -405,20 +420,25 @@ pub async fn revert_composer( // Nothing may be deleted while composer.lock still consumes it. Checked // BEFORE the restore loop (and before any write) so the answer is the - // same for `--dry-run` and a wet run. - let stranded = stranded_wired_packages(&lock_path, &entry.uuid, &restorable_keys(entry)).await; - if !stranded.is_empty() { - let listed = stranded.join(", "); - let args = stranded.join(" "); - return RevertOutcome::failed(format!( - "refusing revert: composer.lock still points {listed} at {uuid_dir_rel}, but the \ - ledger entry records no pre-vendor lock fragment to restore (an entry \ - reconstructed by `socket-patch repair` recovers the artifact, never the \ - registry dist the surgery replaced). The vendored artifacts were LEFT IN \ - PLACE so the project still installs. To undo the vendoring, re-resolve the \ - package from the registry first (`composer update --no-install {args}`), then \ - re-run `socket-patch vendor --revert`" - )); + // same for `--dry-run` and a wet run. Skipped under `keep_artifact`: + // the refusal exists only to protect the deletion, which a + // preserve-state revert never performs. + if !keep_artifact { + let stranded = + stranded_wired_packages(&lock_path, &entry.uuid, &restorable_keys(entry)).await; + if !stranded.is_empty() { + let listed = stranded.join(", "); + let args = stranded.join(" "); + return RevertOutcome::failed(format!( + "refusing revert: composer.lock still points {listed} at {uuid_dir_rel}, but the \ + ledger entry records no pre-vendor lock fragment to restore (an entry \ + reconstructed by `socket-patch repair` recovers the artifact, never the \ + registry dist the surgery replaced). The vendored artifacts were LEFT IN \ + PLACE so the project still installs. To undo the vendoring, re-resolve the \ + package from the registry first (`composer update --no-install {args}`), then \ + re-run `socket-patch vendor --revert`" + )); + } } // Wiring is restored in reverse application order (one record today). @@ -450,7 +470,10 @@ pub async fn revert_composer( } } - if !dry_run { + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if !dry_run && !keep_artifact { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { kept_artifact: false, diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 186283ea..9f687da3 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -75,7 +75,7 @@ use super::service_fetch::{ use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; const GEMFILE: &str = "Gemfile"; const GEMFILE_LOCK: &str = "Gemfile.lock"; @@ -1076,6 +1076,21 @@ async fn materialise_patched_copy( /// `bundle update`, a newer vendor run — is left alone with a /// `vendor_lock_entry_drifted` warning. pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + revert_gem_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_gem`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion — and the unwired refusal that exists only to protect +/// it — while the wiring restore runs unchanged. +pub async fn revert_gem_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid // grammar is rejected fail-closed before any disk access. @@ -1095,6 +1110,8 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) // the removed dir and the next `bundle install` hard-fails. Refuse // loudly with the manual cleanup steps instead. (Every entry // `vendor_gem` records carries at least the Gemfile + lock records.) + // Skipped under `keep_artifact`: the refusal exists only to protect the + // deletion, which a preserve-state revert never performs. if entry.wiring.is_empty() { let name = parse_gem_purl(&entry.base_purl) .map(|(n, _)| n) @@ -1152,7 +1169,10 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) } } - if !dry_run { + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if !dry_run && !keep_artifact { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { kept_artifact: false, diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index cf12fddf..5b606b2a 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -39,7 +39,7 @@ use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// Vendor one Go module: patched copy in the uuid dir + a vendor-owned /// `replace` directive + marker, returning the ledger entry to persist. @@ -547,6 +547,20 @@ pub async fn revert_go_vendor( project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_go_vendor_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_go_vendor`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion while the wiring restore runs unchanged. +pub async fn revert_go_vendor_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: the coordinates and uuid come from a committed, tamper-able // state.json and key a directory we are about to delete — re-validate // fail-closed before any disk access (mirrors the vendor-side guard). @@ -575,7 +589,10 @@ pub async fn revert_go_vendor( return RevertOutcome::failed(format!("failed to update go.mod: {e}")); } - if !dry_run { + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if !dry_run && !keep_artifact { let uuid_dir = project_root.join(&base_rel); let _ = remove_tree(&uuid_dir).await; // ignore NotFound // Best-effort: prune the now-empty `.socket/vendor/golang/` level so a diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 6b2e891c..6422ed3d 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -81,7 +81,7 @@ use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// The project file this backend wires (always at the project root). const PROJECT_POM: &str = "pom.xml"; @@ -449,6 +449,20 @@ pub async fn revert_maven( project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_maven_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_maven`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion while the wiring restore runs unchanged. +pub async fn revert_maven_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid grammar // is rejected fail-closed before any disk access. @@ -497,7 +511,10 @@ pub async fn revert_maven( } } - if !dry_run { + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if !dry_run && !keep_artifact { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { kept_artifact: false, diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index b06c8608..1c50c98f 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -596,6 +596,29 @@ pub enum VendorOutcome { }, } +/// Options for a vendored revert (one backend `revert_*_opts` call). +#[derive(Debug, Clone, Copy)] +pub struct RevertOpts { + /// Preview only — no file writes, no artifact deletion. + pub dry_run: bool, + /// Restore the lockfile wiring but KEEP the artifact directory (and the + /// caller keeps the ledger entry) — `rollback/remove --preserve-state`. + /// Never sets [`RevertOutcome::kept_artifact`], which stays reserved for + /// drift-keeps. + pub keep_artifact: bool, +} + +impl RevertOpts { + /// The classic revert shape every `dry_run: bool` caller used: the + /// artifact directory is deleted on a successful wet revert. + pub fn new(dry_run: bool) -> Self { + Self { + dry_run, + keep_artifact: false, + } + } +} + /// The result of one backend `revert_*` call. #[derive(Debug)] pub struct RevertOutcome { diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 4146cf26..cc5298e2 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -28,7 +28,7 @@ use super::pnpm_lock_legacy::PnpmLockGrammar; use super::state::VendorEntry; use super::{ bun_lock, npm_lock, pnpm_lock, pnpm_lock_legacy, yarn_berry_lock, yarn_classic_lock, - RevertOutcome, VendorOutcome, VendorWarning, + RevertOpts, RevertOutcome, VendorOutcome, VendorWarning, }; /// Which lockfile flavor drives this project's npm installs. @@ -446,20 +446,30 @@ pub async fn revert_npm_any( entry: &VendorEntry, project_root: &Path, dry_run: bool, +) -> RevertOutcome { + revert_npm_any_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_npm_any`] with full [`RevertOpts`], threaded through to the +/// flavor backend that wired the entry. +pub async fn revert_npm_any_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, ) -> RevertOutcome { match entry.flavor.as_deref() { - None | Some("package-lock") => npm_lock::revert_npm(entry, project_root, dry_run).await, + None | Some("package-lock") => npm_lock::revert_npm_opts(entry, project_root, opts).await, Some("yarn-classic") => { - yarn_classic_lock::revert_yarn_classic(entry, project_root, dry_run).await + yarn_classic_lock::revert_yarn_classic_opts(entry, project_root, opts).await } Some("yarn-berry") => { - yarn_berry_lock::revert_yarn_berry(entry, project_root, dry_run).await + yarn_berry_lock::revert_yarn_berry_opts(entry, project_root, opts).await } - Some("pnpm") => pnpm_lock::revert_pnpm(entry, project_root, dry_run).await, + Some("pnpm") => pnpm_lock::revert_pnpm_opts(entry, project_root, opts).await, Some("pnpm-legacy") => { - pnpm_lock_legacy::revert_pnpm_legacy(entry, project_root, dry_run).await + pnpm_lock_legacy::revert_pnpm_legacy_opts(entry, project_root, opts).await } - Some("bun") => bun_lock::revert_bun(entry, project_root, dry_run).await, + Some("bun") => bun_lock::revert_bun_opts(entry, project_root, opts).await, Some(other) => RevertOutcome::failed(format!( "this socket-patch build cannot revert npm vendor flavor `{other}` — upgrade \ socket-patch and re-run" diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 555513bc..37e34012 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -29,7 +29,7 @@ use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; // Test-only re-imports: the helpers moved to `npm_common` but the existing // suite exercises them through `use super::*` and stays unmodified. @@ -443,6 +443,21 @@ pub(super) async fn guard_unwired_textual_revert( /// Undo one vendored npm package: restore the recorded lock fragments and /// remove the artifact dir. pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + revert_npm_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_npm`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion — and the refusals that exist only to protect it — +/// while the wiring restore runs unchanged. +pub async fn revert_npm_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: `entry.uuid` comes from the committed, tamper-able // state.json and names the directory tree we are about to DELETE. // Validate through the same fail-closed grammar vendor used before any @@ -455,7 +470,9 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) // only be removed when the lock provably no longer resolves through it — // otherwise refuse, fail-closed, instead of silently bricking installs. // Runs before the dry-run return so a preview never advertises a revert - // the wet run refuses (same precedent as the uuid guard above). + // the wet run refuses (same precedent as the uuid guard above). Skipped + // under `keep_artifact`: the refusal exists only to protect the + // deletion, which a preserve-state revert never performs. if entry.wiring.is_empty() { if let Some(blocked) = guard_unwired_textual_revert( project_root, @@ -554,6 +571,14 @@ pub async fn revert_npm(entry: &VendorEntry, project_root: &Path, dry_run: bool) return outcome; } + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so the deletion — and the still-wired probe that exists only + // to protect it — are skipped. + if keep_artifact { + return outcome; + } + // FAIL-CLOSED (same brick class as the unwired guard above): the // restore only rewrites the lock files the wiring names, but the lock // npm actually installs from can still resolve through the artifact — diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 9bbd372a..3dc6ad55 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -67,7 +67,7 @@ use super::service_fetch::{service_archive_copy, ServiceCopy}; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// Project-relative lockfile this backend pins (optional — NuGet only writes /// it when `RestorePackagesWithLockFile`/`--use-lock-file` is set). @@ -573,6 +573,20 @@ pub async fn revert_nuget( project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_nuget_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_nuget`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion while the wiring restore runs unchanged. +pub async fn revert_nuget_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: state.json is committed and tamper-able; the uuid keys the // directory we are about to delete. Anything but the canonical uuid // grammar is rejected fail-closed before any disk access. @@ -627,7 +641,10 @@ pub async fn revert_nuget( } } - if !dry_run { + // `--preserve-state` (`keep_artifact`): the artifact dir stays behind + // (and the caller keeps the ledger entry), so only the deletion is + // skipped. + if !dry_run && !keep_artifact { if let Err(e) = remove_tree(&uuid_dir).await { return RevertOutcome { kept_artifact: false, diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 60b45a7e..07b6ffff 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -61,7 +61,7 @@ use super::path::parse_vendor_path; use super::state::{ write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; const PACKAGE_JSON: &str = "package.json"; const PNPM_LOCK: &str = "pnpm-lock.yaml"; @@ -495,6 +495,21 @@ pub(super) async fn guard_unwired_revert( /// remove the artifact dir. Reverse application order; per-record ownership /// is re-checked against the live fragment (drift ⇒ warning, left alone). pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + revert_pnpm_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_pnpm`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion — and the refusals that exist only to protect it — +/// while the wiring restore runs unchanged. +pub async fn revert_pnpm_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: `entry.uuid` comes from the committed, tamper-able // state.json and names the directory tree we are about to DELETE. // Validate through the same fail-closed grammar vendor used. @@ -506,7 +521,9 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool // only be removed when the lock provably no longer resolves through it — // otherwise refuse, fail-closed, instead of silently bricking installs. // Runs before the dry-run return so a preview never advertises a revert - // the wet run refuses (same precedent as the uuid guard above). + // the wet run refuses (same precedent as the uuid guard above). Skipped + // under `keep_artifact`: the refusal exists only to protect the + // deletion, which a preserve-state revert never performs. if entry.wiring.is_empty() { let in_use = pnpm_entry_in_use(entry, project_root).await; if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { @@ -703,8 +720,13 @@ pub async fn revert_pnpm(entry: &VendorEntry, project_root: &Path, dry_run: bool return outcome; } - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { - return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so only the deletion is skipped. + if !keep_artifact { + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } } outcome } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index bdfaef86..57bd51aa 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -77,7 +77,7 @@ use super::pnpm_lock::{ use super::state::{ write_marker, PnpmMeta, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; const PACKAGE_JSON: &str = "package.json"; const PNPM_LOCK: &str = "pnpm-lock.yaml"; @@ -1194,6 +1194,21 @@ pub async fn revert_pnpm_legacy( project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_pnpm_legacy_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_pnpm_legacy`] with full [`RevertOpts`]: `keep_artifact` skips +/// the artifact deletion — and the refusals that exist only to protect it — +/// while the wiring restore runs unchanged. +pub async fn revert_pnpm_legacy_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { Ok(d) => d, Err(outcome) => return outcome, @@ -1201,7 +1216,9 @@ pub async fn revert_pnpm_legacy( // Nothing to replay (a `repair`-reconstructed entry): refuse the // artifact removal while the legacy lock still resolves through it — // fail-closed, before the dry-run return, exactly like the v9 backend - // (see [`super::pnpm_lock::guard_unwired_revert`]). + // (see [`super::pnpm_lock::guard_unwired_revert`]). Skipped under + // `keep_artifact`: the refusal exists only to protect the deletion, + // which a preserve-state revert never performs. if entry.wiring.is_empty() { let in_use = pnpm_legacy_entry_in_use(entry, project_root).await; if let Some(blocked) = guard_unwired_revert(project_root, in_use, &uuid_dir_rel).await { @@ -1363,8 +1380,13 @@ pub async fn revert_pnpm_legacy( return outcome; } - if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { - return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so only the deletion is skipped. + if !keep_artifact { + if let Err(e) = remove_tree(&project_root.join(&uuid_dir_rel)).await { + return RevertOutcome::failed(format!("cannot remove {uuid_dir_rel}: {e}")); + } } outcome } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 446f8f7f..94abff96 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -36,7 +36,7 @@ use super::state::{ write_marker, PdmMeta, PipenvMeta, PoetryMeta, UvMeta, VendorArtifact, VendorEntry, VendorMarker, }; -use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; /// Which wiring backend serves this project. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -601,6 +601,20 @@ pub async fn vendor_pypi( /// the artifact uuid dir (validated path only — never a path taken on faith /// from state.json). pub async fn revert_pypi(entry: &VendorEntry, project_root: &Path, dry_run: bool) -> RevertOutcome { + revert_pypi_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_pypi`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion while the per-flavor wiring restore runs unchanged. +pub async fn revert_pypi_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; let mut outcome = match entry.flavor.as_deref() { Some("uv") => revert_uv(entry, project_root, dry_run).await, Some("requirements") => revert_requirements(entry, project_root, dry_run).await, @@ -616,6 +630,12 @@ pub async fn revert_pypi(entry: &VendorEntry, project_root: &Path, dry_run: bool if !outcome.success || dry_run { return outcome; } + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so only the deletion is skipped. + if keep_artifact { + return outcome; + } // SECURITY: entry.uuid comes from the committed, tamper-able state.json // and names a directory for DELETION. Re-validate through the canonical // uuid grammar; on failure warn and keep the dir (fail-closed). diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 68ec4a1c..02a965cb 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -53,7 +53,7 @@ use super::yarn_classic_lock::{ read_yarn_lock, replace_block, revert_recorded_block, scan_blocks, split_key_patterns, split_pattern, LockBlock, }; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; const YARN_LOCK: &str = "yarn.lock"; const PACKAGE_JSON: &str = "package.json"; @@ -503,11 +503,30 @@ pub async fn vendor_yarn_berry( /// Undo one yarn-berry vendored package: restore the recorded lock entry, /// remove the resolutions entry, and remove the artifact dir. +/// Test-only shorthand — production routes through +/// [`revert_yarn_berry_opts`] (via +/// [`super::npm_flavor::revert_npm_any_opts`]). +#[cfg(test)] pub async fn revert_yarn_berry( entry: &VendorEntry, project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_yarn_berry_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_yarn_berry`] with full [`RevertOpts`]: `keep_artifact` skips the +/// artifact deletion — and the refusals that exist only to protect it — +/// while the wiring restore runs unchanged. +pub async fn revert_yarn_berry_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: shared fail-closed guard on the tamper-able uuid, before any // disk access. let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { @@ -522,7 +541,9 @@ pub async fn revert_yarn_berry( // path), so each is probed independently — a dangling `file:` spec in // either fails every subsequent install on the missing tarball. Runs // before the dry-run return so a preview never advertises a revert the - // wet run refuses. + // wet run refuses. Skipped under `keep_artifact`: the refusal exists + // only to protect the deletion, which a preserve-state revert never + // performs. if entry.wiring.is_empty() { for wired in [YARN_LOCK, PACKAGE_JSON] { if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( @@ -662,6 +683,14 @@ pub async fn revert_yarn_berry( return outcome; } + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so the deletion — and the still-wired probes that exist only + // to protect it — are skipped. + if keep_artifact { + return outcome; + } + // FAIL-CLOSED (same brick class as the unwired guard above, twin of // npm_lock's post-restore probe): the restore only rewrites the // fragments the wiring recorded, but yarn can still resolve through the diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 70395cef..3b110109 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -38,7 +38,7 @@ use super::path::parse_vendor_path; use super::state::{ write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; -use super::{RevertOutcome, VendorOutcome, VendorWarning}; +use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; const YARN_LOCK: &str = "yarn.lock"; @@ -298,11 +298,30 @@ pub async fn vendor_yarn_classic( /// Undo one yarn-classic vendored package: restore the recorded lock blocks /// and remove the artifact dir. +/// Test-only shorthand — production routes through +/// [`revert_yarn_classic_opts`] (via +/// [`super::npm_flavor::revert_npm_any_opts`]). +#[cfg(test)] pub async fn revert_yarn_classic( entry: &VendorEntry, project_root: &Path, dry_run: bool, ) -> RevertOutcome { + revert_yarn_classic_opts(entry, project_root, RevertOpts::new(dry_run)).await +} + +/// [`revert_yarn_classic`] with full [`RevertOpts`]: `keep_artifact` skips +/// the artifact deletion — and the refusals that exist only to protect it — +/// while the wiring restore runs unchanged. +pub async fn revert_yarn_classic_opts( + entry: &VendorEntry, + project_root: &Path, + opts: RevertOpts, +) -> RevertOutcome { + let RevertOpts { + dry_run, + keep_artifact, + } = opts; // SECURITY: shared fail-closed guard on the tamper-able uuid, before any // disk access. let uuid_dir_rel = match guard_revert_uuid_dir(&entry.uuid) { @@ -313,7 +332,9 @@ pub async fn revert_yarn_classic( // only be removed when yarn.lock provably no longer resolves through it // — otherwise refuse, fail-closed, instead of silently bricking // installs. Runs before the dry-run return so a preview never - // advertises a revert the wet run refuses. + // advertises a revert the wet run refuses. Skipped under + // `keep_artifact`: the refusal exists only to protect the deletion, + // which a preserve-state revert never performs. if entry.wiring.is_empty() { if let Some(blocked) = super::npm_lock::guard_unwired_textual_revert( project_root, @@ -398,6 +419,14 @@ pub async fn revert_yarn_classic( return outcome; } + // `--preserve-state` (`keep_artifact`): the wiring restore above already + // ran; the artifact dir stays behind (and the caller keeps the ledger + // entry), so the deletion — and the still-wired probe that exists only + // to protect it — are skipped. + if keep_artifact { + return outcome; + } + // FAIL-CLOSED (same brick class as the unwired guard above, twin of // npm_lock's and yarn_berry's post-restore probes): the restore only // rewrites the blocks the wiring recorded, but yarn can still resolve