diff --git a/.gitattributes b/.gitattributes index 4b3f63f07..858080655 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,13 @@ crates/socket-patch-core/tests/fixtures/redirect/** -text crates/socket-patch-core/tests/fixtures/pdm-native/*.lock -text +# Poetry and Pipenv locks are real `poetry lock` / `pipenv lock` output: the +# upstream restore and VEX tests round-trip them byte for byte and derive +# their CRLF variants from the LF bytes themselves. +crates/socket-patch-core/tests/fixtures/poetry/** -text +crates/socket-patch-core/tests/fixtures/pipenv/** -text +crates/socket-patch-core/tests/fixtures/pipenv-shapes/** -text + # The captured pnpm 1-12 locks are byte-real: the hosted/vendored rewriters # refuse CRLF by design (vendor_lockfile_crlf_unsupported), and the tests # derive their CRLF variants from the LF bytes themselves. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a260c8fe..eb2d47224 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,9 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} - timeout-minutes: 35 + # Windows runs the same suite ~1.6x slower than macOS: on the base + # branch it already took 34m40s of a flat 35m budget. + timeout-minutes: ${{ matrix.os == 'windows-latest' && 50 || 35 }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a44ba3e..2689764ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,10 @@ into the new version's section — see docs/releasing.md. > packages resolve to Socket-hosted, integrity-pinned copies (hosted mode is > the default, and scan never prompts); `socket-patch vex` emits OpenVEX for > vulnerability scanners; `socket-patch vendor` ejects the patches into -> `.socket/vendor/` for offline installs; `socket-patch list` shows them. +> `.socket/vendor/` for offline installs (it ejects a hosted project: no +> manifest needed); `socket-patch list` shows them. Hosted mode keeps no +> ledger — the lockfile edits are the whole change, and `rollback` restores +> each hosted package to its upstream registry entry. > `get`, `apply`, `setup`, `rollback`, `remove` and `repair` (the agent-mode > commands) keep working and are listed after these. @@ -33,7 +36,12 @@ into the new version's section — see docs/releasing.md. > (visible to the patched crate as `CARGO_PKG_VERSION`), makes a bare `scan` > run hosted mode without prompting, changes which patch scan picks when a > package has several, makes `vex` -> refuse to attest stale ledger records and corrupt vendor ledgers, and +> refuse to attest stale ledger records and corrupt vendor ledgers, drops the +> hosted redirect ledger (`rollback` / `remove` now restore upstream registry +> entries and refuse where they cannot; `list`'s hosted `details.ledger` +> becomes `details.lockfiles`; scan's `redirectState` loses `ledger` / +> `ledgerKey`; `vendor_supersedes_redirect` and `hosted_revert_unsupported` +> are gone), and > retries a throttled patch API (new error text, added waiting, a throttled > package failing its legacy-proxy batch) — all > MAJOR per CLI_CONTRACT.md's semver policy — so it ships as the next major @@ -41,6 +49,183 @@ into the new version's section — see docs/releasing.md. ### Changed (BREAKING) +> **Ledger-free hosted mode.** The first entries below supersede every +> earlier entry in this section (and under Added / Fixed) that describes +> the hosted redirect ledger (`.socket/vendor/redirect-state.json`): its +> writes, quarantine, per-purl reverts, whole-ledger replay, ledger +> records in `list` / `vex` / `scan`, and `vendor_supersedes_redirect`. +> Those describe intermediate v5 development states; the ledger-free +> contract here is what ships. + +- **Hosted mode keeps no ledger.** `scan --mode hosted` / `get --mode + hosted` (and the in-memory hosted engine behind the hosted bundle) write + ONLY their lockfile / registry-config edits: + `.socket/vendor/redirect-state.json` is never written — not on success, + not on failure — so a hosted project commits just its lockfile and config + changes (`Commit package-lock.json to keep the redirect.`). A pre-v5 + ledger on disk is ignored by scan (never read for planning, never + quarantined, left byte-identical); a re-run plans from the current lock + text and is idempotent. The in-run `scan --mode hosted --vex` attests from + the patch records this run fetched, and the gem / Python stale-install + probes judge only those (a purl whose record fetch failed is not judged + this run: `record_fetch_failed` now says the in-run VEX omits it and + `socket-patch vex` fetches it again once the API answers). +- **`rollback` and `remove` restore hosted pins to their upstream registry + entries.** With no ledger to replay, each hosted pin the lockfiles wire + (discovered like `vex` does: a hosted URL counts only on + `https://patch.socket.dev` or the `--patch-server-url` origin) is + rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, + re-resolving what the entry pins from the public registry (core + `patch::redirect::upstream`). Restored formats: `package-lock.json` / + `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / + `shrinkwrap.yaml`, `bun.lock` and `vlt-lock.json` (npm registry version + document), `Cargo.lock` + `Cargo.toml` + the project cargo config + (crates.io sparse index), `go.mod` / `go.sum` (module proxy + checksum + database; a user's pre-hosted `replace` is not recoverable, so the + restore lands on the plain upstream module), `Pipfile.lock`, + `requirements.txt`, Hatch direct references, `poetry.lock`, `pdm.lock`, + `uv.lock`, PEP 723 script locks and `pylock*.toml` (PyPI JSON API), + `Gemfile.lock` / `gems.locked` + the Gemfile source block (rubygems.org + compact index; the declaration comes back as the exact pin), `composer.lock` + (packagist v2 metadata), `pom.xml` + the `.mvn` trusted-checksums lines + (no network — restores offline), and `nuget.config` + `packages.lock.json` + (nuget.org `contentHash`). A pin is all-or-nothing and nothing is written + until every pin resolved; `--dry-run` resolves exactly like a wet run. + **Refused**, with nothing written for the pin and the message `cannot + restore to its upstream registry entry: ; restore it from + version control instead (`git checkout -- `)`: every pin under + `--offline` except Maven, a registry that does not answer or no longer + describes the entry, a binary `bun.lockb` (for `rollback` / `remove`; see + the vendor takeover below), a composer entry that + is not packagist-sourced or whose `dist.reference` packagist no longer + serves, a gem whose upstream section is ambiguous or not rubygems.org, a + nuget id the restored config would not resolve from nuget.org alone, a + `pdm.lock` without `cross_platform` or a uv / pylock lock whose release has + a non-pure-Python-3 wheel, a uv lock whose options filter files or whose + registry is not PyPI's, uv 0.2 `[[distribution]]` locks, and any file + format the restore does not know. Rollback reports a refusal in + `hosted.failed[{purl, error}]` (exit 1, `partial_failure`; human `Error: + Cannot restore …`), `remove` as `hosted_revert_failed` before touching the + manifest. Human lines are `Restored to its upstream registry entry` + / `Would restore …`, and the prompt clause is `restore N hosted packages + to the upstream registry`. Scoped runs restore only the named pins (no + whole-ledger replay; `hosted.unsupported` is always empty and + `hosted_revert_unsupported` is gone), `--preserve-state` still restores + (`hosted_state_not_preservable`), and the vlt install heal still runs. + Side settings: a project `.npmrc` that is exactly `allow-remote=all` and a + `pnpm-workspace.yaml` that is exactly hosted mode's scaffold are deleted + once no lock entry needs them; otherwise the line stays with + `npm_allow_remote_left` / `pnpm_trust_lockfile_left` (v5 records no + provenance). New advisories: `maven_trusted_checksums_left`, + `nuget_default_config_left`, `upstream_uv_override_removed`. A pin + discovery cannot see (a lockless cargo pin, a nuget mapping with no + `packages.lock.json`, a Gemfile-only gem) is out of reach: restore those + files from version control. v4's `redirect_state_unreadable`, + `redirect_pnpm_trust_scaffold_modified` and + `redirect_npmrc_allow_remote_modified` are no longer emitted. +- **The Pipenv hosted redirect keeps the entry's `index`.** A hosted + `Pipfile.lock` entry is now `{"file" | "path", "hashes"}` plus every key + but `version` exactly as Pipenv wrote it — `index` included, present or + absent — so `rollback` / `remove` carry it back instead of guessing it + from sibling entries. Pipenv records `index` by release, Pipfile spelling + and locking environment (2018.11.26 writes it for a marker-excluded + package, 2022.12.19 does not; 2022.12.19 writes it for an `extras` table, + 2023.12.1 and later do not; no release writes it for a transitive + package), so no rule over the lock could re-derive it, and the guess left + those rollbacks off by one key. Measured on Pipenv 2018.11.26, 2020.11.15, + 2021.11.23, 2022.12.19, 2023.12.1, 2024.4.1, 2025.1.3 and 2026.8.0: a + `file` entry carrying `index` still installs the referenced wheel itself + (`install --deploy`, `sync`, `verify`); Pipenv 7–11 ignore `index` on a + `path` entry. The restore refuses when the entry's `index` (or the + Pipfile's explicit one) does not name a PyPI source in `_meta.sources`. +- **`list`, `vex`, `scan` and `repair` derive hosted state from the + lockfiles.** `list` shows one entry per hosted pin: JSON + `details.mode: "hosted"` plus `details.lockfiles: []` + (no `details.ledger`), human `Mode: hosted (wired in package-lock.json)`; + a pin carries only its uuid unless a pre-v5 ledger records the same purl + and uuid, and a ledger record whose pin is in no lockfile is no longer + listed. `scan --json`'s `redirectState` is now `{mode, records: [{purl, + uuid}], wiringLive}` built from the pins (no `ledger`, no + `records[].ledgerKey`) and is omitted when no lockfile pins a hosted + patch; `updates[]` folds the pins in (manifest > hosted pins > vendor + ledger), and `hosted_wiring_retained` keys on them. `vex` takes hosted + references from the lockfiles and their records from the API (online); + offline without a local record the pin is `record_unavailable`. A + malformed pre-v5 redirect ledger is now the WARNING + `redirect_ledger_corrupt` in `vex` (every form) and `list` — the run + continues — instead of `vex`'s exit-2 hard error. `repair` treats a + project with hosted pins (or a pre-v5 ledger) and nothing vendored as the + `redirect_only_project` skip, exit 0. A hosted URL on a staging host is + seen only with `--patch-server-url` (no ledger vouches for it any more). +- **`vendor` ejects a hosted project, and vendoring over a hosted pin + restores upstream first.** Standalone `vendor` with no manifest and hosted + pins in the lockfiles takes its patch set from those pins, fetches each + record from the patch API, vendors into `.socket/vendor/` and rewires + hosted → vendored (`Ejecting N hosted packages into .socket/vendor/...`, + `Would eject …` on a dry run). The eject is one planned, all-or-nothing + transition: every record is fetched and every upstream restore resolved + before anything is written (a failure is `eject_refused`, nothing + touched); a dry run writes nothing (`eject_planned`); the wet run + snapshots the files it touches and, if vendoring then fails, puts them + back so the project stays hosted (`eject_rolled_back`). It works from a + fresh checkout (no installed tree needed). `--offline` refuses it with + `offline_eject_unavailable` and makes no requests. A lock whose hosted + wiring discovery cannot attribute is refused with + `hosted_wiring_contested` by eject, `rollback` and `remove` (a warning in + `list`). Without hosted pins the no-manifest no-op is unchanged. Every vendored flow (`vendor`, `scan` / `get --mode + vendored`) that meets a hosted pin — any ecosystem, no longer just cargo, + golang and the npm family — first restores its upstream registry entry + with the same restore as `rollback`, so the vendor ledger records the + upstream entry and **`vendor --revert` returns to upstream, never to + hosted**. `vendor_takeover_reverted_redirect` (`… was hosted; restored its + upstream registry entry () before vendoring (mode takeover)`) and + the dry-run `vendor_would_revert_redirect` keep their codes; a refused + restore fails the purl `redirect_revert_failed` (`cannot vendor over the + live hosted pin: …`) and leaves it hosted. The cargo backend's + `hosted_redirect_live` refusal now names `socket-patch rollback` and + `git checkout -- Cargo.toml Cargo.lock` instead of the ledger. +- **Vendoring over a hosted binary `bun.lockb` works again** (Bun 0.8–1.1's + default lock and Bun 1.2's legacy lock; it was refused + `redirect_revert_failed` once the hosted ledger was gone). The takeover + and the eject rebuild each hosted remote-tarball record as Bun's npm + registry record for `name@version` from the registry's `dist.tarball` / + `dist.integrity`, re-derive the package metadata hash, drop the hosted + URL from the string pool, then vendor; `vendor --revert` returns the + pre-hosted lock. The hosted rewrite now keeps the registry record's + inactive bytes (padding, semver) in the tarball record it writes, and a + re-pin to a later grant's URL drops the superseded URL from the string + pool, so the rebuild is byte-exact — early writers' uninitialized padding included. + Where the hosted rewrite had to normalize the lock it marks it (in the + root package's resolution bytes, which no Bun reader reads): a binary + format 1 lock (Bun 0.1.1-0.1.6), promoted to format 2, is demoted back to + its exact format-1 bytes, and a lock whose workspace dependency behaviors + were normalized is refused with the `git checkout -- bun.lockb` remedy + instead of taken over non-exactly. `rollback` / `remove` keep refusing a + hosted `bun.lockb` pin with that remedy; an `--offline` vendor still refuses. +- **`vendor_supersedes_redirect` is removed.** The vendored flows no longer + warn about (or auto-reconcile, or unwind the `.npmrc` for) a stale hosted + ledger record: once the lock routes a package to `.socket/vendor/`, no + hosted state is left to go stale. `redirect_supersedes_vendored` (hosted + over a vendored package) stays, classified from the lockfile pins. +- **A pre-v5 hosted ledger is read for migration only, and `rollback` + retires it.** No command writes `redirect-state.json` any more; `list` + and `vex` read it only as an extra record source for a pin with the same + purl and uuid, and its edits are never replayed. `rollback` and `remove` + delete it once no lockfile pins a hosted patch (`legacy_redirect_ledger_kept` + warns when the delete fails), and a `rollback` in a project whose only + state is that file removes it and exits 0 (JSON + `legacyRedirectLedgerRemoved: true`) instead of failing on the missing + manifest. +- **Registry base overrides for the upstream restore.** Besides the existing + `SOCKET_NPM_REGISTRY`, `SOCKET_GOPROXY` and `SOCKET_PYPI_JSON_API`, the + restore honors new env-only knobs for mirrors and tests: + `SOCKET_CRATES_INDEX` (default `https://index.crates.io`), + `SOCKET_GOSUMDB_URL` (`https://sum.golang.org`; else `GOSUMDB` / + `GONOSUMDB` / `GOPRIVATE` as go reads them), `SOCKET_RUBYGEMS_URL` + (`https://rubygems.org`), `SOCKET_PACKAGIST_URL` + (`https://repo.packagist.org`) and `SOCKET_NUGET_URL` + (`https://api.nuget.org`). + - **Vendored runs refuse lock-text failures before downloading them.** `scan --mode vendored` and `get --mode vendored` evaluate the vendor backends' pure lock-text gates — pnpm, yarn classic and yarn berry @@ -66,8 +251,8 @@ into the new version's section — see docs/releasing.md. package is refused this way, the human arm prints `Nothing was vendored: N patches failed (see above).`). (The human `scan --mode vendored` arm still fetches the views its - baseline pre-check verifies.) Purls the hosted redirect ledger - claims keep the vendor loop's refusal. Because no view is fetched, the + baseline pre-check verifies.) Purls the lockfiles pin hosted keep the + vendor loop's refusal. Because no view is fetched, the lock-text refusal now takes precedence over every outcome that came from the view: a package that would also have hit a paid-access 403, a failed view fetch or the no-applicable-files guardrail reports the lock @@ -259,10 +444,7 @@ into the new version's section — see docs/releasing.md. empty hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the redirect engine; a discovery whose every offer is paid-tier for an org without paid access stops the - same way with `No downloadable patches (paid subscription required).`. A - malformed redirect ledger on a human hosted run that stops before the - engine is reported as the read-only `Warning: the redirect ledger … is - malformed` advisory instead of nowhere. + same way with `No downloadable patches (paid subscription required).`. - **`get` defaults to hosted mode too.** `socket-patch get ` (and the bare-UUID shortcut `socket-patch `) now redirects the package's lockfile entry to its Socket-hosted patched copy, like `scan`. Agent mode @@ -285,12 +467,9 @@ into the new version's section — see docs/releasing.md. be written, so previews create no `.socket/` — and report `lock_held` / `lock_io` like the other lock holders (top-level `errorCode` on the hosted JSON shape; a read-only project root or a file squatting on `.socket/` is - refused at the lock, before the redirect ledger is touched, and a + refused at the lock, before any file is touched, and a vendored→hosted takeover over a symlinked wiring file is refused with - `redirect_symlinked_file_unsupported` before any revert). A zero-grant wet - run — which holds no lock — no longer moves a malformed - `redirect-state.json` aside: like a dry run it reports the hard error and - leaves the file in place; only the lock holder quarantines. The lock guard + `redirect_symlinked_file_unsupported` before any revert). The lock guard unlinks only the file it holds (a replacement planted by a non-cooperating `rm` + `touch` is left for the next acquire), and a long `--lock-timeout` wait behind a hot loop of short commands can no longer accumulate its @@ -318,7 +497,8 @@ into the new version's section — see docs/releasing.md. those flags now skip only the hashing, never the wiring, record-match and conflict gates. A malformed or unreadable `.socket/vendor/state.json` is now the hard error `vendor_ledger_corrupt` (exit 2 standalone, the host - command fails under `--vex`), mirroring `redirect_ledger_corrupt`, instead + command fails under `--vex`) — a malformed pre-v5 redirect ledger is only + the `redirect_ledger_corrupt` warning (see the ledger-free entries) — instead of a warning after which vendored patches silently lost their committed-artifact verification and detached records. Lockfiles that wire one package to different patches attest none of them @@ -365,8 +545,8 @@ into the new version's section — see docs/releasing.md. case-insensitively. - **Hosted projects report patch updates from their lockfiles.** scan's `updates[]` and `[UPDATE]` marker also see the hosted pins the lockfiles - wire, so a hosted project that never committed its redirect ledger still - reports a superseding patch. + wire (hosted mode keeps no ledger), so a hosted project still reports a + superseding patch. - **`apply` and `rollback` patch vlt installs in place.** A project installed by vlt (`node_modules/.vlt/` or `node_modules/.vlt-lock.json`) @@ -661,8 +841,8 @@ into the new version's section — see docs/releasing.md. `Cargo.lock` model, the Python lock and requirements-file readers the rewriters use, with one exact-pin rule and one hosted pypi url grammar). - `scan`'s cross-mode takeover warnings (`redirect_supersedes_vendored`, - `vendor_supersedes_redirect`), `hosted_wiring_retained` and + `scan`'s cross-mode takeover warning `redirect_supersedes_vendored`, + `hosted_wiring_retained` and `redirectState.wiringLive` now prove the live lock with the same discovery and liveness rules `vex` gates attestations on, instead of a looser text scan: a ledger record counts as live only while a lockfile @@ -890,29 +1070,21 @@ into the new version's section — see docs/releasing.md. `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 + lock) — and skip all GC. Hosted pins have no preservable state: they are + restored to upstream 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). Native `bun.lockb` - package snapshots restore binary resolutions directly; - 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. +- **Hosted unwind.** `rollback` restores every in-scope hosted pin to its + upstream registry entry, in every ecosystem — see "`rollback` and `remove` + restore hosted pins to their upstream registry entries" under Changed + (BREAKING); a pin that cannot be restored is refused with the `git checkout + -- ` remedy. - **`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 + matching hosted lockfile pins restores their upstream registry entries + (works manifest-less on hosted-only projects; a refused restore fails + `hosted_revert_failed` before the manifest mutation), and remove's default GC extends from blobs-only to blobs + diff + package archives (parity with rollback/repair/`scan --prune`). - **`SOCKET_API_CONCURRENCY` paces `scan`'s patch-API requests.** `scan` diff --git a/README.md b/README.md index af7fc5af3..82e6ef3a8 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,8 @@ socket-patch scan versions have a patch, prints each one with its severity and CVE/GHSA identifiers, and patches them in **hosted mode**: it rewrites the lockfile so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`. -It never prompts. The patch records go in `.socket/vendor/redirect-state.json`, and the -run ends by listing the files it changed. (Add `--dry-run` to preview without writing.) +It never prompts, keeps no ledger — the lockfile edits are the whole change — and ends by +listing the files it changed. (Add `--dry-run` to preview without writing.) > If it prints `No patches available for installed packages.`, none of your dependency > versions currently has a Socket patch — the good outcome, with nothing to do. To walk @@ -151,11 +151,12 @@ run ends by listing the files it changed. (Add `--dry-run` to preview without wr > (The patch catalog changes over time; if that finds nothing, pick another patched > version.) -**2. Commit.** The lockfile edit *is* the patch, so commit it with the redirect ledger -(`rollback` and `vex` read it): +**2. Commit.** The lockfile edit *is* the patch, so commit just the files `scan` +changed — there is no other state to commit (`rollback`, `list` and `vex` read the +lockfile itself): ```bash -git add package-lock.json .socket/vendor/redirect-state.json .npmrc # npm example +git add package-lock.json .npmrc # npm example git commit -m "apply Socket security patches" ``` @@ -183,19 +184,9 @@ socket-patch vex --output socket.vex.json grype . --vex socket.vex.json ``` -**5. Go offline, if you need to.** Hosted installs must reach `patch.socket.dev`. For -airgapped builds, switch to vendored mode: it copies the patched packages into -`.socket/vendor/`, points the lockfile at them, and reverts the hosted edits: - -```bash -socket-patch scan --mode vendored -git add .socket/vendor package-lock.json .npmrc && git commit -m "vendor Socket patches" -``` - -(Vendored npm installs don't need the `.npmrc` line, so the switch removes it again.) - -**6. See what you have.** `list` shows each patch and the mode that holds it -(`Mode: vendored` after step 5): +**5. See what you have.** `list` shows each patch and the mode that holds it. A hosted +patch is read straight from the lockfile, so it shows its UUID and the files that wire +it (`vex` fetches its full record from the API): ```bash socket-patch list @@ -206,17 +197,34 @@ Found 1 patch: Package: pkg:npm/flatted@3.3.1 UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 - Mode: hosted (recorded in .socket/vendor/redirect-state.json) - ... - Vulnerabilities (1): - - GHSA-25h7-pfq9-p65f (CVE-2026-32141) - Severity: HIGH + Mode: hosted (wired in package-lock.json) ``` -To undo everything, run `socket-patch rollback`: it restores the original lockfile -entries and drops the records. +**6. Go offline, if you need to.** Hosted installs must reach `patch.socket.dev`. For +airgapped builds, eject to vendored mode with `socket-patch vendor`: it fetches the patch +behind each hosted pin, copies the patched packages into `.socket/vendor/`, and points the +lockfile at them — no manifest needed: -That's the whole loop: **scan → commit → reinstall → vex**, with `scan --mode vendored` +```bash +socket-patch vendor +git add .socket/vendor package-lock.json .npmrc && git commit -m "vendor Socket patches" +``` + +(Vendored npm installs don't need the `.npmrc` line: the switch deletes the `.npmrc` hosted +mode created, or leaves it with an `npm_allow_remote_left` warning if you added settings to +it.) Each +package is first restored to its upstream registry entry and then vendored, so a later +`socket-patch vendor --revert` returns the project to the plain upstream packages, not to +hosted. After the switch, `list` reads `Mode: vendored (recorded in +.socket/vendor/state.json)` with the patch's full record. + +To undo everything, run `socket-patch rollback`: it restores each hosted lockfile entry to +its upstream registry entry (re-resolved from the registry), reverts vendored wiring, and +drops the records. Where it cannot restore an entry — offline, or a binary `bun.lockb` — +it changes nothing for that package and tells you to restore the file from version control +(`git checkout -- `). + +That's the whole loop: **scan → commit → reinstall → vex**, with `socket-patch vendor` when installs must be offline. The older *agent* mode, which patches installed files in place and re-applies them from an install hook, is still supported; the next section compares the three. @@ -257,12 +265,14 @@ Local state lives in `.socket/` at your project root, and is designed to be comm | Path | Contents | |------|----------| -| `.socket/vendor/redirect-state.json` | Hosted mode: the patch records plus the original lockfile / registry-config fragments each redirect replaced (what [`rollback`](#rollback) replays) | | `.socket/vendor/state.json` + `.socket/vendor//…` | Vendored mode: the ledger (with embedded patch records) and the patched package artifacts | | `.socket/manifest.json` | Agent mode only: the record of downloaded patches — PURLs, file hashes, vulnerability metadata ([format](#manifest-format)) | | `.socket/blobs/` | Agent mode only: patched file contents, named by git-sha256 hash | -Hosted and vendored mode never write `manifest.json`. +Hosted mode writes nothing here: its patches live only in your lockfiles (and the +registry configs it edits). Hosted and vendored mode never write `manifest.json`. A +`.socket/vendor/redirect-state.json` from a pre-v5 release is no longer used — `list` +and `vex` read it only for details, and `rollback` deletes it. > While a command runs it holds a transient advisory lock, `.socket/apply.lock`, and > removes it when it finishes — the file never outlives the command, so there is nothing @@ -280,8 +290,8 @@ run; a bare `scan` is hosted. | Mode | Where the patch lives | Install-time requirement | Trade-off | |------|----------------------|--------------------------|-----------| -| **hosted** (default) — `scan` | Nowhere in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; the edits and patch records are ledgered in `.socket/vendor/redirect-state.json` | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (lockfile + ledger); not for airgapped installs | -| **vendored** — `scan --mode vendored` (or [`vendor`](#vendor)) | Patched packages committed under `.socket/vendor/`, with the lockfile rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | +| **hosted** (default) — `scan` | Nowhere in your repo: the lockfile is rewritten so **only** the patched dependencies resolve to Socket-hosted, integrity-pinned packages on `patch.socket.dev`; nothing else is written | Installs must be able to reach `patch.socket.dev` (no CLI, no install hook) | Smallest possible diff (just the lockfile / registry-config edits); not for airgapped installs | +| **vendored** — `scan --mode vendored` or [`vendor`](#vendor) (which ejects a hosted project) | Patched packages committed under `.socket/vendor/`, with the lockfile rewired to consume them | **None** — the package manager installs the committed bytes | Fully airgapped and hermetic, at the cost of repo size | | **agent** (older) — `scan --mode agent`, [`get`](#get), [`apply`](#apply) | `.socket/manifest.json` + blobs, committed; the CLI patches installed files in place | The `socket-patch` CLI must run after every install (an install hook via [`setup`](#setup), or an `apply` step in CI) | No lockfile edits and a small repo footprint, but the only mode that needs CI / install-hook changes | Every mode pins the patched bytes: vendored and hosted modes lean on your package @@ -319,8 +329,11 @@ never changed or overridden — whether it sits in the project `.npmrc`, in your environment variable (the warning names where it found it and how to install anyway), `--no-npm-allow-remote-config` (`SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`) turns the write off (install with -`npm ci --allow-remote=all` instead), and `rollback` / `remove` / switching to vendored -mode remove exactly the line or file the run added. Vendored mode needs none of this: +`npm ci --allow-remote=all` instead). Once `rollback` / `remove` / switching to vendored +mode has restored the last hosted `package-lock.json` entry, an `.npmrc` that holds only +`allow-remote=all` is deleted; if you have other settings in it, the line is left alone +with an `npm_allow_remote_left` warning (hosted mode keeps no record of whether it added +the line). Vendored mode needs none of this: npm treats its `file:` tarballs under `allow-file`, which defaults to `all`. See [npm compatibility](docs/testing/npm-compatibility.md) for the tested majors. @@ -337,7 +350,10 @@ clean tree and an empty store, then check with `socket-patch vex`. See #### Bun Both text `bun.lock` and binary `bun.lockb` support hosted and vendored -patches, mode switching, repair, and rollback. Binary locks are read and +patches, mode switching, repair, and rollback — with one exception: a hosted +`bun.lockb` entry is not rolled back to its upstream registry entry, so +`rollback` and `remove` refuse it and point you at `git checkout -- bun.lockb` +(switching it to vendored mode rebuilds the registry entry and works). Binary locks are read and patched natively: Socket Patch does not need Bun installed to discover or rewrite them, and does not convert them to text. If both filenames exist, `bun.lock` takes precedence. See [Bun compatibility](docs/testing/bun-compatibility.md) @@ -421,7 +437,7 @@ socket-patch scan --json A hosted scan takes new patches and newer versions of the ones already applied. Your PR tooling (e.g. `peter-evans/create-pull-request`) commits the changed lockfiles and -`.socket/vendor/`; use the JSON result for the PR title/body. See +registry configs (hosted mode writes nothing else); use the JSON result for the PR title/body. See [Scripting & CI/CD](#scripting--cicd), including how to supply `SOCKET_API_TOKEN` for org-tier patches. @@ -439,11 +455,14 @@ inline with `scan --vex `. Details in [OpenVEX attestations](#openvex-atte ### Work offline / airgapped ```bash -socket-patch scan --mode vendored +socket-patch vendor # ejects a hosted project; or: socket-patch scan --mode vendored git add .socket/vendor ``` -Vendored mode needs no Socket infrastructure and no `socket-patch` binary at install +`socket-patch vendor` in a hosted project (no manifest) fetches the patch behind each +hosted lockfile pin and vendors it; `scan --mode vendored` discovers and vendors from +scratch. Either way a hosted package is restored to its upstream registry entry before it +is vendored, so `socket-patch vendor --revert` later returns to upstream. Vendored mode needs no Socket infrastructure and no `socket-patch` binary at install time — the patched packages install from the committed bytes (other, unvendored dependencies still resolve from your registry or mirror as usual). Agent mode also works offline once its blobs are committed (`socket-patch apply --offline`). `scan` and `get` @@ -453,18 +472,16 @@ need the network and refuse to run with `--offline`. | Command | What it does | |---------|--------------| -| [`rollback`](#rollback) | **Fully unpatches, in every mode**: unwinds hosted redirects (replaying the originals recorded in `redirect-state.json`) and vendored lockfile wiring, restores in-place files, and drops the records — everything, or just the given targets; `--preserve-state` keeps the local patch state for a later re-apply | -| [`remove`](#remove) | The single-patch form of `rollback`: restore, unwind, drop the record and GC for one PURL/UUID | -| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts | +| [`rollback`](#rollback) | **Fully unpatches, in every mode**: restores each hosted lockfile entry to its upstream registry entry (re-resolved from the registry; refused with a `git checkout -- ` hint where that is impossible, e.g. offline or `bun.lockb`), unwinds vendored lockfile wiring, restores in-place files, and drops the records — everything, or just the given targets; `--preserve-state` keeps the local patch state for a later re-apply | +| [`remove`](#remove) | The single-patch form of `rollback`: restore, unwind, drop the record and GC for one PURL/UUID (a hosted patch is restored to upstream) | +| [`vendor --revert`](#vendor) | **Un-vendors wholesale**: restores the recorded original lockfile fragments byte-for-byte and removes the `.socket/vendor/` artifacts (a package vendored over a hosted pin returns to upstream, not to hosted) | | [`scan --prune`](#scan) | Agent mode: **reconciles, doesn't reverse** — drops manifest entries for packages that have left the project and garbage-collects orphan blob/diff/archive files | | [`repair`](#repair) (alias `gc`) | **Restores health, not originals**: re-downloads missing blobs, rebuilds missing/corrupt vendored artifacts, and cleans up unused ones | And `setup --remove` reverts the install hooks that `setup` added. -> If you revert a hosted edit by hand instead (e.g. `git checkout -- `), also -> delete `.socket/vendor/redirect-state.json` — its recorded originals are then stale. A -> leftover ledger does not make [`vex`](#vex) attest the removed redirects: a record -> attests only while a lockfile still wires its hosted patch. +> Reverting a hosted edit by hand (e.g. `git checkout -- `) is always safe: +> hosted mode keeps no other state, so there is nothing else to clean up. ## Command reference @@ -478,7 +495,7 @@ And `setup --remove` reverts the install hooks that `setup` added. | [`get`](#get) | Fetch and apply one patch by UUID / CVE / GHSA / PURL / name (alias: `download`) | | [`apply`](#apply) | Apply the patches in `.socket/manifest.json` in place | | [`setup`](#setup) | Wire install hooks (npm, Python, Bundler, Composer) that re-apply patches after install | -| [`rollback`](#rollback) | Undo patches in every mode: restore original files and unwind hosted or vendored lockfile wiring | +| [`rollback`](#rollback) | Undo patches in every mode: restore original files, unwind vendored lockfile wiring, and restore hosted lockfile entries to upstream | | [`remove`](#remove) | Remove one patch by PURL or UUID (rolls back first) | | [`repair`](#repair) | Download missing patch artifacts, rebuild vendored artifacts, clean up unused ones (alias: `gc`) | @@ -589,8 +606,8 @@ Find patches for your dependencies and apply them. `scan` is the entry point for three [patch modes](#three-patch-modes): - **hosted** (the default — a bare `scan`, `scan --json` included) rewrites lockfiles / - registry configs so only the patched dependencies resolve to Socket-hosted packages, - and records the edits in `.socket/vendor/redirect-state.json`; + registry configs so only the patched dependencies resolve to Socket-hosted packages + (the lockfile edits are the only record — no ledger); - `--mode vendored` discovers, downloads, and builds + wires the committable `.socket/vendor/` artifacts in one pass (re-vendoring automatically when a newer patch is selected), writing no `.socket/manifest.json`. It works on a fresh clone: @@ -608,7 +625,7 @@ garbage collection) and prints `To apply these patches in place, run: socket-pat When a package has several patches, `scan` applies the one described in [Which patch is picked](#which-patch-is-picked). The JSON `updates[]` array lists packages whose recorded patch has been superseded — agent manifest entries, vendored -entries, and hosted pins read from the redirect ledger and the lockfiles — and the next +entries, and hosted pins read from the lockfiles — and the next `scan` in that mode takes the newer patch. **Usage:** @@ -696,7 +713,7 @@ socket-patch vex [options] |------|---------|-------------| | `-O, --output ` | `SOCKET_VEX_OUTPUT` | Write the VEX document to this path instead of stdout. Required when combined with `--json`. | | `--product ` | `SOCKET_VEX_PRODUCT` | Override the auto-detected top-level product PURL/identifier. | -| `--no-verify` | `SOCKET_VEX_NO_VERIFY` | Skip the on-disk file-hash check and trust the patch records — useful on a build machine that doesn't have the patched files laid out. The wiring checks still apply: a hosted/vendored ledger record the lockfile no longer wires, or a lockfile reference whose record is unavailable or names another package, is omitted either way. | +| `--no-verify` | `SOCKET_VEX_NO_VERIFY` | Skip the on-disk file-hash check and trust the patch records — useful on a build machine that doesn't have the patched files laid out. The wiring checks still apply: a vendored ledger record the lockfile no longer wires, or a lockfile reference whose record is unavailable or names another package, is omitted either way. | | `--doc-id ` | `SOCKET_VEX_DOC_ID` | Override the document `@id`. Default is a random `urn:uuid:` regenerated each run; pin this for a reproducible identifier. | | `--compact` | `SOCKET_VEX_COMPACT` | Emit compact JSON instead of pretty-printed. | @@ -728,13 +745,22 @@ consuming machine. There are two ways in: -- **`socket-patch scan --mode vendored`** discovers, downloads and vendors in one pass, - writes no `.socket/manifest.json`, and takes over packages a hosted scan redirected - (their hosted lockfile edits are reverted first). This is the way to move a hosted - project offline. - **`socket-patch vendor`** vendors the agent-mode patches listed in - `.socket/manifest.json`. With no manifest (a hosted or `scan --mode vendored` project) - it has nothing to vendor and says so; `vendor --revert` works either way. + `.socket/manifest.json`. With no manifest in a **hosted** project it **ejects**: it + takes the patch set from the lockfiles' hosted pins, fetches each patch from the API, + and vendors it (`Ejecting N hosted packages into .socket/vendor/...`). This is the way + to move a hosted project offline (run it while online; it works from a fresh checkout). + The eject is all-or-nothing: if any patch can't be fetched or vendored, the project is + left hosted exactly as it was. With neither a manifest nor hosted pins (a + `scan --mode vendored` project) it has nothing to vendor and says so; `vendor --revert` + works either way. +- **`socket-patch scan --mode vendored`** discovers, downloads and vendors in one pass, + writes no `.socket/manifest.json`, and takes over packages a hosted scan redirected. + +Taking over a hosted package (either way) first restores its upstream registry entry — +the same restore `rollback` does — so `vendor --revert` later returns it to upstream, +never back to hosted. A package whose entry cannot be restored (offline, `bun.lockb`) +fails with `redirect_revert_failed` and stays hosted. Vendoring is per-patch: only dependencies with a Socket patch are vendored. For the lockfile flavors each ecosystem supports, see the @@ -750,7 +776,7 @@ socket-patch scan --mode vendored [PATHS]... [options] | Flag | Env var | Description | |------|---------|-------------| | `-f, --force` | `SOCKET_FORCE` | Tolerate *missing* patch-target files in the staged copy (skipped instead of failing the vendor) and bypass the variant probe for multi-release ecosystems. A plain before-hash mismatch doesn't need this: vendor staging always overwrites mismatched content with the verified patched bytes (surfaced as a `vendor_content_mismatch_overwritten` warning). | -| `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore the recorded original lockfile fragments byte-for-byte and remove the `.socket/vendor/` artifacts. Works without a manifest. | +| `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore the recorded original lockfile fragments byte-for-byte and remove the `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry. | | `--vex ` | `SOCKET_VEX` | On a successful vendor, also write an OpenVEX 0.2.0 document to this path. | | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder. Inert unless `--vex` is set. | @@ -780,7 +806,8 @@ socket-patch scan --mode vendored # Preview it (would_vendor / would_revendor / already_vendored) socket-patch scan --json --mode vendored --dry-run -# Vendor the agent-mode patches listed in .socket/manifest.json +# Vendor the agent-mode patches listed in .socket/manifest.json, +# or, in a hosted project with no manifest, eject its hosted pins socket-patch vendor # Preview without writing anything @@ -789,7 +816,8 @@ socket-patch vendor --dry-run # Then make it stick: commit .socket/ (vendor artifacts + ledger) and the lockfile git add .socket package-lock.json && git commit -m "vendor Socket patches" -# Undo everything (restores the original lockfile byte-for-byte) +# Undo everything (restores the original lockfile byte-for-byte; ejected hosted +# packages come back as their upstream registry entries) socket-patch vendor --revert # JSON output for scripting @@ -798,9 +826,12 @@ socket-patch vendor --json ### `list` -List the patches in this project: the hosted redirect ledger's records (labeled -`Mode: hosted`), the vendor ledger's (`Mode: vendored`), and any agent-mode entries in -`.socket/manifest.json`. +List the patches in this project: the hosted pins your lockfiles wire (labeled +`Mode: hosted (wired in )` — JSON `details.mode: "hosted"` and +`details.lockfiles`), the vendor ledger's records (`Mode: vendored`), and any agent-mode +entries in `.socket/manifest.json`. A hosted pin shows its UUID only (hosted mode keeps no +local record; `vex` fetches it from the API), unless a pre-v5 +`.socket/vendor/redirect-state.json` still records it. **Usage:** ```bash @@ -825,7 +856,7 @@ Found 1 patch: Package: pkg:npm/flatted@3.3.1 UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 - Mode: hosted (recorded in .socket/vendor/redirect-state.json) + Mode: vendored (recorded in .socket/vendor/state.json) Tier: free License: MIT Exported: Wed, 18 Mar 2026 22:53:26 GMT @@ -1082,13 +1113,28 @@ socket-patch setup --json -y Roll back patches to restore the system to unpatched. If no target is given, everything is rolled back, across all three modes: in-place file restores (agent), vendored unwire + -artifact deletion + ledger-entry drop, and hosted lockfile-redirect unwind + record drop. +artifact deletion + ledger-entry drop, and hosted lockfile entries restored to their +upstream registry entries. The rolled-back entries are then removed from `.socket/manifest.json` (a zero-patch `{"patches": {}}` husk stays) and their blobs are garbage-collected — a later `apply` has nothing to re-apply. Pass `--preserve-state` to keep the local patch state (manifest entries, vendored artifacts + ledger entries) for a later re-apply; use [`remove`](#remove) for a single patch. +**Hosted patches** are read from the lockfiles (hosted mode keeps no ledger). Each one is +rewritten back to the default upstream registry entry, with the tarball URL, integrity or +checksum re-resolved from the public registry — npm, crates.io, the Go module proxy, PyPI, +rubygems.org, packagist, nuget.org (Maven needs no network). Where that is impossible the +package is refused, nothing is written for it, and the run exits 1 with a hint to restore +the file from version control (`git checkout -- `): under `--offline`, when the +registry does not answer, for a binary `bun.lockb`, and for a few lock shapes whose +entries only the package manager can compute (see +[CLI_CONTRACT.md](crates/socket-patch-cli/CLI_CONTRACT.md), "Hosted unwind coverage"). +The registry bases honor the `SOCKET_NPM_REGISTRY`, `SOCKET_CRATES_INDEX`, +`SOCKET_GOPROXY`, `SOCKET_GOSUMDB_URL`, `SOCKET_PYPI_JSON_API`, `SOCKET_RUBYGEMS_URL`, +`SOCKET_PACKAGIST_URL` and `SOCKET_NUGET_URL` overrides for mirrors. A leftover pre-v5 +`.socket/vendor/redirect-state.json` is deleted once no lockfile pins a hosted patch. + A wet run confirms once (auto-accepted under `--yes`/`--json`/non-TTY). Vendor-owned purls the run did NOT act on (a corrupt vendor ledger) are listed in the JSON output's `vendored` array; acted-on entries ride `vendoredReverted` / `vendoredPreserved` / @@ -1106,7 +1152,7 @@ socket-patch rollback [targets]... [options] **Command-specific options** (plus all [Global options](#global-options)): | Flag | Env var | Description | |------|---------|-------------| -| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Unpatch the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — for a later re-apply, and skip GC. Hosted redirects have no preservable state and are unwound either way. | +| `--preserve-state` | `SOCKET_PRESERVE_STATE` | Unpatch the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — for a later re-apply, and skip GC. Hosted patches have no preservable state (the lockfile is their only record) and are restored to upstream either way. | | `--one-off` | `SOCKET_ONE_OFF` | Reserved: rollback by fetching original (`beforeHash`) files from the API, no manifest required. **Not yet implemented** — the command currently errors up front. | **Examples:** @@ -1134,7 +1180,9 @@ Remove a patch from the manifest (rolls back files first by default). If the pac byte-for-byte and the `.socket/vendor/` artifact is deleted — so the patch is fully gone in one command. Patches vendored by `scan --mode vendored` have no manifest entry and are removable by PURL or UUID all the same (reverting the vendoring *is* the removal, so -`--skip-rollback` is refused for them). +`--skip-rollback` is refused for them). A hosted patch is removed the same way: its +lockfile entries are restored to their upstream registry entry, exactly as `rollback` +does it (refused, with the manifest untouched, where that is impossible). **Usage:** ```bash @@ -1218,15 +1266,15 @@ place — without bumping the package version. 1. Gathers every patch the project can prove: agent-mode patches from `.socket/manifest.json`, and [vendored](#vendor) / [hosted](#three-patch-modes) patches - from the wiring in your **lockfiles** (plus the `.socket/vendor` ledgers when they are - committed). See [No manifest needed for hosted and vendored + from the wiring in your **lockfiles** (plus the vendor ledger when it is committed; + hosted records are fetched from the API). See [No manifest needed for hosted and vendored patches](#no-manifest-needed-for-hosted-and-vendored-patches). 2. Unless `--no-verify` is passed, re-checks each patch's bytes so the attestation only covers patches that are actually applied: agent patches against the installed tree, vendored patches against the **committed artifact** (marker `(vendored)`), and hosted patches against the installed copy the build consumes — or, before any install, against the lockfile's integrity pin (marker `(redirected)`). Vendored and hosted patches need - no `setup` install hook to be attested. Whatever `--no-verify` says, a ledger record the + no `setup` install hook to be attested. Whatever `--no-verify` says, a record the lockfile no longer wires is never attested. 3. Auto-detects the top-level **product** identifier (override with `--product`), probing in order: @@ -1284,7 +1332,7 @@ trivy image --vex socket.vex.json ``` Patch first (in any mode). When nothing names a patch anywhere — no manifest -entry, no `.socket/vendor` ledger entry, no hosted or vendored lockfile reference — `vex` +entry, no vendor ledger entry, no hosted or vendored lockfile reference — `vex` errors with `no_patches` (exit 1) when the manifest file exists but is empty, or with `manifest_not_found` (exit 2) when there is no manifest either. When there are patches but none can be attested, it exits 1 with `no_applicable_patches`, and each omission is listed @@ -1292,12 +1340,13 @@ with its reason: `hash_mismatch`, `record_unavailable`, `redirect_unwired`, and ### No manifest needed for hosted and vendored patches -A hosted or vendored checkout needs no `.socket/manifest.json`, and no `.socket/vendor` -ledgers either. This covers a depscan-opened PR, a clone of a repo that never committed its -ledgers, and a fresh hosted `scan`. `vex` reads the patch reference out of each root -lockfile or config: a `patch.socket.dev` URL or a `.socket/vendor///…` path -carries the patch uuid. It then finds that patch's record in the manifest or ledgers, or -fetches it from the patch API. The references it accepts are what socket-patch's own +A hosted or vendored checkout needs no `.socket/manifest.json`, and no vendor ledger +either. This covers a depscan-opened PR, a clone of a repo that never committed its +vendor ledger, and every hosted project (hosted mode keeps no ledger at all). `vex` reads +the patch reference out of each root lockfile or config: a `patch.socket.dev` URL (or one +on your `--patch-server-url`) or a `.socket/vendor///…` path carries the patch +uuid. It then finds that patch's record in the manifest or vendor ledger, or fetches it +from the patch API — the normal path for hosted patches. The references it accepts are what socket-patch's own rewriters write: a URL on any other host, or an entry the package manager would not install from, is ignored. @@ -1310,10 +1359,11 @@ Behavior worth knowing: - **Network.** Without a local record, `vex` fetches the patch by uuid from the patch API. With `--offline`, or when the fetch fails or the patch is paid and not entitled, the patch - is omitted as `record_unavailable`. Commit the ledgers, or keep the manifest, to attest - offline. -- **Liveness.** A ledger entry attests only while a lockfile still wires it. Otherwise it is - omitted as `vendor_unwired` or `redirect_unwired`, even under `--no-verify`. Lockfiles + is omitted as `record_unavailable`. Hosted patches therefore need the network to be + attested; commit the vendor ledger, or keep the manifest, to attest offline. +- **Liveness.** A vendor ledger entry (or a leftover pre-v5 hosted record) attests only + while a lockfile still wires it. Otherwise it is omitted as `vendor_unwired` or + `redirect_unwired`, even under `--no-verify`. Lockfiles that wire one package to different patches (`wiring_conflict`) attest none of them. A package that one lock wires to a patch while another lock resolves it from the registry is not attested either. @@ -1330,13 +1380,13 @@ Behavior worth knowing: | yarn | `yarn.lock` (classic + berry) | Berry vendored entries also need the root `package.json` `resolutions` mapping; member locks are not read | | bun | `bun.lock`, else `bun.lockb` | A hosted entry that Bun < 1.3.10 re-saved without its sha512 attests only after install | | vlt | `vlt-lock.json` (`lockfileVersion` absent, 0 or 1) | A BOM-prefixed or other-version lock wires nothing; a same-version instance on another registry keeps a hosted pin from attesting before install; a vendored directory whose `package.json` patch lost its devDependencies needs the patched blob in `.socket/blobs` without the vendor ledger | -| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config[.toml]` | Root manifest + project config only (no `$CARGO_HOME` / parent configs); vendored `[patch.crates-io]` entries are read from `Cargo.toml` first (v5), the project config for pre-v5 projects, and must agree with the detached lock entry's tagged version `+socket.` (a tag for another uuid — in the lock or in the copy's own `Cargo.toml` — is dead wiring; an untagged detached entry counts only beside an untagged, pre-tag copy); a manifest entry cargo ignores (a same-key project-config item, or a URL-spelled crates.io `[patch]` table) is not attested; a lockless hosted pin needs the redirect ledger's record | +| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config[.toml]` | Root manifest + project config only (no `$CARGO_HOME` / parent configs); vendored `[patch.crates-io]` entries are read from `Cargo.toml` first (v5), the project config for pre-v5 projects, and must agree with the detached lock entry's tagged version `+socket.` (a tag for another uuid — in the lock or in the copy's own `Cargo.toml` — is dead wiring; an untagged detached entry counts only beside an untagged, pre-tag copy); a manifest entry cargo ignores (a same-key project-config item, or a URL-spelled crates.io `[patch]` table) is not attested; a lockless hosted pin (no `Cargo.lock`) names no version and is not attested — nor seen by `list` / `rollback` (only a pre-v5 redirect ledger record keeps it live) | | golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | A replace that `require` no longer selects is inert; `vendor/modules.txt` is not read | | pypi | `uv.lock`, `*.py.lock`, `pylock*.toml`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, `requirements.txt` (+ `-r` includes), `pyproject.toml` / `hatch.toml` | A `uv.lock` beside a `pyproject.toml` must agree with its `[tool.uv.sources]`; PDM 3.1 / 4.0–4.2 locks are refused; a Pipenv project needs `--product` (or a git remote) | -| gem | `Gemfile.lock`, `gems.locked` | Platform gems unsupported; a Gemfile-only (pre-bundler-2.6, not yet locked) wiring needs the redirect ledger | +| gem | `Gemfile.lock`, `gems.locked` | Platform gems unsupported; a Gemfile-only (pre-bundler-2.6, not yet locked) wiring is not attested — nor seen by `list` / `rollback` (only a pre-v5 redirect ledger record keeps it live) | | composer | `composer.lock` | `installed.json` and `COMPOSER=`-renamed locks are not read | | maven | `pom.xml` (+ `.mvn/` checksums) | Root pom only (no parents / submodules, no Gradle); legacy same-GAV hosted repositories cannot be attributed | -| nuget | `nuget.config`, `packages.lock.json` | Hosted needs a `packages.lock.json` entry for the id (with no lock at all, an exclusive exact-id mapping still keeps the redirect ledger's record live); root config only | +| nuget | `nuget.config`, `packages.lock.json` | Hosted needs a `packages.lock.json` entry for the id: with no lock, the mapping names no version and is not attested — nor seen by `list` / `rollback` (only a pre-v5 redirect ledger record keeps it live); root config only | | deno | none | No hosted or vendored mode exists; Deno patches attest only through the manifest (agent mode + `setup.manual`) | The full recognition rules are in @@ -1368,15 +1418,15 @@ Contract: with the command's own `--json` output. JSON mode adds a top-level `vex` summary — `{ path, statements, format }` — to the envelope (`apply`) / result (`scan`). - It's built from the project **as it stands after the run** — the manifest (including - any `--mode agent` writes), the `.socket/vendor` ledgers, and the lockfile wiring — and + any `--mode agent` writes), the vendor ledger, and the lockfile wiring — and verified against on-disk state unless `--vex-no-verify` is set. Generated for real runs and report-only scans alike; `--dry-run` skips it (nothing was changed, so nothing is attested). - `apply --vex` and `vendor --vex` with **no manifest** still attest what the lockfiles and - ledgers wire. A project with nothing wired anywhere keeps the calm exit 0 and writes no + the vendor ledger wire. A project with nothing wired anywhere keeps the calm exit 0 and writes no document. `apply --check` never generates one. - **Fail-the-command:** if `--vex` was requested but generation fails (no detectable - product, nothing attestable, a corrupt ledger, unwritable path), the command exits + product, nothing attestable, a corrupt vendor ledger, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**, with a stable error code in the JSON output. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 55a3983aa..978a22d41 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -9,13 +9,13 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | Name | Visible alias(es) | Notes | |---|---|---| | `scan` | — | Find patches for installed packages and apply them. **v5.0 (MAJOR)**: a bare `scan` runs hosted mode (rewrites lockfiles so only the patched dependencies resolve to Socket-hosted, integrity-pinned packages); `--mode vendored` / `--mode agent` pick the other modes. Never prompts. See [scan modes](#scan-modes-v50) | -| `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the `.socket/vendor` ledgers, and the hosted / vendored patch references the project's lockfiles wire (no manifest required) | +| `vex` | — | Emit an OpenVEX 0.2.0 attestation derived from the local manifest, the vendor ledger, and the hosted / vendored patch references the project's lockfiles wire (no manifest required; hosted records come from the API) | | `vendor` | — | Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles | -| `list` | — | Print patches in the local manifest, plus the vendor ledger's (v5.0) and the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | +| `list` | — | Print patches in the local manifest, plus the vendor ledger's records (v5.0) and the hosted pins the lockfiles wire (labeled; see the `manifest_not_found` row and the action matrix) | | `get` | `download` | Agent mode by default (`--mode` selects hosted/vendored): fetch + apply a patch; requires positional `identifier` | | `apply` | — | Agent mode: apply patches from the local manifest | | `setup` | — | Agent mode: wire automatic-patching install hooks (npm/pypi/gem/composer) | -| `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) | +| `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored lockfile wiring / restore hosted pins to their upstream registry entries, 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) | | `remove` | — | Agent mode: remove a patch from manifest (rolls back first); requires positional `identifier` | | `repair` | `gc` | Agent mode: download missing blobs, rebuild missing/corrupt vendored artifacts, and clean up unused ones (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | @@ -77,7 +77,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `apply` | `--force` / `-f` | `SOCKET_FORCE` | Bypass beforeHash check | | `apply` | `--check` | — | Read-only audit that the committed **Go** `replace`-redirects match the manifest (CI / GitHub-App auditing) — Go ONLY (cargo patches in place, so there is no redirect to audit). Lock-free, crawl-free, offline-safe; exits 0 in sync, 1 on drift. Vendored modules are excluded from the audit | | `vendor` | `--force` / `-f` | `SOCKET_FORCE` | Tolerate missing patch-target files in the stage + bypass the variant probe. A beforeHash mismatch no longer needs it: vendor staging auto-overwrites with the verified patched content (`vendor_content_mismatch_overwritten` warning) | -| `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest | +| `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") | | `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) Meaning depends on the mode. **Hosted / vendored** (bare `scan` included): each PATH, or directory glob (`apps/*`), is a project directory scanned on its own as if it were `--cwd`. **Agent** (and a mode-less `--prune`/`--global` report): path globs scoping DISCOVERY to packages installed under matching paths (`packages/foo`, `apps/**`). See "Path-scoped scans" below | @@ -97,17 +97,17 @@ Beyond the globals above, each subcommand defines a small set of local arguments **pnpm hosted-mode contract**: `scan --mode hosted` handles block and flow resolutions in legacy `shrinkwrap.yaml` and lockfileVersion 5.x, 6.0, and 9.0. The [pinned compatibility matrix](../../docs/testing/pnpm-compatibility.md) samples pnpm majors 1–12. Early shrinkwrapVersion 3 without a positive minor version is refused with `redirect_pnpm_legacy_lockfile_unsupported`: pnpm 1.0.0 discards hosted URLs even on frozen installs. Upgrade to a tested release (1.43.1 or newer) and regenerate the lock, or use agent mode. -Each matching package instance is spliced, including scoped, quoted and nested-peer keys, with one `redirect_pnpm_resolution` revert-ledger edit per changed instance. LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. +Each matching package instance is spliced, including scoped, quoted and nested-peer keys, one `redirect_pnpm_resolution` edit per changed instance (`rollback` / `remove` restore each from the npm registry — see "Hosted unwind coverage"). LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. -For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write is ledger-recorded as `redirect_pnpm_workspace_trust`, respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings, and is disabled by `--no-trust-lockfile-config`. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. +For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings, and is disabled by `--no-trust-lockfile-config`. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. -**npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` and is ledger-recorded as `redirect_npmrc_allow_remote` (`path: ".npmrc"`, `key: "allow-remote"`, `new: "all"`; `action: "created"` for a new file, `"added"` for a spliced line), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind**: the edit is removed exactly once no `redirect_npm_lock_entry` / `redirect_npm_lock_dep` edit remains in the ledger — by the per-purl npm revert of the LAST package-lock entry (scoped `rollback ` / `remove `, the hosted → vendored takeover), by the whole-ledger replay (`rollback` / `remove`, `npm` group — a refused package-lock edit keeps the setting it needs), and by the vendored-supersedes-hosted reconcile. A `created` file still holding exactly `allow-remote=all\n` is deleted; otherwise only the one top-level `allow-remote=all` line is removed, user edits kept (a modified created file warns `redirect_npmrc_allow_remote_modified`, surfaced in the `warnings[]` of `rollback`, `remove`, `vendor` and the vendored reconcile, and as a `Warning (): …` stderr line in human mode); copies under an ini `[section]` are inert to npm and never counted, and a duplicated TOP-LEVEL line refuses fail-closed (ambiguous). A symlinked or non-regular `.npmrc` refuses the unwind while it is still being PLANNED, so the revert writes nothing (never a reverted lock behind a ledger that still records the redirect). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. +**npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` (edit kind `redirect_npmrc_allow_remote`), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind (v5.0: no ledger)**: once `rollback`, `remove` or a hosted → vendored takeover has restored the last hosted entry of the root `package-lock.json` / `npm-shrinkwrap.json` to its upstream registry entry (see "Hosted unwind coverage"), a project `.npmrc` holding exactly `allow-remote=all\n` (the file hosted mode creates) is deleted; any other `.npmrc` that still has a top-level `allow-remote=all` line is left untouched and the `npm_allow_remote_left` warning says the line may be removed if nothing else needs it (v5 keeps no record of whether hosted mode added it, so it is never removed behind the user's back). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. -**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. One `redirect_vlt_lock_node` ledger edit per changed node records the entry text (`"": `, no indent, comma or `\r`). `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. vlt ledgers require the socket-patch release that adds vlt support. Tested releases: `docs/testing/vlt-compatibility.md`. +**vlt hosted-mode contract**: `scan` / `get --mode hosted` rewrite, in `vlt-lock.json`, every default-registry node of a granted `name@version` (the `''` / `npm` segment or a URL segment equal to the lock's scalar `registry`, both DepID grammars, every peer and modifier variant): slot [2] becomes the granted sha512 and slot [3] the hosted URL (appended to a 3-tuple); the DepID, flags and trailing slots, the line ending and every other byte stay. `options` is never edited and `vlt.json` is only read. A lock with another `lockfileVersion` (decided on the raw JSON token), a BOM, a non-object body or a `nodes` section outside vlt's one-node-per-line layout refuses the whole lock (`redirect_vlt_lock_unsupported`). **Confirmation**: vlt drives when its install state (`node_modules/.vlt-lock.json` or `node_modules/.vlt/`) is present or no other npm-family lock is; then only `vlt-lock.json` confirms a uuid. Otherwise every lock is rewritten, `redirect_vlt_sibling_lockfiles` warns, and the other locks' rules confirm, including a dep `vlt-lock.json` merely does not wire (`redirect_vlt_entry_not_found`, `redirect_vlt_entry_vendored`). Whichever lock drives, a dep the vlt rewriter refuses (`redirect_vlt_missing_sha512`, `redirect_vlt_unsupported_lock_key`) is never confirmed by any lock, although a sibling lock may already carry its rewritten URL. **Artifact preflight**: before any takeover or write (dry runs included), each granted artifact with a default-registry instance is fetched once as vlt fetches it and must verify, else the dep is withheld (`redirect_vlt_artifact_unverifiable`, see the tag table). **Heal**: stale installed copies of Socket-owned nodes are removed so the next `vlt install` extracts the patched bytes, and `rollback` / `remove` do the same for the registry bytes (`--no-vlt-install-cleanup` keeps them; optional dependencies' copies are always kept); `redirect_vlt_reinstall_required` says what happened and what to run. The same-run `--vex` never attests a vlt package whose installed copy is stale or unchecked, whose lock a vlt release may ignore (`redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored`, `redirect_vlt_scalar_registry_ignored`), or which also resolves from a non-default registry (`redirect_vlt_custom_registry_skipped`). `vlt.json` or vlt install state without `vlt-lock.json` warns `redirect_vlt_no_lockfile` instead of `redirect_npm_no_lockfile`. `rollback` / `remove` restore each hosted node's slots [2] and [3] from the npm registry, following the lock's own slot-[3] convention (see "Hosted unwind coverage"). Tested releases: `docs/testing/vlt-compatibility.md`. -**Takeover reconciliation (npm family, bun and vlt included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. **golang** takes over the same way: the per-purl revert drops the module's hosted `replace`, removes the socket module's go.sum lines, puts the pruned upstream go.sum lines back in go's sort order, and drops the ledger record, so the vendored `replace` is recorded over pristine go.mod/go.sum (a go.mod whose replace for the module is no longer the recorded one refuses `redirect_revert_failed`). The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. Which way the live lock points is decided by the same lockfile discovery and ledger-liveness rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for this warning, its `redirect_supersedes_vendored` twin and `hosted_wiring_retained` alike. +**Takeover reconciliation (every hosted ecosystem, v5.0)**: vendoring over a hosted pin (`vendor`, `scan --mode vendored`, `get --mode vendored`) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning (vendor_takeover_reverted_redirect): …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. ### Scan modes (v5.0) @@ -115,9 +115,9 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get`, `rollback`, `remove`, `setup` and `--update` keep their prompts. -**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. `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`, and the same liveness rule `vex` applies to a redirect-ledger record (see "Manifest-less VEX (lockfile discovery)"). 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. +**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 project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record'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 join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result), and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. +**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 lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. `scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one) for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. @@ -146,29 +146,29 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips, and the `redirect_yarn_berry_entry` ledger edits record the lock's ON-DISK (CRLF) fragments, which the reverts match byte-exactly. A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`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) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. 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. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). -**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. +**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. -**Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras` preserved and `version`/`index` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the same rules as the gem guard (records by uuid with the ledger fallback, PATCHED = `verify_patch_record` Ok, STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`) — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback**: `redirect_pipenv_entry` edits replay per entry, compared as parsed JSON (a whole-file CRLF/LF conversion or a Pipenv re-serialization that kept our reference and hashes is not drift; the original is spliced back in the live file's line ending); an entry a relock removed retires the edit; a relock (`pipenv lock`, `update`, `install ` before 2024) regenerates the entry to registry shape on every Pipenv major and is NOT drift — the edit retires and the user's fresh resolution stands (vendored twin: `vendor_lock_entry_relocked`); a foreign `file`/`path` reference still refuses the pypi group. A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. +**Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept exactly as Pipenv wrote them (present or absent: whether Pipenv records `index` depends on its release, the Pipfile spelling and the locking environment, so only the entry itself knows) and `version` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the same rules as the gem guard (records by uuid from this run's fetch, PATCHED = `verify_patch_record` Ok, STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`) — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback** (v5.0, upstream restore): each hosted entry gets its registry shape back — `"version": "=="`, the entry's own `index` carried back unchanged (refused unless it — and the Pipfile's explicit `index`, if any — names a PyPI source in `_meta.sources`), and every release file's sha256 from PyPI's JSON API (`SOCKET_PYPI_JSON_API`), sorted by filename as Pipenv records them; an entry that pins another version beside the hosted reference is refused with the `git checkout` remedy (see "Hosted unwind coverage"). A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. -**Mode ledgers (contract surfaces).** Each committable mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write these files, so path + schema are part of the contract: +**Mode ledgers (contract surfaces).** Vendored mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write it, so path + schema are part of the contract. Hosted mode (v5.0) persists nothing but its lockfile / config edits: * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, and the embedded patch `record` — for every entry written by `scan`/`get --mode vendored` beside `detached: true` (the record is that entry's only source), and for standalone `vendor` fed by an agent-mode manifest as a fallback copy without `detached` (the manifest record stays authoritative while the manifest covers the entry, by ledger key or base purl; `vex`, `list` and `setup --check` fall back to the embedded copy when it does not, `repair` only with no manifest at all). Entries written before 5.0 by standalone `vendor` carry no `record`; readers tolerate its absence. **Schema version 2 (v5.0)**: the `new` of a whole-file wiring record (kinds `maven_pom_repository`, `nuget_config_source`, `python_lock_document`, `python_script_metadata`, `hatch_document`) of 1 KiB or more, when its `original` is a string, is stored as an edit of that same record's `original`: `{"snapshot": "", "ops": [[start, len] | "inserted text", …]}` (the text is the ops concatenated in order: a `[start, len]` byte range copied from the `original`, a string inserted as is), and the ledger's `version` is `2`; the `original` stays a plain string, no other record kind is touched, and a ledger without such a record keeps the version-1 bytes. Both versions are read; a version-2 edit is rebuilt and checked against its hash (a mismatch, a missing `original`, an out-of-range copy, or any other `{"snapshot": …}` value is `vendor_state_unreadable`), so every consumer sees the same full texts as with an inline version-1 ledger. Records are self-contained, so an older socket-patch re-saving a version-2 ledger (it keeps `original` / `new` verbatim and drops unknown fields) loses nothing. -* `.socket/vendor/redirect-state.json` — the **hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`): `{ version, mode: "hosted", edits[], records{} }`. `edits` are recorded `FileEdit`s (append-only across re-runs — merge, never clobber: the pre-redirect originals a future revert needs live here; v5.0: a byte-identical re-save is skipped, which still satisfies the rule); `records` maps PURL → the full manifest `PatchRecord`, one of `vex`'s record sources for redirected patches with no manifest entry (a record attests only while a lockfile still wires its hosted patch — see "Manifest-less VEX" below). The `mode` string is opaque to the loader (pre-rename ledgers carrying `"redirect"` still load; a hosted re-run normalizes them to `"hosted"`). Written identically by this CLI and by the depscan backend's hosted PR flow (`github-patch-pr-hosted.ts`). +* `.socket/vendor/redirect-state.json` — the **pre-v5 hosted**-mode ledger (`RedirectState` in `socket-patch-core/src/patch/redirect/state.rs`: `{ version, mode, edits[], records{} }`). **Retired in v5.0**: no command writes it, and `scan` / `get --mode hosted` ignore it. It is read for migration only — `list` and `vex` take a record from it for a hosted pin with the same purl and uuid (the lockfiles still decide what is hosted; its `edits` are never replayed), and a malformed one is only the `redirect_ledger_corrupt` warning there — and `rollback` / `remove` delete it once no lockfile pins a hosted patch any more (a `rollback` in a project whose ONLY state is this file removes it and exits 0, JSON `legacyRedirectLedgerRemoved: true`). A project scanned in hosted mode by v5 commits only its lockfile / config edits. **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — the per-advisory selector for hosted/vendored (`get --save-only` then `vendor` still works). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite (no ledger, v5.0), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids`, golang via `confirmed_golang_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs, no ledger** — the lockfile edits are the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; `--yes`/`--json`/non-TTY auto-accept as usual). This confirm is get's alone: `scan` never prompts. * **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. -**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --vendor` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the hosted redirect ledger claims keeps the loop's refusal (its takeover revert rewrites the lock the gates read), as does every purl when that ledger is malformed; other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor_rerun_no_network_e2e.rs`. +**Lock-text refusals before the download (v5.0)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`, after the Bun preflight above and the ledger's `already vendored` skip: a `pkg:npm/` result in a **pnpm, yarn classic or yarn berry** project, or a `pkg:cargo/` result, that its vendor backend refuses on the project's lock and manifest text alone is refused BEFORE its patch view is fetched — the pnpm / classic / berry gates the backend runs before it reads the package (coordinates, the lock and manifest reads and their line-ending / version / `cacheKey` / `.yarnrc.yml` gates, override and `resolutions` conflicts, the lock entry present and rewritable) and cargo's `locked_version_mismatch` (only when it is the crate's FIRST refusal; an in-tree `cargo vendor` copy still refuses in the loop as `already_vendored_in_tree`). **Scope:** only a package the vendor loop would hand to its backend is refused early — one installed on disk (the loop's own qualified-aware resolver plus the npm identity lookup), or one the lockfile inventory resolves to a verifiable registry source (a lock entry with an integrity, or the ledger-recovered pre-vendor resolution — exactly the entry the pristine fetch would use). A package absent from the lock and not installed never reached its backend and is untouched: its view is fetched, it downloads, and the vendor loop skips it `skipped` / `package_not_installed` as in v4.x (so cargo's `locked_version_mismatch` is refused early only for a crate installed at the unlocked version). The result becomes `{action:"failed", errorCode:, error:}` in `download.patches[]` / `patches[]` with the backend's exact code and detail, no view and no pristine fetch, no patch record, and therefore no vendor event: compared with v4.x, `download.downloaded` drops and `download.failed` rises by the number of such packages, `vendor.summary.failed` and `vendor.events` lose their `failed` events, and a lockfile-only package among them loses its `vendor_fetched_missing` event (it is never fetched). Exit code and top-level `status` are unchanged (`partial_failure`/1); the nested `vendor.status` becomes `success` when those refusals were the vendor step's only failures (observed on the depscan fixture: 3 refusals, `partialFailure` → `success`), and when every selected package is refused this way the human `scan --vendor` arm prints `Nothing was vendored: N patches failed (see above).`. **Precedence:** the lock-text refusal is decided before the view, so it wins over every view-derived outcome — a package that would also have been a paid-access 403 (`[PAID]`/no access), a failed view fetch, or a no-applicable-files skip reports the lock refusal instead (the Bun refusal and the ledger's `already vendored` skip still come first). The human `[error] (): ` line is printed during the download instead of the vendor step's failure line (the human (non-`--silent`) `scan --vendor` arm's baseline pre-check still fetches the views it verifies; only the download, the pristine fetch and the vendor step skip the package there). A purl the lockfiles pin hosted keeps the loop's refusal (its takeover restore rewrites the lock the gates read); other flavors (package-lock, pnpm-legacy, bun) and ecosystems are untouched, and `--dry-run` is unchanged. `vendor` (manifest-driven, no view fetch) keeps its per-package `failed` events but no longer fetches the pristine source of a lockfile-only package it refuses this way — the source is deferred to the backend, which refuses before reading it (no `vendor_fetched_missing` event and no registry request; a refused package whose registry is unreachable reports the gate's code instead of `vendor_fetch_failed`); only a package the lock resolves to a verifiable source is deferred, and one it does not resolve keeps its `package_not_installed` skip. Pinned by `tests/scan_vendor_e2e.rs` (`exact_download_plan`: scan and exact-purl get, pnpm and cargo scope), `tests/e2e_yarn_legacy_cachekey_refusal_build.rs` and `tests/vendor_rerun_no_network_e2e.rs`. * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` 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 N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). * **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-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). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `{status:"success", dryRun:true, found, downloaded:0, skipped, applied:0, patches:[{purl, uuid, action:"would_add"|"would_update"(+oldUuid)|"skipped"}, ], warnings?}`, exit 0. -`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), 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. @@ -183,12 +183,12 @@ The hidden alias `--no-apply` on `get --save-only` is **part of the contract** Contract details: * **Always written to the file** — never stdout — so the document never races the command's own `--json` output. -* **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, nothing to attest in the manifest / ledgers / lockfiles, all patches omitted, a corrupt ledger, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). -* **Built from the post-run state** — the manifest, both `.socket/vendor` ledgers and the project's lockfile references (see "Manifest-less VEX" below) — and verified against on-disk state (unless `--vex-no-verify`; the wiring gates apply either way). Generated for real applies and read-only `scan` alike; `--dry-run` skips generation on every host command (nothing was changed, and a preview must not write an attestation — `scan --json` marks it `vex: {skipped: true, reason: "dry_run"}`). +* **Fail-the-command**: if `--vex` was requested but generation fails (product PURL undetectable, nothing to attest in the manifest / vendor ledger / lockfiles, all patches omitted, a corrupt vendor ledger, unwritable path), the command exits non-zero **even when the apply/scan itself succeeded**. In `--json` mode the failure surfaces in the envelope's `error` (`apply`) / top-level `error` (`scan`), with a stable code (`product_undetected`, `no_applicable_patches`, `write_failed`, …). +* **Built from the post-run state** — the manifest, the `.socket/vendor/state.json` ledger and the project's lockfile references, with hosted records fetched from the API (see "Manifest-less VEX" below) — and verified against on-disk state (unless `--vex-no-verify`; the wiring gates apply either way). Generated for real applies and read-only `scan` alike; `--dry-run` skips generation on every host command (nothing was changed, and a preview must not write an attestation — `scan --json` marks it `vex: {skipped: true, reason: "dry_run"}`). * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. -* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold setup files or vendored state) and `vendor`'s (`No manifest found, nothing to vendor.`) still generate the document from the lockfiles and `.socket/vendor` ledgers (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. +* `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold setup files or vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` or `redirect-state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` / `redirect_ledger_corrupt` (see the error-code table). +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the authenticated API refused the credentials and the public proxy served free patches only; `get` / `scan`'s warning text) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). ### VEX provenance markers (contract) @@ -198,24 +198,24 @@ Every VEX statement's impact string records which patch-application mode persist |---|---|---| | `Patched via Socket patch ` | agent | installed-tree file hashes vs the manifest's `afterHash` | | `Patched via Socket patch (vendored)` | vendored | the committed `.socket/vendor/` artifact (no install hook needed) | -| `Patched via Socket patch (redirected)` | hosted | the lockfile's hosted integrity pin; in-run `scan --mode hosted --vex` attests from the redirect ledger WITHOUT hash verification (the JSON `vex` summary carries `verified: false`), while a post-install `socket-patch vex` re-proves the lockfile wiring and hash-verifies the installed copy the build consumes — or, with nothing installed, attests a discovered lockfile reference from its integrity pin (see "Manifest-less VEX") | +| `Patched via Socket patch (redirected)` | hosted | the lockfile's hosted integrity pin; in-run `scan --mode hosted --vex` attests from the patch records THIS RUN fetched (v5.0: held in memory; hosted mode persists none) WITHOUT hash verification (the JSON `vex` summary carries `verified: false`), while a post-install `socket-patch vex` re-proves the lockfile wiring and hash-verifies the installed copy the build consumes — or, with nothing installed, attests a discovered lockfile reference from its integrity pin (see "Manifest-less VEX") | `vendored` and `redirected` are disjoint in practice (the modes conflict); if a PURL somehow appears in both sets, `vendored` wins. -**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A redirect-ledger record whose recorded wiring names its patch on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is judged by the ledger's own recorded wiring: with verification on it attests only an installed tree that hashes to the record (nothing installed is `package_not_found`, pristine bytes `not_applied`), but `--no-verify` / `--vex-no-verify` trusts the records by definition and attests it `(redirected)`, because the wiring gate cannot tell a staging host from a hostile one. Pass `--patch-server-url` for a non-production patch server, and do not combine `--no-verify` with lockfiles you do not trust. +**Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. (A pre-v5 redirect ledger still on disk can keep such a record in play under the pre-v5 rule: judged by the ledger's own recorded wiring, attesting only an installed tree that hashes to the record unless `--no-verify` / `--vex-no-verify` trusts it; do not combine `--no-verify` with lockfiles you do not trust.) ### Manifest-less VEX (lockfile discovery) -`vex` and every embedded `--vex` attest hosted and vendored patches without `.socket/manifest.json`, and without the `.socket/vendor` ledgers too, by reading the wiring out of the project's lockfiles and package-manager configs. This covers a depscan-opened PR, a clone of a repo that never committed its ledgers, and a `scan --mode hosted` checkout. The merge lives in `commands/vex_sources.rs`; discovery lives in `socket-patch-core/src/vex/discover/`. +`vex` and every embedded `--vex` attest hosted and vendored patches without `.socket/manifest.json`, and without the vendor ledger too, by reading the wiring out of the project's lockfiles and package-manager configs. This covers a depscan-opened PR, a clone of a repo that never committed its vendor ledger, and every `scan --mode hosted` checkout (v5.0 hosted mode keeps no ledger at all: its records come from the API). The merge lives in `commands/vex_sources.rs`; discovery lives in `socket-patch-core/src/vex/discover/`. **Inputs.** Four sources feed one record view: 1. `.socket/manifest.json`. A missing file counts as empty. -2. The redirect ledger's `records`. -3. The vendor ledger entries' embedded `record`s. -4. Lockfile discovery. +2. The vendor ledger entries' embedded `record`s. +3. Lockfile discovery — the only source of hosted references (v5.0). +4. Hosted records: the ones an in-run `scan --mode hosted --vex` fetched this run, and — for migration only — a pre-v5 `.socket/vendor/redirect-state.json`'s `records` (a malformed one is the `redirect_ledger_corrupt` WARNING, v5.0, and is simply not consulted). Anything else is fetched from the API (see **Record resolution**). -Discovery is read-only, never touches the network, and never fails the run: a malformed file becomes a diagnostic. It reads files at `--cwd`, the root where the ledgers are read, and it does so under `--global` / `--global-prefix` as well, because discovery is what gates the ledgers (below). It reads only root files (no nested workspace-member locks) except where noted, and it reads **every** supported file that is present. There is no precedence chain: the hosted rewriter edits every candidate it finds, so a lock that another lock "shadows" can still carry wiring. Every value is committed, tamperable data, so each one is validated fail-closed: canonical uuid grammar, path-safe coordinates, root-anchored `.socket/vendor/` paths, and the patch-host allowlist. +Discovery is read-only, never touches the network, and never fails the run: a malformed file becomes a diagnostic. It reads files at `--cwd`, the root where the vendor ledger is read, and it does so under `--global` / `--global-prefix` as well, because discovery is what gates the ledger entries (below). It reads only root files (no nested workspace-member locks) except where noted, and it reads **every** supported file that is present. There is no precedence chain: the hosted rewriter edits every candidate it finds, so a lock that another lock "shadows" can still carry wiring. Every value is committed, tamperable data, so each one is validated fail-closed: canonical uuid grammar, path-safe coordinates, root-anchored `.socket/vendor/` paths, and the patch-host allowlist. | Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) | |---|---|---|---|---| @@ -235,26 +235,26 @@ Discovery is read-only, never touches the network, and never fails the run: a ma Recognition rules that hold for every ecosystem: -* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a redirect-ledger record on any other host, see **Patch hosts** above. +* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. * **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. -* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own. It still keeps a redirect-ledger record live for a version the pin admits. +* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). -**Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the redirect ledger's `records`, then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. +**Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. **Verification basis.** `(vendored)` and `(redirected)` patches bypass the Property 7 ecosystem filter, because their wiring is the persistence. With no manifest there is no `setup.manual`, and none is needed. | Wiring | Evidence (verify mode) | Marker | |---|---|---| | Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` | -| Hosted: a discovered patch-host reference, or a live redirect-ledger record | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` | +| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` | | Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged | none | **Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates: * A **vendor ledger entry** attests only while some lockfile or config still wires its artifact. Otherwise it is omitted as `vendor_unwired`. The exception is a hosted takeover: the same package with a live redirect record falls through to that hosted claim. -* A **redirect ledger record** attests only while a lockfile still wires its hosted patch. Otherwise it is omitted as `redirect_unwired`. The exception is a manifest-owned purl, which falls back to agent-mode verification. The purls that an in-run `scan --mode hosted --vex` itself confirmed count as live. -* **Discovery is authoritative** for every patch uuid that a file it read *mentions*: the accepted references alone decide. A mention an extractor rejected keeps nothing alive, whatever raw text survives. That covers an orphaned berry entry, an inert Go replace, a reverted cargo pin, a uv lock its `pyproject.toml` does not confirm, a shadowed maven pin, a contested lock, a commented-out line and an unparseable lock. Only for a uuid that no read file mentions (formats no extractor reads, patch hosts outside the allowlist) does the ledger's own recorded wiring decide. Even then, only files that PIN the resolution count, never a leftover registry definition. +* A **hosted record** (this run's, or a pre-v5 ledger's) attests only while a lockfile still wires its hosted patch. Otherwise it is omitted as `redirect_unwired`. The exception is a manifest-owned purl, which falls back to agent-mode verification. The purls that an in-run `scan --mode hosted --vex` itself confirmed count as live. +* **Discovery is authoritative** for every patch uuid that a file it read *mentions*: the accepted references alone decide. A mention an extractor rejected keeps nothing alive, whatever raw text survives. That covers an orphaned berry entry, an inert Go replace, a reverted cargo pin, a uv lock its `pyproject.toml` does not confirm, a shadowed maven pin, a contested lock, a commented-out line and an unparseable lock. Only for a uuid that no read file mentions (formats no extractor reads, patch hosts outside the allowlist) does a ledger's own recorded wiring decide. Even then, only files that PIN the resolution count, never a leftover registry definition. * **Wiring conflict.** When the lockfiles wire one package to two or more different patches, every candidate for that package is omitted as `wiring_conflict`, with a note naming the patches. A reverted lockfile plus a leftover ledger or artifact therefore stops attesting, even under `--no-verify`. @@ -561,6 +561,37 @@ machines with **no socket-patch installed and no Socket API access** (registry a unvendored dependencies may still be needed). Every mechanism below was validated against the real package managers (`spikes/PHASE0-FINDINGS.txt`). +**Eject a hosted project (v5.0)**: standalone `vendor` with NO manifest in a project whose lockfiles +pin hosted patches (not under `--global`; `--ecosystems` narrows the pins) takes its patch set from +those pins — each pin's purl plus the patch uuid in its hosted URL — fetches each record from the +patch API (`GET …/patches/view/`, the same client and public-proxy fallback as `get`), vendors +into `.socket/vendor/` exactly like `scan --mode vendored`, and rewires each package from hosted to +vendored (the upstream registry entry is restored first, so a later `vendor --revert` returns the +project to upstream, never to hosted). The human output opens with +`Ejecting N hosted package(s) into .socket/vendor/...` (`Would eject …` under `--dry-run`); the +JSON is the vendor envelope. It needs no installed `node_modules` / site-packages: the sources are +fetched from the upstream registry, so a fresh hosted checkout ejects. + +The eject is ONE planned transition, all-or-nothing: (1) every record is fetched first — a failed +(or 404) view fetch is a `failed` event with `errorCode: "patch_fetch_failed"` and the run stops +with `status: "error"`, `errorCode: "eject_refused"`, exit 1, nothing touched; (2) the upstream +restore of every pin is resolved against staged copies — a refused pin (offline registry, a +non-derivable field; a binary `bun.lockb` record is rebuilt like the takeover's) is `eject_refused` with the `git checkout -- ` +remedy, nothing touched; (3) `--dry-run` stops here and reports each pin as an `applied` event with +reason `eject_planned` — no file is written and no `.socket/` is created; (4) the wet run snapshots +every file the eject may touch under one `apply.lock`, restores upstream, then vendors. If any +package then fails, the snapshot is put back — the project stays hosted exactly as before, with the +`eject_rolled_back` warning and `partial_failure`, exit 1; if putting the snapshot back itself fails, +the error is `eject_rollback_failed` naming the files to `git checkout`. The eject does not emit the +per-purl `vendor_takeover_reverted_redirect` warning (the restore is its own planned step). +`--offline` (or `SOCKET_OFFLINE`) refuses the eject up front with `offline_eject_unavailable` — +records and registry entries cannot be fetched offline — making zero network requests (dry run +included). A hosted wiring that discovery cannot attribute (a lock mentioning a recognized hosted +uuid it rejected, or a pin with no lockfile) is refused with `hosted_wiring_contested` (exit 1, +nothing touched) rather than ejecting a partial set; `rollback`, `remove` and `list` refuse the same +way (`list` degrades to a warning when it can still list). `--vex` works as on the manifest-driven +path. Without hosted pins the no-manifest no-op below is unchanged. + **Prebuilt vendor artifacts (`--vendor-source`)**: by default (`auto`) `vendor` first tries to DOWNLOAD the already-built patched artifact + integrity from the patch.socket.dev vendoring service, and silently falls back to building it locally on any non-fatal miss. `service` requires the service @@ -676,9 +707,10 @@ kept). Reconstructed entries carry no pre-vendor wiring originals, so a later `- to the documented `vendor_lock_entry_drifted` guidance (re-resolve with the package manager). Because of this phase, `repair` no longer errors with `manifest_not_found` when the project has a vendor ledger or vendor-path lockfile references — it runs the vendored phase alone. A **hosted-only** project -(no manifest, no vendor ledger, no vendored references — only `.socket/vendor/redirect-state.json`) -is a no-op: `repair` exits 0 with a `redirect_only_project` skip pointing at `scan --mode hosted` -(hosted redirects have no local artifacts to repair), rather than the `manifest_not_found` error a +(no manifest, no vendor ledger, no vendored references — only hosted pins in its lockfiles, v5.0, +or a pre-v5 `.socket/vendor/redirect-state.json`) is a no-op: `repair` exits 0 with a +`redirect_only_project` skip pointing at `scan --mode hosted` (hosted pins have no local artifacts +to repair), rather than the `manifest_not_found` error a bare directory still gets. Step 1's source download likewise skips vendored-in-sync manifest entries (their content lives in the committed artifact), so repairing a vendored project never re-litters `.socket/blobs`. `--dry-run` previews @@ -806,7 +838,8 @@ worse, lets a warm cache silently serve unpatched bytes): `already_vendored` skips). Manifest-tracked entries whose patches were dropped from the manifest are auto-reverted at the start of the next `vendor` run (`vendor_reconciled` events); `detached` entries have no manifest record and are exempt. Standalone `vendor` (no flags) is fed - by `.socket/manifest.json` only: with no manifest it is a clean exit-0 no-op whose human line names + by `.socket/manifest.json` — or, with no manifest, by the lockfiles' hosted pins (the eject + above); with neither it is a clean exit-0 no-op whose human line names the missing manifest — `No manifest found, nothing to vendor.`, or, when the vendor ledger holds entries, `No manifest to vendor from; N vendored entr(y is|ies are) tracked in the ledger — `socket-patch repair` verifies (it|them).` — and it never re-vendors from the ledger. This no-op @@ -832,52 +865,20 @@ worse, lets a warm cache silently serve unpatched bytes): 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 manifest-less vendored escape). Unsupported-ecosystem hosted targets fail closed - BEFORE the manifest mutation with top-level `hosted_revert_unsupported` (exit 1; remedy: - unscoped `socket-patch rollback`, or re-run `scan --mode hosted`); a failed unwind or ledger - persist is `hosted_revert_failed` (exit 1, manifest not modified). Successful unwinds ride the - 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 - siblings). Golang especially — apply never repoints a vendor-owned `replace` back at - `.socket/go-patches/` — and `apply --check` excludes vendored modules from its drift audit. -* **scan skips vendored purls before download** (plain `--apply`/`--sync`): the manifest is never - moved past the vendored uuid (that would break VEX verification with `vendor_uuid_mismatch` - until a vendor run). The skip rides `apply.patches[]` as `skipped`/`vendored`; a newer available - patch still surfaces in `updates[]` — the signal to run `scan --vendor`. In `--json` mode the - run additionally carries one top-level `vendored_ownership_retained` warning naming the skipped - purls and the migration path (see "Agent-flow run-level warnings"), so consumers need not dig - into `apply.patches[]` to learn the mode did not change; exit code and status are unaffected. `scan --prune` exempts - vendored purls from the crawl-based manifest prune (an absent installed copy is their NORMAL - state) but reconciles vendored state via the lockfile instead — see the `--prune` section. An - explicit `get` is allowed to move the manifest past the vendored uuid and warns - (`warnings[]` + stderr) that a `vendor` run must refresh the artifact — while - `get … --mode vendored` (v3.6) re-vendors at the new uuid in the same run instead - of warning (the vendor step immediately resolves the drift the warning describes). -* **Old-binary skew caveat**: EVERY `scan`/`get --mode vendored` entry is now detached-shaped, so a - `socket-patch` binary that predates the `detached` flag (pre-4.0) running `vendor` against such a - checkout cannot see the flag and will reconcile-revert every vendored entry; a 4.x binary honors - the flag but drives its own re-vendor from the manifest and finds nothing to do. Pin the CLI - version in CI when mixing generations. The ledger schema itself stays parseable both ways - (additive optional fields). +* **remove restores hosted pins (v5.0)**: an identifier matching hosted pins in the lockfiles + (purl or patch uuid; v5 keeps no hosted ledger) restores each matched pin to its default + upstream registry entry — the same restore as `rollback` (see "Hosted unwind coverage"), for every + ecosystem. A hosted-only match works with no manifest at all (mirroring the manifest-less + vendored escape). A pin the restore refuses (`--offline`, a registry that does not answer, + `bun.lockb`, …) or a failed write is the top-level `hosted_revert_failed` error BEFORE the + manifest mutation (exit 1, manifest not modified; message `could not restore to its + upstream registry entry: … (`git checkout -- `)`). v4's `hosted_revert_unsupported` is no + longer emitted. Successful restores ride the envelope as `removed`/`hosted_reverted` events + (beside a manifest entry they bypass `summary.removed`, like `vendor_reverted`; for a hosted-only + match the restore IS the removal and they count); once no hosted pin is left, a pre-v5 + `redirect-state.json` is deleted. `--skip-rollback` leaves hosted wiring untouched (and is refused + for a hosted-only match); `--preserve-state` still restores — hosted has no preservable local + state (a stderr note says the lockfile pins now resolve upstream). ### Caveats (documented behavior, not bugs) @@ -917,13 +918,13 @@ worse, lets a warm cache silently serve unpatched bytes): ## 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. +> **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. **Hosted leg (v5.0)**: hosted mode keeps no ledger, so the hosted leg restores each hosted pin to its default upstream registry entry (re-resolved from the registry) instead of replaying recorded fragments; a pin that cannot be restored is refused with the `git checkout -- ` remedy. -`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). +`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 three sources (`.socket/manifest.json` = agent/in-place, `.socket/vendor/state.json` = vendored, and the hosted pins lockfile discovery finds in the project's lockfiles = hosted — v5.0 hosted mode keeps no ledger). ### 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): +`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 sources (a hosted pin matches by purl or by the patch uuid in its hosted URL); 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.** @@ -935,29 +936,39 @@ worse, lets a warm cache silently serve unpatched bytes): A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatched and cleans up the local state, in phases under one `apply.lock` acquisition: -1. **State discovery.** A missing manifest is no longer fatal when the vendor or redirect ledger holds work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` project is manifest-less). The **truly-empty** project — all three stores absent — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape). A project whose lockfiles still reference `.socket/vendor/` artifacts but whose vendor ledger is missing errors naming `socket-patch repair` (reconstruct the ledger, then roll back). **Corrupt-ledger containment**: an unreadable vendor ledger fails ONLY the legs that need it — the vendored leg, manifest cleanup, and GC are skipped fail-closed (`vendor_state_unreadable` warning) while the agent leg still restores files; an unreadable redirect ledger skips only the hosted leg (`redirect_state_unreadable` warning; v5.0 distinguishes a ledger that cannot be READ — EACCES, a directory or FIFO squatting on the path — which is reported as such and left in place with a fix-the-permissions remedy, from MALFORMED JSON, which is quarantined to `redirect-state.json.corrupt` with the restore remedy). Either drives `partial_failure` exit 1; an emergency restore is never blocked by an unrelated corrupt ledger. When the ONLY state on disk is an unreadable ledger, the run fails closed naming the store. +1. **State discovery.** A missing manifest is no longer fatal when the vendor ledger or the lockfiles' hosted pins hold work (`rollback` runs manifest-less on hosted-only / vendored projects — every `scan`/`get --mode vendored` and v5 `scan --mode hosted` project is manifest-less). The **truly-empty** project — no manifest, no vendor ledger, no hosted pin — keeps the legacy "Manifest not found" exit 1 (JSON: the legacy `{status: "error", error: "Manifest not found", path}` shape), with one v5.0 exception: when a pre-v5 `.socket/vendor/redirect-state.json` is the only thing left, nothing pins it any more, so a wet run deletes it and exits 0 (human `Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no lockfile pins a hosted patch.`, `Would remove …` on `--dry-run`, which deletes nothing; JSON `{status: "success", rolledBack: 0, alreadyOriginal: 0, failed: 0, dryRun, warnings, legacyRedirectLedgerRemoved}` — a minimal envelope without the keys below; a failed delete is the `legacy_redirect_ledger_kept` warning, still exit 0). 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 and hosted legs still run; it drives `partial_failure` exit 1, and an emergency restore is never blocked by it. When the ONLY state on disk is an unreadable vendor ledger, the run fails closed naming the store. A pre-v5 redirect ledger is never read by rollback (v4's `redirect_state_unreadable` is no longer emitted). 2. **Agent leg** — the existing in-place restore machinery, unchanged (v5.0 presentation: the human `No patches found in manifest` line prints only for an unscoped run with no work in ANY leg — a run whose work is all vendored/hosted stays quiet about the manifest): multi-copy restore, release-variant narrowing, the before-blob gate (+ on-demand download; a gate abort still exits 1 with per-package `missing_blob` failure results **and** skips manifest cleanup + GC entirely — nothing was restored, and the retry's revert data must survive), local-go redirect drop, and the `not_installed` exit-0 asymmetry verbatim. Vendor-owned purls are still excluded here (see the vendored-mode section) — they are handled by the next leg instead of being punted to other commands. 3. **Vendored leg** — each in-scope ledger entry (embedded-record entries included) is reverted through the vendor backends: lockfile wiring restored, artifact dir deleted (and its emptied `.socket/vendor//` husk pruned, v5.0), ledger entry dropped + persisted per purl (crash-consistent, like `vendor --revert`). A **drift-keep** (the backend refused a drifted lock) keeps the entry, the artifact, AND the manifest record (`vendoredKept`, exit 1 — the system is still patched); a failure is recorded and other entries proceed. -4. **Hosted leg** — see "Hosted unwind coverage" below. +4. **Hosted leg** — each in-scope hosted pin is restored to its default upstream registry entry; see "Hosted unwind coverage" below. After a hosted leg with no failure, a wet run deletes a pre-v5 `redirect-state.json` once no lockfile pins a hosted patch any more (a failed delete is the `legacy_redirect_ledger_kept` warning). 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 into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `unwind H hosted redirects` (a hosted ledger with leftover edits but no records gets `replay K leftover hosted redirect edits` instead of the unwind clause; e.g. `Roll back 1 patch, remove it from the local manifest, and unwind 1 hosted redirect?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `restore H hosted packages to the upstream registry` (e.g. `Roll back 1 patch, remove it from the local manifest, and restore 1 hosted package to the upstream registry?`) — 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. +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 pins have no preservable local state**: the lockfile pins are the only record, so a preserve run still restores them to upstream — 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. **Residue rule (v5.0)**: a reversal that empties a ledger deletes the file — `redirect-state.json` and/or `vendor/state.json` — and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. +**Lock discipline**: the manifest and vendor ledger are LOADED under the apply lock (only cheap existence probes and the read-only hosted-pin discovery run before it; the upstream restore re-reads every file it rewrites under the lock), so a concurrent run's writes are never clobbered by a stale pre-lock snapshot. **Residue rule (v5.0)**: a reversal that empties the vendor ledger deletes `vendor/state.json` (and a pre-v5 `redirect-state.json` is retired as above) and prunes the emptied `.socket/vendor//` and `.socket/vendor/` directories (non-recursive, so a pre-v5 `redirect-state.json.corrupt` quarantine or any other stray file keeps its directory alive — the one sanctioned `.socket/vendor/` residue); emptied `blobs/`, `diffs/` and `packages/` stores are removed by the GC sweep; `.socket/` itself is removed by the lock guard when the run leaves it empty, so a fully unwound hosted or vendored project has no `.socket/` at all. What legitimately survives a full reversal: `.socket/manifest.json` at `{"patches": {}}` (+ its `setup` block — never deleted, see the exit-code section), the setup-owned `.socket/.gitignore`, `gem-plugin-stamp` and `bundler-plugin/`, and `.corrupt` quarantine files. ### Hosted unwind coverage -* **Per-purl reverts** exist for **cargo, golang 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 whole-ledger replay (below) whenever it will run — the scope covers every record, and the replay stages the bun group all-or-nothing. A SCOPED unwind (`rollback `, or `remove ` while other hosted records remain) takes the per-purl revert instead: it claims that purl's `redirect_bun_lock_package` edits by the recorded line's spec (`@` registry spec, or a hosted URL whose tarball leaf is `-.tgz`) and replays them like the yarn/pnpm text kinds (whole-line fragments, CRLF-exact); a sibling version's edit is neither claimed nor a refusal; an edit that mentions the package but is not a bun packages-entry line refuses with the unscoped-`rollback` remedy. Pinned by `tests/in_process_vendor_bun_takeover.rs` (`bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl` and the `remove` twin). Native binary `redirect_bun_lockb_package` snapshots follow the same scoped ownership rule and restore only the claimed package records; unrelated binary resolutions stay intact. yarn lock blocks (`redirect_yarn_berry_entry` / `redirect_yarn_classic_entry`) are recorded in the lock's on-disk line endings and replayed byte-exactly; when a `core.autocrlf` checkout has since flipped the lock's UNIFORM ending (LF ↔ CRLF — the committed ledger keeps its fragments verbatim), this per-purl revert and the whole-ledger replay below match the recorded blocks respelled in the live ending and restore in that ending (v5.0). A lock with mixed endings proves nothing and still refuses as drift. vlt `redirect_vlt_lock_node` edits record entry text (`"": `, no indent, comma or `\r`) and revert slot by slot, in the per-purl revert and the whole-ledger replay alike: the line keyed by the recorded DepID gets the recorded slots [2] and [3] back while it keeps the flags, trailing slots, comma and line ending vlt has written since; a line already at the recorded original, or a DepID vlt has re-locked away with no line still carrying the hosted URL, is already reverted; anything else refuses as drift (remedy: restore the registry pin for the DepID by hand, or re-run `socket-patch scan --mode hosted` and roll back). Per-purl claims are by key: `@` or `@~` (peer and modifier variants). -* **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 npm `.npmrc` `allow-remote=all` auto-config (`redirect_npmrc_allow_remote`) replays in the `npm` group (a pristine created file is deleted; otherwise only the line is removed, warned as `redirect_npmrc_allow_remote_modified` for a modified created file) and is ALSO claimed by the per-purl npm revert of the last package-lock entry, so a scoped unwind never strands it. -* **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 edit kinds fail closed (forward compatibility).** A ledger edit kind this release has no inverse for (written by a newer socket-patch) refuses in the replay's reserved `unknown` group with "the redirect ledger holds a {kind} edit this socket-patch release does not understand; upgrade socket-patch", and every record of every ecosystem is held while that group refuses, so no record is dropped beside an edit it may own. The other groups still unwind on disk and drop their edits; only their records wait until the unknown group clears. A per-purl revert (`rollback `, `remove`, the hosted→vendored takeover) refuses with the same text, and with nothing written, when any unknown `redirect_*` edit's `key`, `original` or `new` names the purl's `@` (at a package-name boundary: `left-pad@1.3.0` does not name `pad@1.3.0`, nor does `@scope/a@1.0.0` name `a@1.0.0`). When that scope covers every record, the whole-ledger replay above still runs after the refusal. The vendored flows' takeover reconcile (`vendor_supersedes_redirect`) drops nothing for such a purl and falls back to the manual advisory. vlt ledgers (`redirect_vlt_lock_node` edits, vendored entries with `flavor: "vlt"`) require the socket-patch release that adds vlt support. The ledger `version` stays 1: compatibility is decided per kind. -* **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`. +v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_remaining_redirect_edits`) with ONE mechanism, the **upstream restore** (core `patch/redirect/upstream/`), shared by `rollback`, `remove` and the hosted → vendored takeover: + +* **Scope.** The hosted pins are what lockfile discovery finds — `(purl, patch uuid, files wiring it)`, recognized only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A scoped rollback (paths / identifiers / `--ecosystems`) restores exactly the pins in scope; each pin restores or refuses on its own (there is no whole-ledger replay, and a pre-v5 ledger's edits are never replayed). A pin discovery cannot see is out of reach: a lockless cargo `registry = "socket-patch-"` pin, a nuget exact-id mapping with no `packages.lock.json`, a gem wired only in the `Gemfile` (pre-bundler-2.6 mixed state) — restore those files from version control. +* **What a restore does.** Every file wiring the pin is rewritten back to the DEFAULT UPSTREAM registry entry for `name@version`, re-resolving whatever the entry pins (tarball URL, integrity, checksum, hashes) from the public registry; only the hosted entries change and every other byte stays the file's own. A pin is **all-or-nothing**: refused in one of its files, it is restored in none of them, so no pin is left half hosted. Nothing reaches disk until every pin has resolved, and `--dry-run` resolves exactly like a wet run — registry lookups included — and skips only the write. Per format: + * **npm family** — `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` (classic and berry), `pnpm-lock.yaml` / `shrinkwrap.yaml`, `bun.lock`: resolution + integrity (+ shasum where recorded) from the npm registry's version document (`SOCKET_NPM_REGISTRY`). Side settings: a project `.npmrc` that is exactly `allow-remote=all\n` is deleted once no root npm lock entry is hosted, otherwise a remaining top-level `allow-remote=all` warns `npm_allow_remote_left`; a `pnpm-workspace.yaml` that is exactly the scaffold hosted mode creates is deleted once `pnpm-lock.yaml` is no longer hosted, otherwise a remaining `trustLockfile: true` warns `pnpm_trust_lockfile_left`. **`bun.lockb` (binary)**: `rollback` and `remove` refuse it (the checkout remedy). The hosted → vendored takeover and the eject DO restore it, since the vendor ledger then records the rebuilt record as its pre-vendor original: the native codec turns each hosted remote-tarball record back into Bun's npm registry record for `name@version` (the registry's `dist.tarball` + `dist.integrity`, the package metadata hash re-derived, the hosted URL string dropped from the string pool). The hosted rewrite keeps the registry record's inactive bytes (padding, semver) in the tarball record, so a lock it wrote comes back byte for byte — early writers' uninitialized padding included; a record without them (an older socket-patch or a Bun re-save) is rebuilt the way Bun writes one, and refused for a prerelease/build version. A lock the hosted rewrite had to normalize is marked in the root package's resolution value bytes (which no Bun reader reads): a binary format 1 lock it promoted to format 2 is demoted back to its exact format-1 bytes (verified by promoting it again, otherwise refused), and a lock whose workspace dependency behaviors it normalized is refused with the `git checkout -- bun.lockb` remedy. + * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. + * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-]` block leaves the project cargo config. A declaration it cannot unpin refuses. + * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. + * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; uv 0.2 `[[distribution]]` locks. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). + * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. + * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. + * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). + * **nuget** — `nuget.config` loses the `socket-patch-` source and its exact-id mapping; every `packages.lock.json` entry of the id gets nuget.org's `contentHash` back (`SOCKET_NUGET_URL`). Refused when the restored config would not resolve the id from nuget.org alone. A config hosted mode created from scratch is kept (`nuget_default_config_left`). + * Any other file wiring a pin refuses it (`socket-patch cannot re-derive the upstream entry in `). +* **Refusals.** `--offline` refuses every pin whose restore needs a registry lookup (all but maven), as does a registry that does not answer or no longer describes the entry. A refused pin writes nothing; its message is `cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` — human `Error: Cannot restore …` on stderr (even under `--silent`), JSON `hosted.failed[{purl, error}]`, and `partial_failure` exit 1 (`remove`: the `hosted_revert_failed` error). A write failure after every pin resolved is one `hosted.failed` entry with the pseudo-purl `files`. +* **Output.** Human `Restored to its upstream registry entry` / `Would restore to its upstream registry entry` (`--dry-run`). vlt: the stale installed copies of restored nodes are removed afterwards, as before (`--no-vlt-install-cleanup` keeps them). ### JSON envelope (legacy shape + additive always-present keys) @@ -965,18 +976,18 @@ Restore the system but keep the local patch state for a later re-apply: manifest | Key | Shape | Meaning | |---|---|---| -| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `redirect_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `redirect_pnpm_trust_scaffold_modified`, `redirect_npmrc_allow_remote_modified`, `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), plus vendored/hosted leg advisories. New codes are additive (MINOR) | +| `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`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), plus vendored/hosted leg advisories. New codes are additive (MINOR) | | `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. | | `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 | +| `hosted` | `{reverted: [purl], failed: [{purl, error}], unsupported: [purl], editedFiles: N}` | The hosted leg (v5.0: the upstream restore). `reverted` lists the pins restored (would-be on dry-run); `failed` the refused pins with the version-control remedy in `error` (the pseudo-purl `files` for a write failure); `unsupported` is kept for shape and is always empty (every ecosystem has a restore); `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. +**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, a corrupt vendor ledger, and a failed manifest write. GC failures never affect the exit. ## Self-update contract (`socket-patch --update`) @@ -1119,14 +1130,20 @@ Contract properties: ### Registry override env vars -Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/vendor/registry_fetch.rs`, `src/vendor/maven_repo.rs`). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. +Env-only knobs (no CLI flag) read by the vendor auto-fetch / artifact-rebuild paths in `socket-patch-core` (`src/vendor/registry_fetch.rs`, `src/vendor/maven_repo.rs`) and (v5.0) by the hosted upstream restore of `rollback` / `remove` / the vendored takeover (`src/patch/redirect/upstream/client.rs`, which honors the same bases). Each is the enterprise-mirror / test escape hatch for one registry base; trailing slashes are trimmed and an exported-but-empty value falls back to the default. Lock-recorded URLs (npm/yarn/composer/gem/uv `resolved`/dist URLs) are used verbatim and bypass these. | Env var | Default | Notes | |---|---|---| -| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch + the npm-family lockfile-integrity reconstruction rung in `repair`). | +| `SOCKET_NPM_REGISTRY` | `https://registry.npmjs.org` | Base for conventional npm tarball URLs (vendor auto-fetch + the npm-family lockfile-integrity reconstruction rung in `repair`) and, v5.0, the version documents (`//`, a scoped name's `/` as `%2f`; `dist.tarball` / `integrity` / `shasum`) the npm-family and vlt upstream restore reads. | | `SOCKET_CRATES_REGISTRY` | `https://static.crates.io/crates` | crates.io static `.crate` download host. | | `SOCKET_GOPROXY` | `https://proxy.golang.org` | Go module proxy. Wins over the standard `GOPROXY` env var, whose first element is used otherwise. When that element is `off` or `direct`, or the module matches `GONOPROXY` (default `GOPRIVATE`), go would not ask a proxy, so the pristine fetch is refused (`vendor_fetch_unverifiable` + the calm `package_not_installed` skip) instead of falling back to `proxy.golang.org`. | | `SOCKET_MAVEN_REGISTRY` | `https://repo1.maven.org/maven2` | maven2 base for the fallback upstream-pom download. | +| `SOCKET_CRATES_INDEX` | `https://index.crates.io` | v5.0 upstream restore: the crates.io sparse index whose `checksum` a restored `Cargo.lock` entry gets back. | +| `SOCKET_GOSUMDB_URL` | `https://sum.golang.org` | v5.0 upstream restore: the checksum database the restored go.sum lines are checked against. Without it, `GOSUMDB=off` or a module matching `GONOSUMDB` (default `GOPRIVATE`) skips the database and the hashes come from the module proxy's bytes alone (`SOCKET_GOPROXY` above). | +| `SOCKET_PYPI_JSON_API` | `https://pypi.org/pypi` | PyPI's JSON API (`///json`): the vendored fetch's hash → URL lookup, and (v5.0) the release files whose sha256 the upstream restore writes back into every Python lock format. | +| `SOCKET_RUBYGEMS_URL` | `https://rubygems.org` | v5.0 upstream restore: the compact index (`/info/`) a restored `CHECKSUMS` entry is re-pinned from. | +| `SOCKET_PACKAGIST_URL` | `https://repo.packagist.org` | v5.0 upstream restore: packagist's composer v2 metadata (`/p2//.json`) a restored `composer.lock` `dist` / `source` comes from. | +| `SOCKET_NUGET_URL` | `https://api.nuget.org` | v5.0 upstream restore: the nuget.org API host whose catalog `packageHash` a restored `packages.lock.json` `contentHash` comes from. | ### Internal env vars (no stability guarantee) @@ -1239,21 +1256,21 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `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 vendored patch with no manifest record (every `scan`/`get --mode vendored` entry — and, v5.0, the ledger-only leftover of an earlier `remove --skip-rollback` of a manifest-tracked vendored patch). | -| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): unwinding the redirect is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | +| `hosted_state_retained` | (top-level error) | remove `--skip-rollback` targeting a hosted-only patch (no manifest entry): restoring the pin's upstream registry entry is the only possible removal, so the combination is refused (exit 1), mirroring the manifest-less vendored refusal above. | | `vendor_state_preserved` | `skipped` | remove `--preserve-state` (v5.0): lockfile unwired; artifact, ledger entry, and manifest entry all kept for a later re-apply. Rollback's counterpart is the `vendoredPreserved: []` envelope array. | | `vendor_revert_kept` | `skipped` + top-level error | remove (v5.0): the vendored revert drift-kept (`kept_artifact`), so the ledger entry AND the manifest entry were both kept. ANY drift-keep makes the run a `partialFailure` (exit 1) — part of the requested removal did not happen; when EVERY matching entry drift-kept, the top-level error carries this code (`summary.removed` stays 0; the identifier DID match, so never `not_found`). Remedy: re-run `scan --mode vendored` to normalize, then remove. Rollback's counterpart is the `vendoredKept: []` envelope array (also exit 1). | -| `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile redirect was unwound as part of removing the patch (`verified` on dry-run). Bypasses `summary.removed` like `vendor_reverted`. | -| `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). | +| `hosted_reverted` | `removed` | remove (v5.0): a hosted lockfile pin was restored to its upstream registry entry as part of removing the patch (`verified` on dry-run). Beside a manifest entry it bypasses `summary.removed` like `vendor_reverted`. | +| `hosted_revert_failed` | top-level error | remove (v5.0): a matched hosted pin could not be restored to its upstream registry entry (`--offline`, a registry that does not answer, `bun.lockb`, a lock shape the restore refuses — see "Hosted unwind coverage"), or writing the restored files failed; the message names the `git checkout -- ` remedy. The manifest was not modified, exit 1. Rollback's counterpart is a `hosted.failed[]` entry (also `partial_failure` exit 1). v4's `hosted_revert_unsupported` is no longer emitted (every ecosystem has a restore). | | `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.) | +| `hosted_state_not_preservable` | rollback `warnings[]` | rollback `--preserve-state` (v5.0): hosted pins were restored to upstream anyway — the lockfile pins are hosted mode's only record, so there is no local state to preserve; 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_commit_failed` | top-level error (`vendor`, and the nested vendor envelope of `scan` / `get --mode vendored`) | v5.0 group commit: the run's lockfile / manifest / ledger edits could not be written (the detail names the I/O error). Exit 1; the project's lockfiles and `.socket/vendor/state.json` are left as they were before the run (a partially-applied commit is put back), and the per-package events describe the uncommitted outcome. When putting a partially-applied commit back fails too, the journal is kept instead and the detail says the next socket-patch command in the project finishes the commit. | -| `vendor_state_unreadable` / `redirect_state_unreadable` | rollback `warnings[]`; remove top-level error | corrupt-ledger containment (v5.0). Rollback: an unreadable vendor ledger skips the vendored leg + manifest cleanup + GC; an unreadable redirect ledger skips the hosted leg (quarantine/restore remedy in the detail); either drives `partial_failure` exit 1 while the agent leg still restores files. Remove: `vendor_state_unreadable` is a hard top-level error before any mutation (an unreadable redirect ledger only warns — the identifier may match other stores). Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. | +| `vendor_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 and drives `partial_failure` exit 1 while the agent and hosted legs still run. Remove: a hard top-level error before any mutation. Also the Bun vendored preflight's refusal code: `get` / `scan --mode vendored`, `vendor`'s pre-takeover check and the `--dry-run` `would_refuse` preview report an unreadable `.socket/vendor/state.json` as itself (`errorCode` in `patches[]` / `download.patches[]`, or `get `'s top-level `error.code`), fail-closed — nothing is exempt — instead of a Bun lock code. (v4's `redirect_state_unreadable` is no longer emitted: v5 never reads the redirect ledger on these paths.) | | `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 1. | -| `redirect_pnpm_trust_scaffold_modified` | rollback/remove `warnings[]` | hosted replay (v5.0): the redirect-created `pnpm-workspace.yaml` scaffold was modified since; the file was kept and only the `trustLockfile: true` line removed. | -| `redirect_npmrc_allow_remote_modified` | rollback/remove `warnings[]` (+ human stderr); vendored-supersedes-hosted reconcile `warnings[]` (`vendor`, `scan --mode vendored`); vendor advisory event | hosted unwind (v5.0): the redirect-created project `.npmrc` was modified since; the file was kept and only the `allow-remote=all` line removed. | +| `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently. | +| `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` was removed. | +| `legacy_redirect_ledger_kept` | rollback `warnings[]` (+ remove stderr) | v5.0: a pre-v5 `.socket/vendor/redirect-state.json` could not be deleted once no hosted pin was left; the file is inert (never read for planning). Never flips the exit. | | `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. | @@ -1262,10 +1279,10 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_wiring_unknown_revert_blocked` | `skipped` (beside the `failed`/`revert_failed` event) | vendor --revert: the ledger entry was reconstructed by `repair` without wiring records and the live lockfile still resolves through the artifact — the revert refuses (fail-closed) instead of deleting a tarball the lock points at. Recovery: `socket-patch repair`, then restore the pre-vendor lock (or re-lock without the override) and re-run the revert. repair: an npm ledger entry whose `flavor` this release does not know (written by a newer socket-patch) is skipped, never health-checked or rebuilt, and the artifact, wiring and ledger stay as found (a lone `skipped` event; the run's exit is unaffected). Recovery: upgrade socket-patch. | | `ecosystem_not_setup` | `skipped` | vex: the patch is applied and byte-verified but its ecosystem has no install hook configured and is not declared in the manifest's `setup.manual`, so it is omitted from the document (Property 7). | | `stale_install` | `skipped` | vex (in-run `scan --mode hosted --vex`): a hosted stale-install probe found positively unpatched installed bytes, so the purl is omitted even under `--vex-no-verify` (see the gem / Python stale-install guards). | -| `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | +| `record_unavailable` | `skipped` | vex (manifest-less): a lockfile-wired patch has no local record (manifest, this run's hosted records or a pre-v5 redirect ledger, vendor ledger) and none could be fetched — `--offline`, transport error, 404, or a refused (paid) patch. Omitted, never attested from the `socket-patch.vendor.json` marker. | | `record_mismatch` | `skipped` | vex (manifest-less): the record found for a wired patch names another package or another patch uuid than the wiring. | | `vendor_unwired` | `skipped` | vex: a vendor-ledger entry whose committed artifact no lockfile/config wires any more (reverted lock, leftover ledger or artifact). Applies under `--no-verify` too. | -| `redirect_unwired` | `skipped` | vex: a redirect-ledger record whose hosted patch no lockfile wires any more (and no manifest entry owns the purl). Applies under `--no-verify` too. | +| `redirect_unwired` | `skipped` | vex: a hosted record (this run's, or a pre-v5 redirect ledger's) whose hosted patch no lockfile wires any more (and no manifest entry owns the purl). Applies under `--no-verify` too. | | `wiring_conflict` | `skipped` | vex (manifest-less): the lockfiles wire one package to two or more different patches (e.g. a stale sibling lock); which one the build installs is undecidable, so none is attested. | | `hash_mismatch` / `not_applied` / `file_not_found` / `package_not_found` / `no_files` / `vendor_*` | `skipped` | vex: verification omissions — the installed copy (agent / hosted) or the committed artifact (`vendor_hash_mismatch`, `vendor_artifact_missing`, `vendor_artifact_unreadable`, `vendor_inventory_mismatch`, `vendor_uuid_mismatch`, `vendor_path_unsafe`) does not carry the patched bytes, or nothing is installed. `vendor_manifest_unverifiable`: a vendored vlt directory verified without its vendor ledger (from `vlt-lock.json` alone) holds a `package.json` with its devDependencies stripped, and the patched `package.json` blob is not in `.socket/blobs`, so it cannot be checked. A lockfile-pinned hosted reference with nothing installed attests instead of `package_not_found` (see "Manifest-less VEX"). | | `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. | @@ -1284,10 +1301,17 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `cargo_copy_untaggable` | `failed` (error prefix) | vendor / scan / get `--mode vendored` (cargo, v5.0): the copy's `Cargo.toml` has no literal `[package] version` string that can be rewritten byte-exactly (or it names another version); nothing is swapped in. A dry run over an already-vendored copy reports the same failure; a patch-service crate that cannot be tagged is a miss (`vendor_prebuilt_layout_mismatch`: `auto` builds locally, `service` fails `vendor_prebuilt_required`). | | `cargo_wiring_restored` | `skipped` (advisory note) | repair (v5.0): a vendored crate's Cargo.lock entry was detached with no Socket-owned `[patch]` pointing at its committed copy (a pre-v5 release overwrote its crate-named config key when a second version was vendored); the manifest entry is written back and the ledger updated (dry run: "would restore"). A `vendor` re-run heals the same state as a plain re-vendor. | | `cargo_manifest_unreadable` / `cargo_manifest_unparseable` / `cargo_manifest_symlink_unsupported` / `cargo_manifest_not_workspace_root` / `cargo_manifest_patch_source_alias` | `failed` | vendor / scan / get `--mode vendored` (cargo, v5.0): the workspace-root `Cargo.toml` cannot carry the vendored `[patch.crates-io]` entry (or cargo would ignore it there) — see the cargo caveat under "Vendored mode". Refused before any write. | -| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted-redirected purl (cargo and the npm family, bun included): dry run — the per-purl hosted revert was PROBED and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the hosted lockfile edits were reverted to their pre-redirect registry values and the redirect-ledger record dropped before vendoring (mode takeover). Fires on the run that takes over, not on re-runs. | -| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the per-purl hosted revert refused (drifted lock, missing original fragment, an undecidable ledger edit) — nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`; the detail names the remedy (for bun: an unscoped `socket-patch rollback`). | +| `vendor_would_revert_redirect` / `vendor_takeover_reverted_redirect` | `skipped` (advisory event) | vendor / scan / get `--mode vendored` over a hosted pin (every ecosystem, v5.0): dry run — the upstream restore was resolved (registry lookups included) and would succeed (for bun, only after the Bun vendored preflight accepted the lock; a refused lock is previewed as the wet run's `failed ` instead) / wet run — the pin's lock entries were restored to their upstream registry entry before vendoring (mode takeover; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`), so `vendor --revert` later returns to upstream. Fires on the run that takes over, not on re-runs. | +| `redirect_revert_failed` | `failed` | vendor / scan / get `--mode vendored` (dry and wet): the upstream restore of a hosted pin was refused (`--offline`, a registry that does not answer, a lock shape the restore refuses — for `bun.lockb`, a record the codec cannot rebuild) — detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)`; nothing vendored for the purl, hosted wiring left in place, exit 1 `partial_failure`. | +| `patch_fetch_failed` (eject) | `failed` | vendor eject (v5.0): a hosted pin's patch record could not be fetched from `…/patches/view/`; the whole eject is refused (`eject_refused`), nothing touched, exit 1. | +| `eject_refused` | top-level `errorCode` (`status: "error"`) | vendor eject (v5.0): a record fetch failed or a pin's upstream restore was refused while planning; nothing was changed, exit 1. | +| `eject_planned` | `applied` (reason) | vendor eject `--dry-run` (v5.0): the pin would be restored upstream and vendored; nothing written. | +| `eject_rolled_back` | warning | vendor eject (v5.0): a package failed after the restore began; every touched file was put back from the pre-eject snapshot, so the project is still hosted; `partial_failure`, exit 1. | +| `eject_rollback_failed` | top-level `errorCode` | vendor eject (v5.0): putting the pre-eject snapshot back failed; the detail names the files to `git checkout --`; exit 1. | +| `offline_eject_unavailable` | top-level `errorCode` | vendor eject under `--offline` / `SOCKET_OFFLINE` (v5.0): records and registry entries cannot be fetched offline; zero network requests, nothing touched, exit 1. | +| `hosted_wiring_contested` | top-level `errorCode` (list: warning when it can still list) | rollback / remove / vendor eject / list (v5.0): a lockfile mentions a recognized hosted patch uuid that discovery rejected (or a pin with no lockfile), so the hosted set is not known exactly; refused with nothing touched, exit 1. Remedy: fix or `git checkout` the named lockfile. | | `vendor_yarn_berry_cache_unsupported` | `failed` | vendor (yarn berry): lock `cacheKey ≠ 10c0` or non-default `.yarnrc.yml` `compressionLevel` — the cache-zip checksum is not reproducible. | -| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE reverting the hosted redirect (dry run too), so a refused purl stays hosted. | +| `vendor_yarn_berry_mixed_line_endings` | `failed` | vendor (yarn berry): `yarn.lock` or the root `package.json` mixes CRLF and LF line endings (or holds a bare CR) — no single ending can be kept, and yarn rewrites such a file wholesale on its next install (a mixed lock also fails `--immutable`, YN0028). Refused before any write; `yarn install` normalizes the files. A uniformly CRLF pair is vendored in CRLF. A hosted→vendored takeover (`vendor`, `scan`/`get --mode vendored`) raises this — and the berry `vendor_yarn_berry_cache_unsupported` gates — BEFORE restoring the hosted pin's upstream entry (dry run too), so a refused purl stays hosted. | | `vendor_override_conflict` | `failed` | vendor (pnpm/yarn-berry): a user-authored override/resolution for the package already exists. | | `vendor_integrity_unverified` | `skipped` (warning) | vendor (pipenv): the lockfile format does not hash-check file entries; the committed wheel bytes are the protection. | | `vendor_content_mismatch_overwritten` | `skipped` (warning) | vendor: a staged file matched NEITHER beforeHash nor afterHash (patch built against different bytes, or local edits); the stage was overwritten with the verified patched content and the vendor succeeded. | @@ -1351,11 +1375,12 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | list, remove, repair, rollback, vex | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no redirect-ledger record, no vendor-ledger entry, no lockfile reference — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | +| `manifest_not_found` | list, remove, repair, rollback, vex | `.socket/manifest.json` doesn't exist. For `vex` (and `scan --vex`) it fires only when, in addition, NOTHING else names a patch — no vendor-ledger entry, no lockfile reference (hosted or vendored) — and the message says so (exit 2 standalone; `apply`/`vendor --vex` treat it as their calm no-op). 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 project's only patch state is hosted pins in its lockfiles (v5.0; or a pre-v5 `redirect-state.json`). `list` likewise no longer fires this on a hosted-only project: v5.0 lists every hosted pin the lockfiles wire (exit 0, labeled `details.mode: "hosted"` + `details.lockfiles: []` — no `details.ledger`, since hosted mode keeps none; when the manifest exists too, both are shown, purl-sorted with the manifest entry first on a tie). A pin carries its uuid and empty details unless a pre-v5 redirect ledger records the same purl and uuid (read for migration only: its record supplies the vulnerabilities / tier / description); a pre-v5 ledger record whose pin is in no lockfile is not listed. v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (wired in )` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. A standalone-`vendor` entry's fallback `record` lists the same way once no manifest entry covers it (by ledger key or base purl) — the copy manifest-less `vex` attests from, so `list` never reports `manifest_not_found` for a tree whose VEX document attests a patch; while the manifest covers it, only the manifest entry is listed. All sources always come from the SAME project: the vendor ledger and the lockfiles are 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 state, never the local one. The error still fires when NONE of the three sources has a patch, and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless (corruption is never masked). A malformed pre-v5 redirect ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (the pins still list); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the lockfiles' hosted pins hold work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — no manifest, no vendor ledger, no hosted pin (a lone pre-v5 redirect ledger is deleted, exit 0) — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor ledger file exists or the lockfiles pin a hosted patch (an existence probe and the read-only hosted-pin discovery before the lock; the vendor ledger loads under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match restores its upstream registry entry, and when that state exists but holds nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three sources are empty. Manifest entries are removed in sorted purl order. | | `manifest_invalid` | list, remove | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove, vex | I/O error reading manifest (vex: also an unparseable manifest; exit 2). | -| `no_patches` | vex | The manifest file exists but is empty AND no ledger record or lockfile reference names a patch (exit 1). | -| `redirect_ledger_corrupt` / `vendor_ledger_corrupt` | vex (every form) | `.socket/vendor/redirect-state.json` / `.socket/vendor/state.json` exists but is malformed or unreadable. Both ledgers are attestation inputs (records and liveness), so attesting from a partial view is refused (exit 2 standalone; the host command fails). A missing ledger is simply empty. | +| `no_patches` | vex | The manifest file exists but is empty AND no vendor-ledger record or lockfile reference names a patch (exit 1). | +| `vendor_ledger_corrupt` | vex (every form) | `.socket/vendor/state.json` exists but is malformed or unreadable. The vendor ledger is an attestation input (records and liveness), so attesting from a partial view is refused (exit 2 standalone; the host command fails). A missing ledger is simply empty. | +| `redirect_ledger_corrupt` | vex, list (`warnings[]`) | v5.0: a WARNING, no longer an error — a pre-v5 `.socket/vendor/redirect-state.json` exists but is malformed or unreadable. v5 hosted mode keeps no ledger (hosted references come from the lockfiles, their records from the API), so the file is only an optional migration record source: its records are not consulted and the run continues. Delete the file or restore it from version control. | | `serialize_failed` | vex | The built document could not be serialized (exit 2). | | `apply_failed` | apply | apply pipeline error before any patch ran. | | `repair_failed` | repair | repair pipeline error. | @@ -1367,7 +1392,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified |--------------|---| | `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | -| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | +| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted pins (v5.0: one per `(purl, uuid)` the lockfiles wire) additionally carry `details.mode: "hosted"` and `details.lockfiles: []` (no `details.ledger` — hosted mode keeps no ledger; the human listing labels them `Mode: hosted (wired in )`), both additive and absent on manifest entries; v5.0: vendor-ledger records carry `details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` the same way, and the human listing labels them `Mode: vendored (recorded in .socket/vendor/state.json)`; a `state.json` that cannot be read or parsed degrades to nothing-to-consult with the stderr line `Warning: unreadable vendor ledger (); its vendored patches are not listed` — muted by `--silent`, exit unchanged) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | @@ -1606,7 +1631,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | | `2` | Usage error: clap parse failures (unknown flag/value, missing required arg — including the clap-enforced `setup --check --remove` conflict) and the conflicts the commands enforce themselves — `scan`'s cross-mode conflicts (`--mode` combined with a DIFFERENT mode's boolean spelling, rejected in `resolve_mode_flags`), `--detached` without vendored mode and `--mode hosted` with `--global`/`--global-prefix` (same enforcement point); in hosted/vendored `scan` (bare `scan` included), a PATH that is not a directory, a PATH glob matching no directory, and `--json` with more than one project directory (`run_project_dirs`); `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 (a manifest-less project whose vendor or redirect ledger holds records is NOT "missing": `list` reads all three stores and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. +`list` returns **`0`** for an empty manifest and **`1`** for a missing manifest — these are distinct and load-bearing (a manifest-less project whose vendor ledger holds records or whose lockfiles pin hosted patches is NOT "missing": `list` reads all three sources and exits 0 — see the `manifest_not_found` row). Every lock-taking subcommand — including `scan`/`get --mode hosted` as of v5.0 — returns **`1`** with `errorCode: lock_held` when another live socket-patch process holds `<.socket>/apply.lock`. `vex` exit codes are tri-state: @@ -1614,7 +1639,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | A non-empty OpenVEX document was produced | | `1` | Nothing attested: `no_applicable_patches` (every candidate was omitted — by verification, a wiring gate, a missing record, or Property 7; the omissions ride `skipped` events) or `no_patches` (an empty manifest file and nothing wired anywhere) | -| `2` | Hard error: `manifest_not_found` (no manifest AND no ledger record / lockfile reference anywhere), `manifest_unreadable`, `redirect_ledger_corrupt`, `vendor_ledger_corrupt`, `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | +| `2` | Hard error: `manifest_not_found` (no manifest AND no vendor-ledger record / lockfile reference anywhere), `manifest_unreadable`, `vendor_ledger_corrupt` (v5.0: `redirect_ledger_corrupt` is a warning), `json_requires_output`, `product_undetected`, `serialize_failed`, `write_failed` | A missing manifest alone is not an error: a hosted or vendored checkout attests from its lockfiles (see "Manifest-less VEX"). Embedded `--vex` maps every failure to the host command's exit `1`. diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 29a09df73..14ea18e87 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -427,7 +427,7 @@ impl GlobalArgs { } /// The project root whose `.socket/` state stores — manifest, vendor - /// ledger, redirect ledger — belong together: the RESOLVED manifest's + /// ledger — belong together: the RESOLVED manifest's /// directory, stepping out of a standard `.socket/` layout when the /// manifest lives in one. For the default `/.socket/manifest.json` /// this is exactly `cwd`; for a `--manifest-path` into another project diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 76fe2a8bc..16b7063cc 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -541,8 +541,8 @@ pub struct GetArgs { /// [default: hosted; agent with `--save-only` or `--global`] // agent = record in .socket/manifest.json + blobs and apply in place; // hosted = rewrite lockfiles so the patched deps resolve to Socket's - // hosted patch server (no manifest, no blobs; state lives in the - // redirect ledger); vendored = commit patched artifacts under + // hosted patch server (no manifest, no blobs, no ledger: the lockfile + // is the record); vendored = commit patched artifacts under // .socket/vendor/ and rewire the lockfile (no manifest, no blobs; the // vendor ledger carries the records). Hosted/vendored runs produce the // same on-disk result as `scan --mode hosted|vendored` selecting the @@ -1222,6 +1222,9 @@ pub struct DownloadParams { /// `false`: their patch content is staged in memory and the committed /// artifact is the patch — nothing should land in `.socket/blobs`. pub persist_blobs: bool, + /// `--patch-server-url`: the extra origin whose URLs count as hosted + /// when lockfile discovery reads the project's hosted pins. + pub patch_server_url: Option, } impl DownloadParams { @@ -1851,10 +1854,9 @@ type LockRefusals = HashMap; /// classic / yarn berry gates and cargo's locked-version gate), over the /// patches the phase would otherwise fetch a view for — past the Bun /// refusal and the ledger's idempotency skip, which take precedence in the -/// fetch loop. A purl the hosted redirect ledger claims is left to the -/// vendor loop: its takeover reverts the hosted lock edits first, and the -/// revert rewrites the very text the gates read. A redirect ledger that -/// cannot be read leaves every purl to the loop. +/// fetch loop. A purl the lockfiles pin hosted is left to the vendor loop: +/// its takeover restores the upstream lock entry first, and the restore +/// rewrites the very text the gates read. /// /// Only a package the vendor loop would hand to its backend is refused /// here (see [`crate::commands::vendor::lock_refusals_reaching_backend`]): @@ -1872,11 +1874,23 @@ async fn lock_text_refusals_for( ) -> LockRefusals { let cwd = params.cwd.as_path(); let claimed: Vec = - match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { - Ok(Some(state)) => state.records.keys().map(|k| canonical_purl(k)).collect(), - Ok(None) => Vec::new(), - Err(_) => return HashMap::new(), - }; + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &socket_patch_core::vex::discover_patched_refs_with( + cwd, + &socket_patch_core::vex::DiscoverOptions { + patch_server_origins: params + .patch_server_url + .iter() + .filter(|url| !url.trim().is_empty()) + .cloned() + .collect(), + }, + ) + .await, + ) + .into_iter() + .map(|pin| canonical_purl(&pin.purl)) + .collect(); let candidates: Vec<(&str, &str)> = selected .iter() .filter(|sr| bun_refusal.filter(|r| r.applies_to(&sr.purl)).is_none()) @@ -3663,12 +3677,13 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> strict: args.common.strict, ecosystems: args.common.ecosystems.clone(), persist_blobs, + patch_server_url: args.common.patch_server_url.clone(), } } /// `get … --mode hosted`: hand the selected (purl, uuid) pairs to scan's /// hosted engine ([`super::scan::boxed_run_redirect_selected`]) — lockfile -/// rewrite + redirect ledger, no manifest, no blobs — so the on-disk result +/// rewrite only, no manifest, no blobs, no ledger — so the on-disk result /// matches `scan --mode hosted` selecting the same patches. The engine owns /// all output (and honors `--dry-run` internally); in JSON mode it nests its /// `redirect` block into the get base envelope passed as `scan_result`. @@ -5223,6 +5238,7 @@ mod tests { strict: false, ecosystems: None, persist_blobs: false, + patch_server_url: None, } } @@ -5891,6 +5907,7 @@ mod tests { ecosystems: None, // The vendor-detached posture this fn exists for. persist_blobs: false, + patch_server_url: None, } } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 6bbc5e76b..18956a83b 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -3,7 +3,8 @@ use std::path::Path; use clap::Args; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; +use socket_patch_core::patch::redirect::upstream::HostedPin; +use socket_patch_core::patch::redirect::RedirectState; use socket_patch_core::telemetry::track_patch_listed; use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; @@ -26,9 +27,9 @@ pub struct ListArgs { enum Source { /// A `.socket/manifest.json` entry (agent mode). Manifest, - /// A hosted redirect-ledger record: `scan --mode hosted` records its - /// patches ONLY in `.socket/vendor/redirect-state.json` and never - /// writes the manifest. + /// A hosted pin the lockfiles wire: `scan --mode hosted` keeps no + /// ledger and never writes the manifest, so the lockfiles are the only + /// record of a hosted patch. Hosted, /// A vendor-ledger record: vendored mode is manifest-free, so every /// `scan`/`get --mode vendored` patch lives ONLY in @@ -39,14 +40,14 @@ enum Source { Vendored, } -/// The `(mode, ledger)` label pair for a ledger-sourced record — the shared -/// constant labels, never a ledger's own opaque `mode` string (see -/// `HOSTED_MODE_LABEL`'s docs) — or `None` for a manifest entry. Shared by -/// the JSON `details` and the human `Mode:` line. +/// The `(mode, ledger)` label pair for a vendor-ledger record — the shared +/// constant label, never a ledger's own opaque `mode` string (see +/// `HOSTED_MODE_LABEL`'s docs) — or `None` for a manifest entry or a hosted +/// pin (which has no ledger; see [`ListEntry::lockfiles`]). Shared by the +/// JSON `details` and the human `Mode:` line. fn ledger_label(source: Source) -> Option<(&'static str, &'static str)> { match source { - Source::Manifest => None, - Source::Hosted => Some((crate::commands::HOSTED_MODE_LABEL, REDIRECT_STATE_REL)), + Source::Manifest | Source::Hosted => None, Source::Vendored => Some((crate::commands::VENDORED_MODE_LABEL, VENDOR_STATE_REL)), } } @@ -56,12 +57,62 @@ struct ListEntry<'a> { purl: &'a str, record: &'a PatchRecord, source: Source, + /// The lockfiles wiring a hosted pin (empty for the other sources). + lockfiles: &'a [String], +} + +/// A hosted pin as `list` shows it: the lockfiles wiring it, and its +/// record — from a pre-v5 redirect ledger when one still describes this +/// exact pin (read for migration only), else just the uuid (the details +/// live on the API; `vex` fetches them). +pub(crate) struct HostedListing { + pub purl: String, + pub record: PatchRecord, + pub lockfiles: Vec, +} + +impl HostedListing { + /// One listing per hosted pin in `pins`, detailed from `legacy` where + /// it records the same purl and uuid. + pub(crate) fn from_pins(pins: &[HostedPin], legacy: Option<&RedirectState>) -> Vec { + let canon = |p: &str| { + socket_patch_core::utils::purl::normalize_purl( + socket_patch_core::utils::purl::strip_purl_qualifiers(p), + ) + .into_owned() + }; + pins.iter() + .map(|pin| { + let record = legacy + .and_then(|l| { + l.records + .iter() + .find(|(k, r)| canon(k) == canon(&pin.purl) && r.uuid == pin.uuid) + .map(|(_, r)| r.clone()) + }) + .unwrap_or_else(|| PatchRecord { + uuid: pin.uuid.clone(), + exported_at: String::new(), + files: Default::default(), + vulnerabilities: Default::default(), + description: String::new(), + license: String::new(), + tier: String::new(), + }); + HostedListing { + purl: pin.purl.clone(), + record, + lockfiles: pin.files.clone(), + } + }) + .collect() + } } /// Every listable record from all three stores, in a stable order: by /// PURL, then manifest < hosted < vendored when one purl appears in more /// than one. The record maps (`HashMap` manifest and vendor ledger / -/// `BTreeMap` redirect ledger) never impose an order shared consumers could +/// hosted pins) never impose an order shared consumers could /// diff, so the sort here is the contract. Only vendor entries whose /// embedded record stands on its own fold in /// ([`crate::commands::vendor_record_is_unowned`], the rule `vex` attests @@ -72,7 +123,7 @@ struct ListEntry<'a> { /// purl. A legacy entry with no embedded record never folds in. fn combined_entries<'a>( manifest: Option<&'a PatchManifest>, - redirect: Option<&'a RedirectState>, + hosted: &'a [HostedListing], vendor: Option<&'a std::collections::HashMap>, ) -> Vec> { let mut entries: Vec> = Vec::new(); @@ -81,15 +132,15 @@ fn combined_entries<'a>( purl, record, source: Source::Manifest, + lockfiles: &[], })); } - if let Some(redirect) = redirect { - entries.extend(redirect.records.iter().map(|(purl, record)| ListEntry { - purl, - record, - source: Source::Hosted, - })); - } + entries.extend(hosted.iter().map(|h| ListEntry { + purl: &h.purl, + record: &h.record, + source: Source::Hosted, + lockfiles: &h.lockfiles, + })); if let Some(vendor) = vendor { entries.extend(vendor.iter().filter_map(|(purl, entry)| { let record = entry @@ -100,6 +151,7 @@ fn combined_entries<'a>( purl, record, source: Source::Vendored, + lockfiles: &[], }) })); } @@ -162,6 +214,10 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { details["mode"] = serde_json::json!(mode); details["ledger"] = serde_json::json!(ledger); } + if entry.source == Source::Hosted { + details["mode"] = serde_json::json!(crate::commands::HOSTED_MODE_LABEL); + details["lockfiles"] = serde_json::json!(entry.lockfiles); + } env.record( PatchEvent::new(PatchAction::Discovered, entry.purl.to_string()) @@ -248,6 +304,13 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { // Same labeling rule as the JSON details. lines.push(format!(" Mode: {mode} (recorded in {ledger})")); } + if entry.source == Source::Hosted { + lines.push(format!( + " Mode: {} (wired in {})", + crate::commands::HOSTED_MODE_LABEL, + sanitize(&entry.lockfiles.join(", ")) + )); + } lines.extend(field(" ", "Tier", &patch.tier)); lines.extend(field(" ", "License", &patch.license)); lines.extend(field(" ", "Exported", &patch.exported_at)); @@ -334,22 +397,20 @@ pub async fn run(args: ListArgs) -> i32 { } }; - // Hosted-mode patches live ONLY in the redirect ledger and vendored-mode - // patches ONLY in the vendor ledger, so `list` consults both alongside - // the manifest — leniently (a malformed ledger degrades to "nothing to - // consult", surfaced on stderr unless --silent; the write paths - // hard-error on it instead), and always from the SAME project as the - // manifest (`project_root` steps out of the manifest's `.socket/`): - // with `--manifest-path` pointing at another project, reading the LOCAL - // cwd's ledgers would interleave two projects' patch state (and a local - // ledger could suppress the flagged project's manifest_not_found). + // Hosted-mode patches live ONLY in the lockfiles (v5 keeps no hosted + // ledger) and vendored-mode patches ONLY in the vendor ledger, so + // `list` consults both alongside the manifest — always from the SAME + // project as the manifest (`project_root` steps out of the manifest's + // `.socket/`): with `--manifest-path` pointing at another project, + // reading the LOCAL cwd's state would interleave two projects' patches. // - // Under --json a corrupt redirect ledger rides the envelope's - // `warnings[]` (stdout is the machine channel; a stderr-only warning - // would vanish for JSON consumers), the same split `update` uses. + // A pre-v5 redirect ledger is read (never written) only to detail the + // hosted pins it still describes; a malformed one degrades to "nothing + // to consult", surfaced on stderr unless --silent, or in the envelope's + // `warnings[]` under --json. let project_root = args.common.project_root(); let mut warnings: Vec = Vec::new(); - let redirect_state = + let legacy_redirect = match socket_patch_core::patch::redirect::load_redirect_state(&project_root).await { Ok(state) => state, Err(corrupt) => { @@ -364,19 +425,37 @@ pub async fn run(args: ListArgs) -> i32 { None } }; + let inventory = crate::commands::hosted_inventory(&args.common, &project_root).await; + let hosted = HostedListing::from_pins(&inventory.pins, legacy_redirect.as_ref()); + // Contested hosted wiring cannot be listed as patches, but it is hosted + // state: surface it (stderr / `warnings[]`), never hide it. + let contested = inventory.contested_refusal(); + if let Some(detail) = &contested { + if args.common.json { + warnings.push(RunWarning { + code: "hosted_wiring_contested".to_string(), + detail: detail.clone(), + }); + } else if !args.common.silent { + eprintln!("Warning (hosted_wiring_contested): {detail}"); + } + } let vendor_state = crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; - // `combined_entries` folds only ledger RECORDS in (an edits-only - // redirect ledger — post-takeover residue / a degraded record-fetch- - // failed run — and a record-less legacy vendor entry assert no - // patches), so entry emptiness is the whole exit predicate. + // `combined_entries` folds only real records in (a record-less legacy + // vendor entry asserts no patch), so entry emptiness is the whole exit + // predicate. let entries = combined_entries( manifest.as_ref(), - redirect_state.as_ref(), + &hosted, vendor_state.as_ref().map(|s| &s.entries), ); if manifest.is_none() && entries.is_empty() { + if let Some(detail) = contested { + emit_error(&args, "hosted_wiring_contested", detail, warnings); + return 1; + } // No manifest AND no ledger records: nothing is listable anywhere. emit_error( &args, @@ -422,11 +501,10 @@ mod tests { use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; - /// Envelope for a manifest-only listing (no redirect ledger) — the shape - /// most tests below need; the hosted tests call `combined_entries` - /// directly with a `RedirectState`. + /// Envelope for a manifest-only listing (no hosted pins, no vendor + /// ledger) — the shape most tests below need. fn manifest_envelope(manifest: &PatchManifest) -> Envelope { - build_list_envelope(&combined_entries(Some(manifest), None, None)) + build_list_envelope(&combined_entries(Some(manifest), &[], None)) } fn sample_manifest() -> PatchManifest { @@ -632,25 +710,30 @@ mod tests { assert_eq!(paths, vec!["z/a.js", "z/b.js"]); } - /// Hosted redirect-ledger records fold into the envelope labeled apart - /// from manifest entries: `details.mode` / `details.ledger` ride the - /// hosted events ONLY (additive keys), and the global purl sort holds - /// with the manifest entry first when one purl appears in both stores. + fn hosted(purl: &str, record: PatchRecord) -> HostedListing { + HostedListing { + purl: purl.to_string(), + record, + lockfiles: vec!["package-lock.json".to_string()], + } + } + + /// Hosted pins fold into the envelope labeled apart from manifest + /// entries: `details.mode` / `details.lockfiles` ride the hosted events + /// ONLY (additive keys), and the global purl sort holds with the + /// manifest entry first when one purl appears in both stores. #[test] - fn hosted_ledger_records_are_labeled_and_interleaved() { + fn hosted_pins_are_labeled_and_interleaved() { let manifest = sample_manifest(); - let mut redirect = RedirectState::new(); let mut hosted_record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); hosted_record.uuid = "22222222-2222-4222-8222-222222222222".to_string(); // Same purl as the manifest entry (coexistence) + a distinct one. - redirect - .records - .insert("pkg:npm/minimist@1.2.2".to_string(), hosted_record.clone()); - redirect - .records - .insert("pkg:npm/aaa-hosted@1.0.0".to_string(), hosted_record); + let pins = vec![ + hosted("pkg:npm/minimist@1.2.2", hosted_record.clone()), + hosted("pkg:npm/aaa-hosted@1.0.0", hosted_record), + ]; - let env = build_list_envelope(&combined_entries(Some(&manifest), Some(&redirect), None)); + let env = build_list_envelope(&combined_entries(Some(&manifest), &pins, None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["summary"]["discovered"], 3); let events = v["events"].as_array().unwrap(); @@ -678,22 +761,53 @@ mod tests { "manifest entries must NOT carry the hosted labels: {v}" ); assert_eq!( - events[0]["details"]["ledger"], - ".socket/vendor/redirect-state.json" + events[0]["details"]["lockfiles"], + serde_json::json!(["package-lock.json"]) + ); + assert!( + events[0]["details"].get("ledger").is_none(), + "a hosted pin names no ledger: {v}" ); } + /// A pre-v5 redirect ledger details only the pins it records with the + /// same uuid; any other pin lists with its uuid alone. + #[test] + fn legacy_ledger_details_only_matching_pins() { + let manifest = sample_manifest(); + let record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); + let mut legacy = RedirectState::new(); + legacy + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), record.clone()); + let pin = |purl: &str, uuid: &str| HostedPin { + purl: purl.to_string(), + uuid: uuid.to_string(), + files: vec!["yarn.lock".to_string()], + }; + let listings = HostedListing::from_pins( + &[ + pin("pkg:npm/minimist@1.2.2", &record.uuid), + pin("pkg:npm/other@1.0.0", "33333333-3333-4333-8333-333333333333"), + ], + Some(&legacy), + ); + assert_eq!(listings[0].record, record); + assert_eq!(listings[1].record.uuid, "33333333-3333-4333-8333-333333333333"); + assert!(listings[1].record.vulnerabilities.is_empty()); + assert_eq!(listings[1].lockfiles, vec!["yarn.lock".to_string()]); + } + /// A hosted-only listing (no manifest at all) — the shape a purely /// hosted-wired project produces. #[test] fn hosted_only_entries_build_a_success_envelope() { let manifest = sample_manifest(); - let mut redirect = RedirectState::new(); - redirect.records.insert( - "pkg:npm/minimist@1.2.2".to_string(), + let pins = vec![hosted( + "pkg:npm/minimist@1.2.2", manifest.patches["pkg:npm/minimist@1.2.2"].clone(), - ); - let env = build_list_envelope(&combined_entries(None, Some(&redirect), None)); + )]; + let env = build_list_envelope(&combined_entries(None, &pins, None)); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["discovered"], 1); @@ -728,10 +842,7 @@ mod tests { fn vendored_ledger_records_are_labeled_and_sorted_last() { let manifest = sample_manifest(); let record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); - let mut redirect = RedirectState::new(); - redirect - .records - .insert("pkg:npm/minimist@1.2.2".to_string(), record.clone()); + let pins = vec![hosted("pkg:npm/minimist@1.2.2", record.clone())]; let mut detached = record.clone(); detached.uuid = "44444444-4444-4444-8444-444444444444".to_string(); let mut vendor = HashMap::new(); @@ -751,7 +862,7 @@ mod tests { let env = build_list_envelope(&combined_entries( Some(&manifest), - Some(&redirect), + &pins, Some(&vendor), )); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); @@ -786,7 +897,7 @@ mod tests { "the ledger's embedded record is the one listed: {v}" ); - let only = build_list_envelope(&combined_entries(None, None, Some(&vendor))); + let only = build_list_envelope(&combined_entries(None, &[], Some(&vendor))); let v: serde_json::Value = serde_json::from_str(&only.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success", "{v}"); assert_eq!(v["summary"]["discovered"], 2, "{v}"); @@ -850,7 +961,7 @@ mod tests { }; assert_eq!( - listed(&combined_entries(Some(&manifest), None, Some(&vendor))), + listed(&combined_entries(Some(&manifest), &[], Some(&vendor))), vec![ ( "pkg:npm/left-pad@1.3.0".to_string(), @@ -867,7 +978,7 @@ mod tests { ); // No manifest at all: every fallback copy stands on its own. - let only = listed(&combined_entries(None, None, Some(&vendor))); + let only = listed(&combined_entries(None, &[], Some(&vendor))); assert_eq!(only.len(), 3, "{only:?}"); assert!( only.iter().all(|(_, mode, _)| mode == "vendored"), @@ -899,6 +1010,7 @@ mod tests { purl, record, source: Source::Manifest, + lockfiles: &[], } } @@ -975,13 +1087,13 @@ mod tests { fn format_listing_counts_and_separates_entries() { assert_eq!(format_listing(&[], false), "No patches found in manifest."); let manifest = sample_manifest(); - let one = combined_entries(Some(&manifest), None, None); + let one = combined_entries(Some(&manifest), &[], None); let out = format_listing(&one, false); assert!(out.starts_with("Found 1 patch:\n\nPackage: "), "{out}"); assert!(!out.ends_with('\n'), "no trailing blank line: {out:?}"); let multi = multi_entry_manifest(); - let many = combined_entries(Some(&multi), None, None); + let many = combined_entries(Some(&multi), &[], None); let out = format_listing(&many, false); assert!( out.starts_with(&format!("Found {} patches:\n\n", many.len())), diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index ddda23e26..f30ee99ed 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -20,13 +20,10 @@ pub(crate) mod vlt_preflight; use std::path::Path; -/// The documented name of the mode whose ledger is -/// `.socket/vendor/redirect-state.json`. Shared by scan's `redirectState` -/// envelope block and list's hosted event labels so the two surfaces can -/// never drift, and deliberately a CONSTANT rather than an echo of the -/// ledger's own `mode` string: that string is opaque to the loader -/// (pre-rename ledgers carry `"redirect"`), and a consumer dispatching on -/// these keys must not have to know that history. +/// The documented name of hosted mode (lockfile pins to Socket-hosted +/// patched packages; no ledger). Shared by scan's `redirectState` envelope +/// block and list's hosted event labels so the two surfaces can never +/// drift. pub(crate) const HOSTED_MODE_LABEL: &str = "hosted"; /// The documented name of the mode whose ledger is @@ -62,35 +59,63 @@ pub(crate) async fn discover_wiring( socket_patch_core::vex::discover_patched_refs_with(root, &opts).await } -/// Read-only lenient load of the hosted redirect ledger: missing → `None` -/// (a fresh start); malformed → `None` with the corruption surfaced on -/// stderr unless `silent`. This is the "read-only consumers may degrade a -/// malformed ledger to nothing-to-consult, but must surface it" posture -/// from `load_redirect_state`'s contract — the warning is advisory -/// (muted by `--silent`, "errors only"), because every path that would -/// WRITE or ATTEST from the ledger hard-errors on the same corruption -/// instead. Used by scan's empty-discovery `redirectState` consult; the -/// main-path consult inlines the same posture so it can flush telemetry -/// before the warning. -pub(crate) async fn load_redirect_state_lenient( - cwd: &Path, - silent: bool, -) -> Option { - match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { - Ok(state) => state, - Err(corrupt) => { - if !silent { - eprintln!("Warning: {corrupt}"); - } - None - } +/// The project's hosted wiring as raw inventory (core +/// [`HostedInventory`]): the attributable pins management commands act on, +/// and the contested wiring they must refuse around. VEX eligibility is a +/// separate judgment over the same discovery. +/// +/// [`HostedInventory`]: socket_patch_core::patch::redirect::upstream::HostedInventory +pub(crate) async fn hosted_inventory( + common: &crate::args::GlobalArgs, + root: &Path, +) -> socket_patch_core::patch::redirect::upstream::HostedInventory { + socket_patch_core::patch::redirect::upstream::HostedInventory::of( + &discover_wiring(common, root).await, + ) +} + +/// The project's hosted state, v5-style: v5 hosted mode keeps no ledger, +/// so the hosted pins [`discover_wiring`] finds in the lockfiles are the +/// whole record. Shaped as a [`RedirectState`] for the readers that classify +/// hosted against vendored state (one uuid-only record per pinned purl, no +/// edits) — it is never persisted. +/// +/// [`RedirectState`]: socket_patch_core::patch::redirect::RedirectState +pub(crate) async fn hosted_state_from_lockfiles( + common: &crate::args::GlobalArgs, + root: &Path, +) -> socket_patch_core::patch::redirect::RedirectState { + hosted_state_from_pins(&socket_patch_core::patch::redirect::upstream::HostedPin::all( + &discover_wiring(common, root).await, + )) +} + +/// [`hosted_state_from_lockfiles`] over already-discovered pins. A purl +/// pinned to several uuids (different lockfiles) keeps the first. +pub(crate) fn hosted_state_from_pins( + pins: &[socket_patch_core::patch::redirect::upstream::HostedPin], +) -> socket_patch_core::patch::redirect::RedirectState { + let mut state = socket_patch_core::patch::redirect::RedirectState::new(); + for pin in pins { + state + .records + .entry(pin.purl.clone()) + .or_insert_with(|| socket_patch_core::manifest::schema::PatchRecord { + uuid: pin.uuid.clone(), + exported_at: String::new(), + files: Default::default(), + vulnerabilities: Default::default(), + description: String::new(), + license: String::new(), + tier: String::new(), + }); } + state } /// Read-only lenient load of the vendor ledger (`.socket/vendor/state.json`): /// missing → an empty ledger; malformed/unreadable → `None` with the -/// problem surfaced on stderr unless `silent`. The vendor twin of -/// [`load_redirect_state_lenient`], with the same posture: a read-only +/// problem surfaced on stderr unless `silent`. A read-only /// consumer (`list`) degrades a broken ledger to nothing-to-consult but /// must say so, while every path that writes or attests from it fails /// closed instead. diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 9ce954018..c21fbfd7e 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -3,9 +3,7 @@ use socket_patch_core::api::client::get_api_client_with_overrides; use socket_patch_core::manifest::cleanup_blobs::format_bytes; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::patch::redirect::{ - load_redirect_state, persist_redirect_state, RedirectState, REDIRECT_STATE_REL, -}; +use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::telemetry::{track_patch_remove_failed, track_patch_removed}; use socket_patch_core::utils::purl::patch_matches; use socket_patch_core::vendor::{ @@ -38,15 +36,15 @@ fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String matches } -/// Hosted redirect records matching a remove identifier, sorted. -fn hosted_records_matching(state: &RedirectState, identifier: &str) -> Vec { - let mut matches: Vec = state - .records +/// The lockfiles' hosted pins matching a remove identifier (by purl or +/// patch uuid), sorted by purl. +fn hosted_pins_matching(pins: &[HostedPin], identifier: &str) -> Vec { + let mut matches: Vec = pins .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, identifier)) - .map(|(purl, _)| purl.clone()) + .filter(|pin| patch_matches(&pin.purl, &pin.uuid, identifier)) + .cloned() .collect(); - matches.sort(); + matches.sort_by(|a, b| a.purl.cmp(&b.purl)); matches } @@ -340,22 +338,33 @@ pub async fn run(args: RemoveArgs) -> i32 { let cwd = &args.common.cwd; // ── state discovery ───────────────────────────────────────────────── - // A ledger-only project (vendored mode keeps its records in the vendor - // ledger, hosted mode in the redirect ledger — neither writes a - // manifest) proceeds manifest-less: `remove` is the per-purl exit path - // for those entries. Only cheap EXISTENCE probes run before the lock — - // they decide the truly-empty error path, which never locks (a bare - // project must not see `.socket/` created and pruned again). The - // stores themselves are loaded under the lock below. + // A manifest-less project (vendored mode keeps its records in the + // vendor ledger; hosted mode keeps none — its lockfile pins are the + // record) proceeds manifest-less: `remove` is the per-purl exit path + // for those entries. Only cheap probes run before the lock — they + // decide the truly-empty error path, which never locks (a bare project + // must not see `.socket/` created and pruned again). The vendor ledger + // is loaded under the lock below; the hosted pins come from read-only + // lockfile discovery (the restore re-reads every file under the lock). let manifest_missing = tokio::fs::metadata(&manifest_path).await.is_err(); + let hosted_inventory = crate::commands::hosted_inventory(&args.common, cwd).await; + let hosted_pins: Vec = hosted_inventory.pins.clone(); if manifest_missing { let vendor_ledger_exists = tokio::fs::metadata(cwd.join(VENDOR_STATE_REL)) .await .is_ok(); - let redirect_ledger_exists = tokio::fs::metadata(cwd.join(REDIRECT_STATE_REL)) - .await - .is_ok(); - if !vendor_ledger_exists && !redirect_ledger_exists { + if !vendor_ledger_exists && hosted_pins.is_empty() { + // Contested hosted wiring is still hosted state: name it + // instead of reporting a bare project. + if let Some(refusal) = hosted_inventory.contested_refusal() { + emit_error_envelope( + args.common.json, + args.common.dry_run, + "hosted_wiring_contested", + refusal, + ); + return 1; + } emit_error_envelope( args.common.json, args.common.dry_run, @@ -453,23 +462,18 @@ pub async fn run(args: RemoveArgs) -> i32 { } } - // 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)) = load_redirect_state(cwd).await { - let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); - if !hosted_matches.is_empty() { - return remove_hosted_only( - &args, - hosted_matches, - redirect_state, - api_token.as_deref(), - org_slug.as_deref(), - ) - .await; - } + // Hosted-only patches likewise have no manifest entry — their + // lockfile pins are their only persistence, and `remove` is their + // per-purl exit path (restoring the upstream entry IS the removal). + let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); + if !hosted_matches.is_empty() { + return remove_hosted_only( + &args, + hosted_matches, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; } emit_not_found( @@ -516,8 +520,8 @@ 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"). The prompt names every leg the removal - // will touch: the redirect ledger is probed read-only here (the legs - // below re-load it and decide for real). + // will touch: the hosted pins come from the read-only discovery above + // (the legs below decide for real). if !args.common.dry_run { let (vendored, hosted) = if args.skip_rollback { (0, 0) @@ -526,12 +530,7 @@ pub async fn run(args: RemoveArgs) -> i32 { .as_ref() .map(|st| vendor_entries_matching(st, &args.identifier).len()) .unwrap_or(0); - let hosted = load_redirect_state(cwd) - .await - .ok() - .flatten() - .map(|st| hosted_records_matching(&st, &args.identifier).len()) - .unwrap_or(0); + let hosted = hosted_pins_matching(&hosted_pins, &args.identifier).len(); (vendored, hosted) }; let prompt = remove_prompt( @@ -745,75 +744,50 @@ pub async fn run(args: RemoveArgs) -> i32 { } // ── hosted leg ────────────────────────────────────────────────────── - // An identifier can also (or only) match hosted records in the - // redirect ledger. Supported ecosystems (cargo, npm-family, golang) 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). + // An identifier can also (or only) match hosted pins in the lockfiles. + // Each is restored to its default upstream registry entry; a pin that + // cannot be fails closed BEFORE the manifest mutation. // `--skip-rollback` leaves hosted wiring untouched, like the vendor - // wiring above; `--preserve-state` still unwinds — hosted has no + // wiring above; `--preserve-state` still restores — hosted has no // preservable local state. let mut hosted_reverted_events: Vec = Vec::new(); - // The hosted leg's run-level advisories (e.g. - // `redirect_npmrc_allow_remote_modified`): printed as they arrive, + // The hosted leg's run-level advisories: printed as they arrive, // carried into the success envelope's `warnings[]`. let mut hosted_leg_warnings: Vec<(String, String)> = Vec::new(); if !args.skip_rollback { - match load_redirect_state(cwd).await { - Err(e) => { - if loud { - eprintln!( - "Warning: cannot read the hosted redirect ledger ({e}); hosted \ - redirects were not examined" - ); + let hosted_matches = hosted_pins_matching(&hosted_pins, &args.identifier); + if !hosted_matches.is_empty() { + let leg = match unwind_hosted(&args.common, &hosted_matches).await { + Ok(leg) => { + hosted_leg_warnings.extend(leg.warnings.iter().cloned()); + leg } - } - Ok(None) => {} - Ok(Some(mut redirect_state)) => { - let hosted_matches = hosted_records_matching(&redirect_state, &args.identifier); - if !hosted_matches.is_empty() { - let leg = - match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state) - .await - { - Ok(leg) => { - hosted_leg_warnings.extend(leg.warnings.iter().cloned()); - leg - } - Err(err) => { - let (code, msg) = hosted_unwind_error(err, true); - emit_error_envelope( - args.common.json, - args.common.dry_run, - code, - msg, - ); - return 1; - } - }; - if args.preserve_state && !leg.reverted.is_empty() && loud { - eprintln!( - "Note: hosted redirects have no preservable local state; \ - their ledger records were dropped with the unwound wiring." - ); - } - // `run_hosted_leg` printed one line per unwound purl. - printed_progress |= loud && !leg.reverted.is_empty(); - 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", - ), - ); - } + Err(err) => { + let (code, msg) = hosted_unwind_error(err, true); + emit_error_envelope(args.common.json, args.common.dry_run, code, msg); + return 1; } + }; + if args.preserve_state && !leg.reverted.is_empty() && loud { + eprintln!( + "Note: hosted wiring has no preservable local state; its lockfile pins \ + now resolve upstream." + ); + } + // `run_hosted_leg` printed one line per restored purl. + printed_progress |= loud && !leg.reverted.is_empty(); + 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 pin restored to the upstream registry on remove", + ), + ); } } } @@ -1299,86 +1273,52 @@ async fn revert_vendored_matches( Ok(leg) } -/// Why a hosted unwind stopped. Each caller renders its own message (the +/// Why a hosted unwind stopped: a pin the upstream restore refused (or a +/// write failure). Each caller renders its own message (the /// manifest-backed path adds that the manifest was not touched). -enum HostedUnwindError { - /// The ledger could not be persisted after the reverts flushed. - Persist(String), - /// Scoped targets whose ecosystem has no per-purl hosted revert. - Unsupported(Vec), - /// A per-purl revert (or the whole-ledger replay) refused. - Failed { what: String, why: String }, +struct HostedUnwindError { + why: String, } -/// Unwind the hosted redirect records in `hosted_matches` and persist the -/// ledger — FIRST, failure or not: the per-purl reverts flush lockfile -/// writes as they go, so an early error return without persisting would -/// strand already-reverted purls' records in the on-disk ledger (lockfiles -/// and ledger desynced; `list`/VEX attest dead wiring). When the matches -/// cover EVERY record the whole-ledger replay serves the ecosystems without -/// a per-purl revert. Shared by the manifest-backed and hosted-only remove -/// paths. +/// Restore the hosted pins in `hosted_matches` to their upstream registry +/// entries. Nothing is written unless every pin resolved (the restore is +/// all-or-nothing per pin, and a refused pin fails the remove). Shared by +/// the manifest-backed and hosted-only remove paths. async fn unwind_hosted( common: &GlobalArgs, - hosted_matches: &[String], - state: &mut RedirectState, + hosted_matches: &[HostedPin], ) -> Result { - let replay_eligible = state.records.keys().all(|p| hosted_matches.contains(p)); - let before = (state.edits.len(), state.records.len()); - let leg = run_hosted_leg(common, hosted_matches, state, replay_eligible).await; + let leg = run_hosted_leg(common, hosted_matches).await; // Printed as soon as the leg returns, so a human run that then fails // still says what it did to the files. print_hosted_leg_warnings(common, &leg.warnings); - if !common.dry_run && (state.edits.len(), state.records.len()) != before { - if let Err(e) = persist_redirect_state(&common.cwd, state).await { - return Err(HostedUnwindError::Persist(e.to_string())); - } - } - if !leg.unsupported.is_empty() { - return Err(HostedUnwindError::Unsupported(leg.unsupported)); + if let Some((_, why)) = leg.failed.first().cloned() { + return Err(HostedUnwindError { why }); } - if let Some((what, why)) = leg.failed.first().cloned() { - return Err(HostedUnwindError::Failed { what, why }); + if let Some(warning) = super::rollback::retire_legacy_redirect_ledger(common).await { + print_hosted_leg_warnings(common, std::slice::from_ref(&warning)); } Ok(leg) } /// Error code + message for a stopped hosted unwind. fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'static str, String) { - let note = if manifest_backed { - " The manifest was not modified." - } else { - "" - }; - match err { - HostedUnwindError::Persist(e) => ( - "hosted_revert_failed", - format!("failed to persist the hosted redirect ledger: {e}"), - ), - HostedUnwindError::Unsupported(purls) => ( - "hosted_revert_unsupported", - format!( - "no per-purl hosted-redirect revert exists for: {}. Run an unscoped \ - `socket-patch rollback` to unwind ALL hosted redirects, or re-run \ - `scan --mode hosted` to normalize.{note}", - purls.join(", ") - ), - ), - HostedUnwindError::Failed { what, why } => ( - "hosted_revert_failed", - if manifest_backed { - format!("could not unwind hosted redirect for {what}: {why}.{note}") - } else { - format!("could not unwind hosted redirect for {what}: {why}") - }, - ), - } + // `why` already names the pin (the restore's refusal) or the write + // that failed, with its remedy. + let HostedUnwindError { why } = err; + ( + "hosted_revert_failed", + if manifest_backed { + format!("{why}. The manifest was not modified.") + } else { + why + }, + ) } -/// Remove path for identifiers that match ONLY hosted redirect records -/// (no manifest entry, no vendor-ledger entry): confirm, unwind each -/// record's lockfile wiring, drop it from the redirect ledger, and report -/// `Removed`/`hosted_reverted` events. Like the ledger-only vendored path, +/// Remove path for identifiers that match ONLY hosted lockfile pins (no +/// manifest entry, no vendor-ledger entry): confirm, restore each pin's +/// upstream registry entry, and report `Removed`/`hosted_reverted` events. Like the ledger-only vendored path, /// the unwind IS the removal, so events go through `env.record` and bump /// `summary.removed`. `--skip-rollback` is refused (with no manifest /// entry to delete, removing a hosted patch can only mean unwinding its @@ -1386,8 +1326,7 @@ fn hosted_unwind_error(err: HostedUnwindError, manifest_backed: bool) -> (&'stat /// preservable local state. async fn remove_hosted_only( args: &RemoveArgs, - hosted_matches: Vec, - mut redirect_state: RedirectState, + hosted_matches: Vec, api_token: Option<&str>, org_slug: Option<&str>, ) -> i32 { @@ -1398,8 +1337,8 @@ async fn remove_hosted_only( args.common.dry_run, "hosted_state_retained", format!( - "{} matches only hosted redirect records; removing one means unwinding \ - its lockfile redirect, which --skip-rollback prevents", + "{} matches only hosted lockfile pins; removing one means restoring its \ + upstream registry entry, which --skip-rollback prevents", args.identifier ), ); @@ -1420,8 +1359,8 @@ async fn remove_hosted_only( "will be" } ); - for purl in &hosted_matches { - eprintln!(" - {purl}"); + for pin in &hosted_matches { + eprintln!(" - {}", pin.purl); } eprintln!(); } @@ -1442,24 +1381,10 @@ async fn remove_hosted_only( return 0; } - let leg = match unwind_hosted(&args.common, &hosted_matches, &mut redirect_state).await { + let leg = match unwind_hosted(&args.common, &hosted_matches).await { Ok(leg) => leg, Err(err) => { - match &err { - HostedUnwindError::Unsupported(_) => { - track_patch_remove_failed( - "hosted redirect revert unsupported", - api_token, - org_slug, - ) - .await; - } - HostedUnwindError::Failed { .. } => { - track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug) - .await; - } - HostedUnwindError::Persist(_) => {} - } + track_patch_remove_failed("hosted redirect revert failed", api_token, org_slug).await; let (code, msg) = hosted_unwind_error(err, false); emit_error_envelope(args.common.json, args.common.dry_run, code, msg); return 1; @@ -1482,7 +1407,7 @@ async fn remove_hosted_only( for purl in &leg.reverted { env.record(PatchEvent::new(action, purl.clone()).with_reason( "hosted_reverted", - "hosted lockfile redirect unwound on remove", + "hosted lockfile pin restored to the upstream registry on remove", )); } if args.common.json { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 4fb8ed4d0..f4aa08ec5 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -64,18 +64,15 @@ pub async fn run(args: RepairArgs) -> i32 { let mut vendor_references: Option> = None; if tokio::fs::metadata(&manifest_path).await.is_err() { - // Hosted (redirect) mode leaves no local artifacts to repair: the - // lockfiles point at patch.socket.dev URLs, not `.socket/vendor/...`, - // and there is no manifest or vendor ledger. A project whose only - // trace is `redirect-state.json` is therefore a no-op for repair — - // exit success with an informational skip rather than the - // `manifest_not_found` error a bare directory would get. Only cheap - // existence probes (and the read-only lockfile scan) run before the - // lock, so a project with nothing to repair never grows `.socket/`. - let redirect_state = args - .common - .cwd - .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + // Hosted mode leaves no local artifacts to repair: the lockfiles + // point at patch.socket.dev URLs, not `.socket/vendor/...`, and + // there is no manifest or vendor ledger. A project whose only trace + // is its hosted lockfile pins (or a pre-v5 `redirect-state.json`) + // is therefore a no-op for repair — exit success with an + // informational skip rather than the `manifest_not_found` error a + // bare directory would get. Only cheap existence probes (and the + // read-only lockfile scans) run before the lock, so a project with + // nothing to repair never grows `.socket/`. let state_file = args .common .cwd @@ -88,7 +85,15 @@ pub async fn run(args: RepairArgs) -> i32 { vendor_references = Some(refs); } if !has_vendor_traces { - if tokio::fs::metadata(&redirect_state).await.is_ok() { + let legacy_ledger = args + .common + .cwd + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + let hosted = tokio::fs::metadata(&legacy_ledger).await.is_ok() + || !crate::commands::hosted_inventory(&args.common, &args.common.cwd) + .await + .is_empty(); + if hosted { let msg = HOSTED_ONLY_REASON; if args.common.json { let mut env = Envelope::new(Command::Repair); diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 14806e79b..12ccdb313 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -16,6 +16,7 @@ use socket_patch_core::patch::rollback::{ }; use socket_patch_core::telemetry::{track_patch_rollback_failed, track_patch_rolled_back}; use socket_patch_core::utils::purl::{patch_matches, strip_purl_qualifiers}; +use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::vendor::{save_state, RevertOpts, VendorState, VendorWarning}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -176,12 +177,7 @@ pub(crate) fn as_question(text: &str) -> String { /// The default (destructive) rollback's confirmation prompt, naming only /// the legs that have work. -fn rollback_prompt( - manifest: usize, - vendored: usize, - hosted: usize, - leftover_edits: usize, -) -> String { +fn rollback_prompt(manifest: usize, vendored: usize, hosted: usize) -> String { let mut clauses: Vec = Vec::new(); if manifest > 0 { clauses.push(format!( @@ -206,17 +202,8 @@ fn rollback_prompt( } if hosted > 0 { clauses.push(format!( - "unwind {}", - plural(hosted, "hosted redirect", "hosted redirects") - )); - } else if leftover_edits > 0 { - clauses.push(format!( - "replay {}", - plural( - leftover_edits, - "leftover hosted redirect edit", - "leftover hosted redirect edits" - ) + "restore {} to the upstream registry", + plural(hosted, "hosted package", "hosted packages") )); } as_question(&join_clauses(&clauses)) @@ -1023,35 +1010,34 @@ async fn run_vendored_leg( out } -/// Unwind the in-scope hosted redirects: per-purl reverts where they -/// exist (cargo, npm-family, golang), 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, +/// The patch-server origins that count as hosted, besides Socket's own: +/// the operator's `--patch-server-url` (discovery's allowlist). +pub(crate) fn patch_server_origins(common: &GlobalArgs) -> Vec { + common + .patch_server_url + .iter() + .filter(|url| !url.trim().is_empty()) + .cloned() + .collect() +} + +/// Restore the in-scope hosted pins to their default upstream registry +/// entries (core `patch::redirect::upstream`): v5 hosted mode keeps no +/// ledger, so each pin's lock entry is re-resolved from the registry, and a +/// pin that cannot be is refused with the `git checkout` remedy. Shared +/// with remove's hosted leg. +pub(crate) async fn run_hosted_leg(common: &GlobalArgs, pins: &[HostedPin]) -> HostedLegOutcome { + use socket_patch_core::patch::redirect::upstream::{ + restore_upstream, PinStatus, RestoreOptions, }; let mut out = HostedLegOutcome::default(); - // The vlt nodes the unwound purls pin, read before the revert drops - // their edits: the heal below invalidates the patched installed copies - // once the registry pins are back. - let vlt_scope: Vec = if replay_eligible { - purls - .iter() - .cloned() - .chain(state.records.keys().cloned()) - .collect() - } else { - purls.to_vec() - }; - // FIFO-safe: a FIFO or device planted at the lock path must fail this - // read at once, not block the rollback in open(2). + if pins.is_empty() { + return out; + } + // The vlt nodes the restored pins pin, read before the restore rewrites + // them: the heal below invalidates the patched installed copies once the + // registry pins are back. let vlt_lock = socket_patch_core::utils::fs::read_regular_to_string( &common .cwd @@ -1059,126 +1045,97 @@ pub(crate) async fn run_hosted_leg( ) .await .ok(); - let vlt_targets = socket_patch_core::patch::redirect::vlt_heal::ledger_targets( - state, - &vlt_scope, - vlt_lock.as_deref(), - ); - // When the whole-ledger replay will run anyway (the scope covers every - // record), npm purls on Bun projects defer to it so all lockfile edits - // are staged together atomically. A scoped unwind of one of several - // Bun records uses the per-purl revert to restore only its package. - let has_bun_edits = state.edits.iter().any(|e| { - matches!( - e.kind.as_str(), - "redirect_bun_lock_package" | "redirect_bun_lockb_package" - ) - }); - 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) => { - for (code, detail) in &revert.warnings { - out.warnings.push((code.clone(), detail.clone())); - } - 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!("Error: 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!( - "Error: 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 - // shared settings edits (such as pnpm trustLockfile), so an emptied - // record map with leftover edits replays them here too. (The npm - // `.npmrc` `allow-remote=all` edit is the one exception: the per-purl - // npm revert of the LAST package-lock entry unwinds it itself, so a - // scoped rollback leaves no loosened policy behind while other - // ecosystems' records remain.) - 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!( - "Error: 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) { + let origins = patch_server_origins(common); + let purls: Vec = pins.iter().map(|p| p.purl.clone()).collect(); + let vlt_targets = vlt_lock + .as_deref() + .map(|lock| { + socket_patch_core::patch::redirect::vlt_heal::lock_targets(lock, &origins, &purls) + }) + .unwrap_or_default(); + let opts = RestoreOptions { + dry_run: common.dry_run, + offline: common.offline, + patch_server_origins: origins, + // A binary bun.lockb pin refuses with the checkout remedy: its + // rebuilt registry record is not byte-exact for every lock. + bun_lockb: false, + }; + let outcome = restore_upstream(&common.cwd, pins, &opts).await; + for pin in &outcome.pins { + match &pin.status { + PinStatus::Restored => { if !common.json && !common.silent { if common.dry_run { - println!("Would unwind hosted redirect for {purl}"); + println!("Would restore {} to its upstream registry entry", pin.purl); } else { - println!("Unwound hosted redirect for {purl}"); + println!("Restored {} to its upstream registry entry", pin.purl); } } - out.reverted.push(purl); - } else if !out.failed.iter().any(|(p, _)| p.starts_with("group:")) { - let why = "hosted redirect edits could not be replayed"; + out.reverted.push(pin.purl.clone()); + } + PinStatus::Refused(why) => { // Errors print even under --silent: this drives exit 1. if !common.json { - eprintln!("Error: Failed to unwind hosted redirect for {purl}: {why}"); + eprintln!("Error: {}", capitalize_first(why)); } - out.failed.push((purl, why.into())); + out.failed.push((pin.purl.clone(), why.clone())); } } } - // A target's registry pins are back when its purl reverted, or when the - // whole-ledger replay committed the vlt group: groups commit on their - // own, so another lock's refusal (a drifted package-lock.json) leaves - // the restored vlt-lock.json pins restored. - let vlt_group_refused = out.failed.iter().any(|(p, _)| p == "group:vlt"); + if let Some(e) = &outcome.flush_error { + let why = format!("writing the restored lockfiles failed: {e}"); + if !common.json { + eprintln!("Error: {}", capitalize_first(&why)); + } + out.failed.push(("files".to_string(), why)); + } + out.warnings.extend( + outcome + .warnings + .iter() + .map(|(code, detail)| (code.to_string(), detail.clone())), + ); + out.edited_files.extend(outcome.reverted_files.iter().cloned()); let unwound: Vec<_> = vlt_targets .into_iter() - .filter(|t| { - out.reverted.iter().any(|p| { - socket_patch_core::utils::purl::canonical_purl(p) - == socket_patch_core::utils::purl::canonical_purl(&t.purl) - }) || (replay_eligible && !vlt_group_refused) - }) + .filter(|t| out.reverted.iter().any(|p| p == &t.purl)) .collect(); out.warnings .extend(crate::commands::scan::vlt_rollback_heal(common, &unwound).await); out } +/// Delete a pre-v5 hosted ledger once no hosted pin is left for it to +/// describe (v5 never writes it; it is read only for migration). A wet run +/// only; a failure is a warning (the file is inert). +pub(crate) async fn retire_legacy_redirect_ledger(common: &GlobalArgs) -> Option<(String, String)> { + let path = common + .cwd + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + if common.dry_run || tokio::fs::symlink_metadata(&path).await.is_err() { + return None; + } + let remaining = crate::commands::discover_wiring(common, &common.cwd).await; + if !HostedPin::all(&remaining).is_empty() { + return None; + } + // The emptied `.socket/vendor/` goes with it; the apply lock's drop + // prunes an emptied `.socket/` itself. + let stop = common.cwd.join(socket_patch_core::constants::SOCKET_DIR); + match socket_patch_core::utils::socket_dir::remove_file_and_prune(&path, &stop).await { + Ok(()) => None, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => Some(( + "legacy_redirect_ledger_kept".to_string(), + format!( + "could not delete the pre-v5 hosted ledger {}: {e}", + socket_patch_core::patch::redirect::REDIRECT_STATE_REL + ), + )), + } +} + pub async fn run(args: RollbackArgs) -> i32 { apply_env_toggles(&args.common); @@ -1237,10 +1194,10 @@ pub async fn run(args: RollbackArgs) -> i32 { let cwd = args.common.cwd.clone(); // ── 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 not fatal when a ledger holds work. + // Rollback infers what to undo from three sources: the manifest + // (in-place/agent patches), the vendor ledger (vendored patches), and + // the lockfiles themselves (hosted pins — v5 hosted mode keeps no + // ledger). A missing manifest is not fatal when either holds work. // // Only cheap EXISTENCE probes happen before the lock (they decide the // truly-empty error path, which never locks: acquiring would create @@ -1253,12 +1210,54 @@ pub async fn run(args: RollbackArgs) -> i32 { 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 { + // The hosted pins the lockfiles wire (read-only discovery; the restore + // re-reads every file under the lock before it writes). + let hosted_inventory = crate::commands::hosted_inventory(&args.common, &cwd).await; + let hosted_pins: Vec = hosted_inventory.pins.clone(); + + if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() { + // Hosted wiring the lockfiles name but cannot attribute is still + // hosted state: refuse, naming it, instead of "Manifest not found". + if let Some(refusal) = hosted_inventory.contested_refusal() { + emit_rollback_error(args.common.json, &refusal); + return 1; + } + // Only a pre-v5 hosted ledger left: no lockfile pins it any more, + // so there is nothing to restore — retire the stale file (a wet run + // only) instead of failing on the missing manifest. + let legacy = cwd.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); + if tokio::fs::symlink_metadata(&legacy).await.is_ok() { + let warning = retire_legacy_redirect_ledger(&args.common).await; + if args.common.json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "status": "success", + "rolledBack": 0, + "alreadyOriginal": 0, + "failed": 0, + "dryRun": args.common.dry_run, + "warnings": warning + .iter() + .map(|(code, detail)| serde_json::json!({ + "code": code, "detail": detail, + })) + .collect::>(), + "legacyRedirectLedgerRemoved": warning.is_none() && !args.common.dry_run, + })) + .expect("serializing an in-memory JSON value cannot fail") + ); + } else if let Some((code, detail)) = &warning { + eprintln!("Warning ({code}): {}", capitalize_first(detail)); + } else if !args.common.silent { + println!( + "{} the pre-v5 hosted ledger {}: no lockfile pins a hosted patch.", + if args.common.dry_run { "Would remove" } else { "Removed" }, + socket_patch_core::patch::redirect::REDIRECT_STATE_REL + ); + } + return 0; + } // 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.) @@ -1308,9 +1307,7 @@ pub async fn run(args: RollbackArgs) -> i32 { // each exactly once: the agent leg below receives the manifest and the // vendor-ownership key set instead of re-reading them. 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(); // An unreadable ledger degrades to "nothing vendored" for the in-place // leg (its own containment is the `vendor_state_unreadable` exit below). let vendored_keys: HashSet = vendor_state_result @@ -1355,14 +1352,10 @@ pub async fn run(args: RollbackArgs) -> i32 { } 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 redirect_records: Vec<(String, String)> = hosted_pins + .iter() + .map(|pin| (pin.purl.clone(), pin.uuid.clone())) + .collect(); let scoped = !identifiers.is_empty() || !path_scope.is_empty(); @@ -1528,25 +1521,6 @@ pub async fn run(args: RollbackArgs) -> i32 { }); } - // 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. `--ecosystems` counts as a scope here: - // recordless edits carry no purl to narrow by, so an eco-narrowed - // run leaves them to an unscoped rollback rather than replaying - // other ecosystems' edits behind the filter's back. - Ok(Some(s)) => { - (!s.records.is_empty() || (!scoped && args.common.ecosystems.is_none())) - && 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 @@ -1564,40 +1538,14 @@ pub async fn run(args: RollbackArgs) -> i32 { ), )); } - if redirect_corrupt { - run_warnings.push(( - "redirect_state_unreadable".into(), - // The core error already carries the recovery steps; only say - // what this run skipped. - format!( - "the hosted leg was skipped: cannot read the hosted redirect ledger: {}", - 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; + let has_work = + !manifest_scope.is_empty() || !vendor_scope.is_empty() || !hosted_scope.is_empty(); // Everything in scope was filtered out by `--ecosystems`: say so, // instead of the misleading "No patches found in manifest". let eco_filtered_everything = !has_work && scope_before_eco_filter > 0; @@ -1614,12 +1562,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if has_work && !args.common.dry_run && !args.preserve_state { // Compose only the clauses that apply, so a hosted-only run never // claims manifest entries it does not have. - let prompt = rollback_prompt( - manifest_scope.len(), - vendor_scope.len(), - hosted_scope.len(), - hosted_leftover_edits, - ); + let prompt = rollback_prompt(manifest_scope.len(), vendor_scope.len(), hosted_scope.len()); if !crate::ui::confirm(&prompt, true, &args.common) { if !args.common.json && !args.common.silent { println!("Rollback cancelled."); @@ -1675,33 +1618,28 @@ pub async fn run(args: RollbackArgs) -> i32 { } // ── 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: {}", capitalize_first(&msg)); - } - hosted_leg.failed.push(("ledger".to_string(), msg)); - } - } + let in_scope: Vec = hosted_pins + .iter() + .filter(|pin| hosted_scope.contains(&pin.purl)) + .cloned() + .collect(); + let mut hosted_leg = run_hosted_leg(&args.common, &in_scope).await; + // An unscoped rollback promises to unwind EVERY hosted patch: + // contested wiring it cannot restore fails the leg (a scoped run + // names its own targets and leaves unrelated wiring alone). + if !scoped { + if let Some(refusal) = hosted_inventory.contested_refusal() { + if !args.common.json { + eprintln!("Error: {}", capitalize_first(&refusal)); } + hosted_leg + .failed + .push(("hosted_wiring_contested".to_string(), refusal)); + } + } + if hosted_leg.failed.is_empty() { + if let Some(warning) = retire_legacy_redirect_ledger(&args.common).await { + run_warnings.push(warning); } } @@ -1846,8 +1784,8 @@ pub async fn run(args: RollbackArgs) -> i32 { 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 \ + "hosted wiring has no preservable local state: the lockfile pins are \ + the only record, and they now resolve upstream; re-run \ `scan --mode hosted` to re-wire" .into(), )); @@ -1889,9 +1827,7 @@ pub async fn run(args: RollbackArgs) -> i32 { .filter(|(code, _)| { !matches!( code.as_str(), - "vendor_state_unreadable" - | "redirect_state_unreadable" - | "reinstall_required" + "vendor_state_unreadable" | "reinstall_required" ) }) .chain(hosted_leg.warnings.iter()) @@ -1928,7 +1864,6 @@ pub async fn run(args: RollbackArgs) -> i32 { && 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() @@ -2157,7 +2092,7 @@ pub async fn run(args: RollbackArgs) -> i32 { eprintln!("Error: Kept vendored state for {key}: {reason}"); } for (code, detail) in &run_warnings { - if code == "vendor_state_unreadable" || code == "redirect_state_unreadable" { + if code == "vendor_state_unreadable" { eprintln!("Error ({code}): {}", capitalize_first(detail)); } } @@ -4671,30 +4606,29 @@ mod tests { #[test] fn rollback_prompt_singular_plural_and_clauses() { assert_eq!( - rollback_prompt(1, 0, 0, 0), + rollback_prompt(1, 0, 0), "Roll back 1 patch and remove it from the local manifest?" ); assert_eq!( - rollback_prompt(2, 0, 0, 0), + rollback_prompt(2, 0, 0), "Roll back 2 patches and remove them from the local manifest?" ); - // Never "..., and unwind" after an inner "and". + // Never "..., and restore" after an inner "and". assert_eq!( - rollback_prompt(1, 0, 1, 0), - "Roll back 1 patch, remove it from the local manifest, and unwind 1 hosted \ - redirect?" + rollback_prompt(1, 0, 1), + "Roll back 1 patch, remove it from the local manifest, and restore 1 hosted \ + package to the upstream registry?" ); - assert_eq!(rollback_prompt(0, 0, 3, 0), "Unwind 3 hosted redirects?"); assert_eq!( - rollback_prompt(0, 0, 0, 1), - "Replay 1 leftover hosted redirect edit?" + rollback_prompt(0, 0, 3), + "Restore 3 hosted packages to the upstream registry?" ); assert_eq!( - rollback_prompt(0, 1, 0, 0), + rollback_prompt(0, 1, 0), "Delete 1 vendored artifact and its ledger record?" ); assert_eq!( - rollback_prompt(0, 2, 0, 0), + rollback_prompt(0, 2, 0), "Delete 2 vendored artifacts and their ledger records?" ); } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index d9d51ea74..642547aad 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -398,62 +398,50 @@ pub(super) async fn preverify_vendor_baselines( (mismatched, views) } -/// Fold both ledgers' patch records into the manifest view update detection -/// consults. Hosted mode records purl→uuid ONLY in -/// `.socket/vendor/redirect-state.json` and vendored mode ONLY in -/// `.socket/vendor/state.json`, so without this fold a pure hosted or -/// vendored project's `updates[]` would always be empty. Precedence on a -/// collision: manifest > redirect ledger > vendor ledger (matching VEX's -/// candidate merge in `commands::vex_sources`), then the lockfile's hosted -/// pins (`hosted_pins`, uuid only). Vendor entries are keyed by their -/// manifest-form ledger key (`detect_updates` bridges the spellings); a -/// legacy entry without an embedded record contributes its uuid alone. -/// Borrows the manifest untouched when nothing else contributes. +/// Fold the hosted pins and the vendor ledger's patch records into the +/// manifest view update detection consults. Hosted mode records purl→uuid +/// ONLY in the lockfiles (`hosted_pins`, uuid only; v5 keeps no hosted +/// ledger) and vendored mode ONLY in `.socket/vendor/state.json`, so without +/// this fold a pure hosted or vendored project's `updates[]` would always +/// be empty. Precedence on a collision: manifest > hosted pins > vendor +/// ledger (the live lock over a possibly superseded vendored entry). Vendor +/// entries are keyed by their manifest-form ledger key (`detect_updates` +/// bridges the spellings); a legacy entry without an embedded record +/// contributes its uuid alone. Borrows the manifest untouched when nothing +/// else contributes. pub(super) fn merge_ledger_records_for_updates<'a>( manifest: Option<&'a PatchManifest>, - redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, vendor: Option<&VendorState>, hosted_pins: &[(String, String)], ) -> Option> { - let redirect_records = redirect.map(|s| &s.records).filter(|r| !r.is_empty()); let vendor_entries = vendor.map(|s| &s.entries).filter(|e| !e.is_empty()); - if redirect_records.is_none() && vendor_entries.is_none() && hosted_pins.is_empty() { + if vendor_entries.is_none() && hosted_pins.is_empty() { return manifest.map(Cow::Borrowed); } + let uuid_only = |uuid: &str| PatchRecord { + uuid: uuid.to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + }; let mut merged = manifest.cloned().unwrap_or_default(); - for (purl, record) in redirect_records.into_iter().flatten() { + for (purl, uuid) in hosted_pins { merged .patches .entry(purl.clone()) - .or_insert_with(|| record.clone()); + .or_insert_with(|| uuid_only(uuid)); } for (purl, entry) in vendor_entries.into_iter().flatten() { merged.patches.entry(purl.clone()).or_insert_with(|| { - entry.record.clone().unwrap_or_else(|| PatchRecord { - uuid: entry.uuid.clone(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }) + entry + .record + .clone() + .unwrap_or_else(|| uuid_only(&entry.uuid)) }); } - for (purl, uuid) in hosted_pins { - merged - .patches - .entry(purl.clone()) - .or_insert_with(|| PatchRecord { - uuid: uuid.clone(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }); - } Some(Cow::Owned(merged)) } @@ -905,16 +893,16 @@ mod tests { } // ---- merge_ledger_records_for_updates ----------------------------------- - // Hosted mode records patches ONLY in the redirect ledger and vendored - // mode ONLY in the vendor ledger — these pin that ledger-only projects - // still surface `updates[]` (the documented CI signal) through the - // merged manifest view. + // Hosted mode records patches ONLY in the lockfiles (the hosted pins) and + // vendored mode ONLY in the vendor ledger — these pin that manifest-less + // projects still surface `updates[]` (the documented CI signal) through + // the merged manifest view. - fn ledger_with(entries: &[(&str, &str)]) -> socket_patch_core::patch::redirect::RedirectState { - let mut state = socket_patch_core::patch::redirect::RedirectState::new(); - let manifest = crate::commands::scan::tests::manifest_with(entries); - state.records.extend(manifest.patches); - state + fn pins(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(purl, uuid)| (purl.to_string(), uuid.to_string())) + .collect() } /// A vendor ledger with one entry per `(key, uuid, detached)`: detached @@ -946,12 +934,12 @@ mod tests { } #[test] - fn ledger_only_project_reports_superseding_patch_in_updates() { - // Pure hosted project: NO .socket/manifest.json, one redirected patch - // recorded in the ledger; discovery now offers a different (newer) - // uuid. The merged view must make detect_updates flag it. - let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-old")]); - let merged = merge_ledger_records_for_updates(None, Some(&ledger), None, &[]); + fn hosted_only_project_reports_superseding_patch_in_updates() { + // Pure hosted project: NO .socket/manifest.json, one hosted pin in + // the lockfile; discovery now offers a different (newer) uuid. The + // merged view must make detect_updates flag it. + let hosted = pins(&[("pkg:npm/foo@1.0", "uuid-old")]); + let merged = merge_ledger_records_for_updates(None, None, &hosted); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1); @@ -967,7 +955,7 @@ mod tests { // record still contributes its uuid — all detection reads. for detached in [true, false] { let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-old", detached)]); - let merged = merge_ledger_records_for_updates(None, None, Some(&vendor), &[]); + let merged = merge_ledger_records_for_updates(None, Some(&vendor), &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1, "detached={detached}"); @@ -976,16 +964,16 @@ mod tests { } // Still the top offer — no nag. let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-a", true)]); - let merged = merge_ledger_records_for_updates(None, None, Some(&vendor), &[]); + let merged = merge_ledger_records_for_updates(None, Some(&vendor), &[]); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; assert!(detect_updates(merged.as_deref(), &pkgs).is_empty()); } #[test] - fn ledger_record_matching_the_candidate_is_not_an_update() { - // The redirected patch is still the top offer — no nag. - let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let merged = merge_ledger_records_for_updates(None, Some(&ledger), None, &[]); + fn hosted_pin_matching_the_candidate_is_not_an_update() { + // The hosted patch is still the top offer — no nag. + let hosted = pins(&[("pkg:npm/foo@1.0", "uuid-a")]); + let merged = merge_ledger_records_for_updates(None, None, &hosted); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])]; assert!(detect_updates(merged.as_deref(), &pkgs).is_empty()); } @@ -994,33 +982,32 @@ mod tests { fn manifest_entry_wins_a_collision_with_a_ledger_record() { // A PURL present in every store is manifest-owned (same precedence as // VEX's candidate merge): the manifest's uuid is the "old" side; - // between the ledgers, the redirect record wins. + // between the other two, the live hosted pin wins over the vendor + // ledger's (possibly superseded) entry. let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-manifest")]); - let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-ledger")]); + let hosted = pins(&[("pkg:npm/foo@1.0", "uuid-pin")]); let vendor = vendor_ledger_with(&[("pkg:npm/foo@1.0", "uuid-vendor", true)]); - let merged = - merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor), &[]); + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&vendor), &hosted); let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])]; let updates = detect_updates(merged.as_deref(), &pkgs); assert_eq!(updates.len(), 1); assert_eq!(updates[0].old_uuid, "uuid-manifest"); - let merged = merge_ledger_records_for_updates(None, Some(&ledger), Some(&vendor), &[]); + let merged = merge_ledger_records_for_updates(None, Some(&vendor), &hosted); let updates = detect_updates(merged.as_deref(), &pkgs); - assert_eq!(updates[0].old_uuid, "uuid-ledger"); + assert_eq!(updates[0].old_uuid, "uuid-pin"); } #[test] - fn ledger_and_manifest_cover_disjoint_purls() { - // A mixed project (some deps applied via manifest, some hosted via - // the redirect ledger, some vendored) gets update detection across - // every store. + fn hosted_pins_and_manifest_cover_disjoint_purls() { + // A mixed project (some deps applied via manifest, some hosted in + // the lockfile, some vendored) gets update detection across every + // store. let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-f1")]); - let ledger = ledger_with(&[("pkg:npm/bar@2.0", "uuid-b1")]); + let hosted = pins(&[("pkg:npm/bar@2.0", "uuid-b1")]); let vendor = vendor_ledger_with(&[("pkg:npm/baz@3.0", "uuid-z1", true)]); - let merged = - merge_ledger_records_for_updates(Some(&manifest), Some(&ledger), Some(&vendor), &[]); + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&vendor), &hosted); let pkgs = vec![ batch_with("pkg:npm/foo@1.0", &["uuid-f2"]), batch_with("pkg:npm/bar@2.0", &["uuid-b2"]), @@ -1035,28 +1022,25 @@ mod tests { } #[test] - fn absent_or_empty_ledgers_leave_the_manifest_view_untouched() { - assert!(merge_ledger_records_for_updates(None, None, None, &[]).is_none()); - let pins = vec![("pkg:npm/foo@1.0.0".to_string(), "uuid-pin".to_string())]; - let merged = merge_ledger_records_for_updates(None, None, None, &pins).expect("pinned"); + fn absent_or_empty_stores_leave_the_manifest_view_untouched() { + assert!(merge_ledger_records_for_updates(None, None, &[]).is_none()); + let hosted = pins(&[("pkg:npm/foo@1.0.0", "uuid-pin")]); + let merged = merge_ledger_records_for_updates(None, None, &hosted).expect("pinned"); assert_eq!(merged.patches["pkg:npm/foo@1.0.0"].uuid, "uuid-pin"); - let empty = socket_patch_core::patch::redirect::RedirectState::new(); let empty_vendor = VendorState::new(); - assert!( - merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor), &[]).is_none() - ); + assert!(merge_ledger_records_for_updates(None, Some(&empty_vendor), &[]).is_none()); let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); - let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&empty), None, &[]) + let merged = merge_ledger_records_for_updates(Some(&manifest), Some(&empty_vendor), &[]) .expect("manifest present"); assert!( matches!(merged, Cow::Borrowed(_)), - "empty ledgers must not clone the manifest" + "empty stores must not clone the manifest" ); assert_eq!( merged.patches.len(), manifest.patches.len(), - "an empty ledger adds nothing" + "an empty vendor ledger adds nothing" ); } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 156f8bf40..6231254a6 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -26,15 +26,6 @@ pub(crate) mod vlt; pub(crate) use vlt::rollback_heal as vlt_rollback_heal; pub(crate) use vlt::takeover_heal as vlt_takeover_heal; -/// Fragment-edit kinds whose lockfile the package manager re-lays in place -/// (keeping the Socket source) — a re-scan REBASES their ledger edits instead -/// of appending; see the ledger merge below. -pub(crate) const REBASE_KINDS: &[&str] = &[ - "redirect_poetry_lock_package", - "redirect_pdm_lock_package", - socket_patch_core::patch::redirect::vlt::KIND, -]; - /// Candidate lockfiles / registry configs the redirect rewriters may touch — /// read from the project when present and handed to `rewrite_registry_redirect`. pub(crate) const REDIRECT_CANDIDATE_FILES: &[&str] = &[ @@ -1213,7 +1204,7 @@ pub(crate) async fn run_redirect_selected( ) -> i32 { use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect_withholding_vlt, RedirectState, + rewrite_registry_redirect_withholding_vlt, }; let mut skipped: Vec = Vec::new(); @@ -1409,15 +1400,12 @@ pub(crate) async fn run_redirect_selected( // The apply lock (see `acquire_hosted_lock`), taken only by a WET run // that holds at least one granted reference — the only runs that can // write anything: the takeover pre-reverts (lockfiles + the vendored - // ledger), the redirect-ledger merge and the lockfile writes. Dry runs + // ledger) and the lockfile writes. Dry runs // and zero-grant runs never touch `.socket/`, so they never lock (a // preview must not create `.socket/`, flip to `lock_held` under a - // concurrent wet run, or fail on a read-only checkout). Acquired BEFORE - // the ledger load so load → merge → persist is one critical section - // (rollback's rule: a ledger a run will persist is loaded under the - // lock) and held to the end of the function. Read below: it also gates - // the corrupt-ledger quarantine, the one write the load itself can make. - let lock: Option = if !common.dry_run && !candidates.is_empty() { + // concurrent wet run, or fail on a read-only checkout). Held to the end + // of the function. + let _lock: Option = if !common.dry_run && !candidates.is_empty() { match acquire_hosted_lock(common, &mut scan_result) { Ok(guard) => Some(guard), Err(code) => return code, @@ -1426,36 +1414,11 @@ pub(crate) async fn run_redirect_selected( None }; - // Load the existing redirect ledger before any file changes, including - // Cargo takeover reverts. It stores the originals a future revert needs, so - // a malformed (torn/hand-mangled) ledger must abort the run while the - // project is still untouched, or the merge below would silently - // overwrite that revert data. The malformed file is moved aside to - // redirect-state.json.corrupt (never clobbered) — but only by a run - // holding the apply lock; a dry run or zero-grant run reports the same - // error and moves nothing, so `.socket/vendor/` is never mutated - // lock-free. - // - // Held as the ONE in-memory ledger for the whole run: the write below - // merges into it in place, the stale-install probes read its records - // (persisted ones are the fallback when this run's /patches/view fetch - // fails transiently), and the takeover classification at the end reads - // the merged state. - let mut ledger = - match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { - Ok(state) => state.unwrap_or_else(RedirectState::new), - Err(mut corrupt) => { - if lock.is_some() { - corrupt.quarantine().await; - } - let message = corrupt.to_string(); - eprintln!("{}", format_error_line(&message)); - if common.json { - emit_json_error(scan_result.take(), &message); - } - return 1; - } - }; + // v5 hosted mode keeps no ledger: the lockfiles are the only record of + // a redirect (vex, list, vendor and rollback read the hosted pins from + // them). This run's patch records (fetched below) feed only the + // stale-install probes and the in-run VEX attestation. A pre-v5 ledger + // on disk is left untouched: it is read only for migration. // The vendored ledger, loaded ONCE per run (under the same lock, so no // other writer can move the on-disk file under it): the takeover below // mutates it in place per reverted purl (saving after each), and the @@ -2807,8 +2770,8 @@ pub(crate) async fn run_redirect_selected( "code": "record_fetch_failed", "detail": format!( "{purl} redirected, but its patch record could not be fetched; \ - it will be missing from VEX until `socket-patch scan --mode \ - hosted` is re-run" + this run's VEX attestation omits it (`socket-patch vex` \ + fetches it again once the API answers)" ), })); } @@ -2817,137 +2780,7 @@ pub(crate) async fn run_redirect_selected( status.finish(); } - // Whether this run persisted the redirect ledger (human next steps). - let mut ledger_written = false; if !common.dry_run { - // Ledger (mirrors the vendor state.json shape): recorded edits for a - // future revert + the patch records (file hashes + vulnerabilities) so - // a post-install `socket-patch vex` can attest the redirected patches. - // MERGE with any existing ledger rather than overwriting: an idempotent - // re-run produces no new edits (the lockfile already points at the - // hosted patch), and clobbering the file would lose the original - // pre-redirect values a future revert needs. New edits APPEND (revert - // walks them in reverse), skipping byte-identical re-plans from a - // retried partial failure; records are keyed by PURL, newest wins. - // - // Persisted BEFORE the project files, and atomically (stage + fsync + - // rename): a crash between the two leaves a complete ledger whose - // originals match files never rewritten, never rewritten files whose - // originals reached no ledger. - if !rewrite.edits.is_empty() || !records.is_empty() { - // Older ledgers carry `"mode": "redirect"`; normalize on rewrite - // (the loader accepts either). - ledger.mode = "hosted".to_string(); - // REBASE instead of append for fragment kinds whose file the - // package manager itself rewrites in place: when the ledger already - // holds edits for the same (path, kind, key) and the file no longer - // carried their `new` fragments before this run (Poetry 1.1/1.2 - // `poetry lock --no-update` keeps the Socket source but re-lays the - // unit and drops the inserted `files` line), appending this run's - // edits — recorded against the RELOCKED text — would build a chain - // whose older links match nothing, so rollback and remove refuse - // forever. Keeping the oldest `original` (the pristine - // fragment) and adopting the fresh `new` keeps the chain a single - // invertible link: replay swaps the fragment this run wrote back to - // the fragment the very first run found. - let vlt_merged = rebase_vlt_edits( - &mut ledger.edits, - &rewrite.edits, - files - .get(socket_patch_core::constants::npm_family::VLT_LOCK) - .map(String::as_str), - ); - let mut rebased: Vec = Vec::new(); - for edit in rewrite.edits.iter().filter(|e| { - REBASE_KINDS.contains(&e.kind.as_str()) - && e.kind != socket_patch_core::patch::redirect::vlt::KIND - }) { - let siblings: Vec = ledger - .edits - .iter() - .enumerate() - .filter(|(_, old)| { - old.path == edit.path && old.kind == edit.kind && old.key == edit.key - }) - .map(|(i, _)| i) - .collect(); - let before = files.get(&edit.path).map(String::as_str).unwrap_or(""); - let drifted = !siblings.is_empty() - && siblings.iter().all(|&i| { - ledger.edits[i] - .new - .as_ref() - .and_then(serde_json::Value::as_str) - .is_none_or(|new| !before.contains(new)) - }); - if !drifted { - continue; - } - // Positional pairing: the rewriter emits a key's fragments in a - // fixed order (package unit, then the legacy integrity entry). - let nth = rewrite - .edits - .iter() - .filter(|e| e.path == edit.path && e.kind == edit.kind && e.key == edit.key) - .position(|e| std::ptr::eq(e, edit)) - .unwrap_or(0); - if let Some(&target) = siblings.get(nth) { - if !rebased.contains(&target) { - // `pdm lock` fully un-patches the lock (registry source - // restored) and may reflow line endings (CRLF → LF), so - // the fresh run's `original` IS the correct - // relocked-registry rollback target and the stale - // recorded one would restore a mismatched fragment. - // Poetry's relock instead KEEPS the Socket source (it - // only drops the inserted `files` line), so its oldest - // `original` — the true pre-patch fragment — must - // survive; only its `new` is refreshed. - if edit.kind == "redirect_pdm_lock_package" { - ledger.edits[target].original = edit.original.clone(); - } - ledger.edits[target].new = edit.new.clone(); - ledger.edits[target].action = edit.action.clone(); - rebased.push(target); - } - } - } - // Dedup against the ledger as this run found it, never within - // this run: one run legitimately records identical edits (a - // Cargo.toml declaring the crate with the same line in two - // sections), and each one reverts one occurrence. - let recorded = ledger.edits.len(); - for (i, edit) in rewrite.edits.iter().enumerate() { - if vlt_merged[i] { - continue; - } - let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) - && rebased.iter().any(|&t| { - let old = &ledger.edits[t]; - old.path == edit.path - && old.kind == edit.kind - && old.key == edit.key - && old.new == edit.new - }); - if !is_rebased && !ledger.edits[..recorded].contains(edit) { - ledger.edits.push(edit.clone()); - } - } - ledger.records.extend(records); - // The ledger is the only revert path and the VEX record store — - // a swallowed write failure would let the lockfile writes below - // proceed with no revert data persisted while reporting success. - let saved = - socket_patch_core::patch::redirect::save_redirect_state(&common.cwd, &ledger).await; - ledger_written = saved.is_ok(); - if let Err(e) = saved { - let message = format!("failed to write .socket/vendor/redirect-state.json: {e}"); - eprintln!("{}", format_error_line(&message)); - if common.json { - emit_json_error(scan_result.take(), &message); - } - return 1; - } - } for (rel, content) in rewrite .files .iter() @@ -3004,7 +2837,7 @@ pub(crate) async fn run_redirect_selected( common.global, common.global_prefix.clone(), &confirmed, - &ledger.records, + &records, &gem_artifact_shas, ) .await @@ -3016,7 +2849,7 @@ pub(crate) async fn run_redirect_selected( common, &confirmed, &rewrite.confirmed_pipenv_uuids, - &ledger.records, + &records, ) .await }; @@ -3038,7 +2871,7 @@ pub(crate) async fn run_redirect_selected( .or_else(|| files.get(lock_key)) .map(String::as_str), preflight: &vlt_preflight, - records: &ledger.records, + records: &records, confirmed: &confirmed, confirmed_vlt: &rewrite.confirmed_vlt_uuids, foreign: &rewrite.vlt_foreign_uuids, @@ -3049,23 +2882,23 @@ pub(crate) async fn run_redirect_selected( }; // Cross-mode takeover: a committed vendored ledger (`.socket/vendor/state.json`) - // may still claim package(s) this project also has a hosted redirect ledger - // for — their tarballs would then be orphaned and that ledger stale. But the - // overlap alone does NOT prove hosted won: only warn for the package(s) the - // LIVE lockfile actually routes to the hosted patch server (see - // `classify_overlap_takeover`), so a dry-run / no-op over a lock that still - // points at the vendored files stays silent instead of pointing cleanup at - // the live vendored ledger. The takeover pre-revert above already - // reconciled what it could; this only warns (JSON `warnings[]` and - // stderr) about any overlap left, WITHOUT deleting the other ledger. - // Classified over this run's in-memory ledgers — the redirect ledger as - // merged and persisted above, the vendored ledger as the takeover left - // it — so a non-dry-run reflects this run without re-reading either file. + // may still claim package(s) the lockfiles now pin hosted — their + // tarballs would then be orphaned and that ledger stale. But the overlap + // alone does NOT prove hosted won: only warn for the package(s) the LIVE + // lockfile actually routes to the hosted patch server (see + // `classify_overlap_takeover`), so a dry-run / no-op over a lock that + // still points at the vendored files stays silent instead of pointing + // cleanup at the live vendored ledger. The takeover pre-revert above + // already reconciled what it could; this only warns (JSON `warnings[]` + // and stderr) about any overlap left, WITHOUT deleting the other ledger. + // Classified over the lockfiles as this run left them and the vendored + // ledger as the takeover left it. let mut takeover_warnings: Vec = Vec::new(); + let hosted_now = crate::commands::hosted_state_from_lockfiles(common, &common.cwd).await; let superseded = super::classify_overlap_takeover_with( common, &common.cwd, - Some(&ledger), + Some(&hosted_now), vendor_state.as_ref().ok(), ) .await @@ -3073,7 +2906,7 @@ pub(crate) async fn run_redirect_selected( if !superseded.is_empty() { takeover_warnings.push(serde_json::json!({ "code": super::REDIRECT_SUPERSEDES_VENDORED, - "detail": super::mode_takeover_detail(&superseded, /*current_is_hosted=*/ true), + "detail": super::mode_takeover_detail(&superseded), })); } @@ -3088,7 +2921,7 @@ pub(crate) async fn run_redirect_selected( // Emit an OpenVEX attestation when `--vex` was requested. The redirected // bytes are fetched from the hosted patch server at install time, so the - // PURLs CONFIRMED REDIRECTED BY THIS RUN are attested from the ledger + // PURLs CONFIRMED REDIRECTED BY THIS RUN are attested from this run's // records WITHOUT hash verification (`assume_applied` — the integrity // pins written into the lockfile are the evidence), while any OTHER // manifest patches (previously applied / vendored — and any stale ledger @@ -3110,6 +2943,9 @@ pub(crate) async fn run_redirect_selected( // it was taken with these crawler options). `get --mode hosted` // passes none. params.npm_prior = npm_prior.cloned(); + // v5 keeps no hosted ledger: this run's fetched records are the + // hosted record source of the in-run attestation. + params.hosted_records = records.clone(); // Stale-flagged purls are EXCLUDED from assume_applied: the same-run // envelope carries a redirect_gem_stale_install warning proving the // installed materialization unpatched, so attesting that purl from @@ -3295,7 +3131,7 @@ pub(crate) async fn run_redirect_selected( if let Some(statements) = vex_statements { eprintln!( "Wrote OpenVEX document with {} to {} (redirected patches are attested \ - from the ledger, not hash-verified — their bytes are fetched at install \ + from their patch records, not hash-verified — their bytes are fetched at install \ time; run `socket-patch vex` after installing to verify against the \ installed tree).", crate::ui::plural(statements, "statement", "statements"), @@ -3312,7 +3148,7 @@ pub(crate) async fn run_redirect_selected( } if !common.dry_run { for line in - format_next_steps(&human_files, ledger_written, !takeover_migrated.is_empty()) + format_next_steps(&human_files, !takeover_migrated.is_empty()) { println!("{line}"); } @@ -3614,30 +3450,17 @@ fn join_names(names: &[String], max: usize) -> String { /// Next steps after a wet run that rewrote files (stdout, after the /// summary — the same place vendored mode prints its own): commit the -/// ledger and the rewritten files, reinstall so the installed tree picks -/// up the patched artifacts, then verify with `vex`. After a -/// vendored→hosted takeover (`vendored_removed`) the commit also has to -/// carry the deleted vendored ledger entries and artifacts, so the whole -/// `.socket/vendor/` directory is named instead of the redirect ledger. -fn format_next_steps( - files: &[String], - ledger_written: bool, - vendored_removed: bool, -) -> Vec { +/// rewritten files, reinstall so the installed tree picks up the patched +/// artifacts, then verify with `vex`. After a vendored→hosted takeover +/// (`vendored_removed`) the commit also has to carry the deleted vendored +/// ledger entries and artifacts. +fn format_next_steps(files: &[String], vendored_removed: bool) -> Vec { if files.is_empty() && !vendored_removed { return Vec::new(); } let mut commit: Vec = Vec::new(); if vendored_removed { - commit.push(if ledger_written { - ".socket/vendor/ (the redirect ledger, plus the removed vendored ledger entries and \ - artifacts)" - .to_string() - } else { - ".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string() - }); - } else if ledger_written { - commit.push(".socket/vendor/redirect-state.json".to_string()); + commit.push(".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string()); } commit.extend(files.iter().cloned()); let npm = files @@ -3660,91 +3483,6 @@ fn format_next_steps( steps } -/// Merge this run's vlt node edits into the recorded ones. A fresh edit -/// for the same `key` and DepID keeps the oldest recorded `original` (the -/// pristine registry entry), takes the fresh `new` and drops the chain's -/// later links (a server-written ledger appends one per hosted PR). One -/// whose recorded same-key edits all name DepIDs the pre-run lock no longer -/// holds (a re-lock, or a new id grammar after a vlt upgrade) replaces -/// them. So does one for another key of the same `name@version` whose -/// vanished recorded edit's pin vlt carried to the fresh DepID (a new peer -/// context). A replacing edit keeps the recorded pristine slots when vlt -/// carried the pin ([`carried_pin_original`]). Returns, per fresh edit, -/// whether it was merged (anything else is appended as usual). -pub(crate) fn rebase_vlt_edits( - ledger: &mut Vec, - fresh: &[socket_patch_core::patch::redirect::FileEdit], - before_lock: Option<&str>, -) -> Vec { - use socket_patch_core::patch::redirect::vlt::{ - carried_pin_original, edit_dep_id, lock_node_ids, KIND, - }; - use socket_patch_core::patch::redirect::FileEdit; - fn superseding(edit: &FileEdit, old: &FileEdit) -> FileEdit { - let mut next = edit.clone(); - if let Some(original) = carried_pin_original(edit, old) { - next.original = Some(original); - } - next - } - fn key_base(key: &Option) -> Option<&str> { - key.as_deref() - .map(|k| k.split_once('~').map_or(k, |(base, _)| base)) - } - let live = before_lock.and_then(lock_node_ids).unwrap_or_default(); - let mut merged = vec![false; fresh.len()]; - for (i, edit) in fresh.iter().enumerate() { - if edit.kind != KIND { - continue; - } - let id = edit_dep_id(edit); - let same_key: Vec = ledger - .iter() - .enumerate() - .filter(|(_, old)| old.kind == KIND && old.path == edit.path && old.key == edit.key) - .map(|(j, _)| j) - .collect(); - let same_dep: Vec = same_key - .iter() - .copied() - .filter(|&j| id.is_some() && edit_dep_id(&ledger[j]) == id) - .collect(); - if let Some((&first, rest)) = same_dep.split_first() { - ledger[first].new = edit.new.clone(); - ledger[first].action = edit.action.clone(); - for &j in rest.iter().rev() { - ledger.remove(j); - } - merged[i] = true; - continue; - } - let vanished = |j: &usize| edit_dep_id(&ledger[*j]).is_none_or(|old| !live.contains(&old)); - let gone: Vec = same_key.into_iter().filter(vanished).collect(); - if let Some((&first, rest)) = gone.split_first() { - ledger[first] = superseding(edit, &ledger[first]); - for &j in rest.iter().rev() { - ledger.remove(j); - } - merged[i] = true; - continue; - } - let rekeyed = (0..ledger.len()).find(|j| { - let old = &ledger[*j]; - old.kind == KIND - && old.path == edit.path - && old.key != edit.key - && key_base(&old.key) == key_base(&edit.key) - && vanished(j) - && carried_pin_original(edit, old).is_some() - }); - if let Some(j) = rekeyed { - ledger[j] = superseding(edit, &ledger[j]); - merged[i] = true; - } - } - merged -} - /// Transient-frame boxed constructor for [`run_redirect_selected`] — the /// future embeds the whole hosted engine, and callers outside scan (`get /// --mode hosted`) must not materialize it in their own poll frame (Windows @@ -3789,10 +3527,9 @@ mod tests { pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, sentence_case, split_sentences, wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, }; - use super::{rebase_vlt_edits, REBASE_KINDS}; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; use socket_patch_core::constants::npm_family; - use socket_patch_core::patch::redirect::{DepOverride, FileEdit}; + use socket_patch_core::patch::redirect::DepOverride; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; /// The wheel window is a patch-API window, so the documented escape @@ -5379,14 +5116,12 @@ mod tests { } #[test] - fn next_steps_name_the_ledger_files_and_reinstall() { - assert!(format_next_steps(&[], true, false).is_empty()); + fn next_steps_name_the_rewritten_files_and_reinstall() { + assert!(format_next_steps(&[], false).is_empty()); assert_eq!( - format_next_steps(&["package-lock.json".to_string()], true, false), + format_next_steps(&["package-lock.json".to_string()], false), vec![ - "Commit .socket/vendor/redirect-state.json and package-lock.json to keep the \ - redirect." - .to_string(), + "Commit package-lock.json to keep the redirect.".to_string(), "Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages \ pick up the patched artifacts, then run `socket-patch vex` to verify them." .to_string(), @@ -5398,7 +5133,6 @@ mod tests { "pnpm-workspace.yaml".to_string(), ], false, - false, ); assert_eq!( steps[0], @@ -5409,196 +5143,22 @@ mod tests { #[test] fn next_steps_add_the_vlt_ci_line_only_for_a_rewritten_vlt_lock() { - let steps = format_next_steps(&["vlt-lock.json".to_string()], true, false); + let steps = format_next_steps(&["vlt-lock.json".to_string()], false); assert_eq!( steps.last().map(String::as_str), Some("vlt: commit vlt-lock.json; CI should run `vlt ci`") ); assert!( - !format_next_steps(&["package-lock.json".to_string()], true, false) + !format_next_steps(&["package-lock.json".to_string()], false) .iter() .any(|s| s.starts_with("vlt:")) ); } - fn vlt_edit(key: &str, id: &str, slot2: &str, slot3: &str) -> FileEdit { - FileEdit { - path: "vlt-lock.json".into(), - kind: socket_patch_core::patch::redirect::vlt::KIND.into(), - action: "rewritten".into(), - key: Some(key.into()), - original: Some(serde_json::Value::String(format!( - "\"{id}\": [0,\"x\",\"sha512-reg\",null]" - ))), - new: Some(serde_json::Value::String(format!( - "\"{id}\": [0,\"x\",\"{slot2}\",\"{slot3}\"]" - ))), - } - } - - fn vlt_lock_with(ids: &[&str]) -> String { - let nodes: Vec = ids - .iter() - .map(|id| format!(" \"{id}\": [0,\"x\"]")) - .collect(); - format!( - "{{\n \"lockfileVersion\": 1,\n \"nodes\": {{\n{}\n }},\n \"edges\": {{}}\n}}\n", - nodes.join(",\n") - ) - } - - #[test] - fn vlt_rerun_keeps_the_pristine_original_for_the_same_dep_id() { - assert!(REBASE_KINDS.contains(&socket_patch_core::patch::redirect::vlt::KIND)); - let mut ledger = vec![vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-p1", "u1")]; - let mut fresh = vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-p2", "u2"); - fresh.original = ledger[0].new.clone(); - let merged = rebase_vlt_edits( - &mut ledger, - std::slice::from_ref(&fresh), - Some(&vlt_lock_with(&["~npm~x@1.0.0"])), - ); - assert_eq!(merged, [true]); - assert_eq!(ledger.len(), 1); - assert_eq!( - ledger[0].original, - vlt_edit("x@1.0.0", "~npm~x@1.0.0", "", "").original - ); - assert_eq!(ledger[0].new, fresh.new); - } - - #[test] - fn vlt_relocked_dep_id_supersedes_the_recorded_edits() { - let mut ledger = vec![ - vlt_edit("x@1.0.0", "··x@1.0.0", "sha512-p", "u"), - vlt_edit("x@1.0.0", "·npm·x@1.0.0", "sha512-p", "u"), - vlt_edit("y@1.0.0", "·npm·y@1.0.0", "sha512-p", "u"), - ]; - let fresh = vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-p", "u"); - let merged = rebase_vlt_edits( - &mut ledger, - std::slice::from_ref(&fresh), - Some(&vlt_lock_with(&["~npm~x@1.0.0", "·npm·y@1.0.0"])), - ); - assert_eq!(merged, [true]); - assert_eq!( - ledger, - [fresh, vlt_edit("y@1.0.0", "·npm·y@1.0.0", "sha512-p", "u")] - ); - } - - fn vlt_pinned_at(edit: &FileEdit, id: &str) -> Option { - let new = edit.new.as_ref()?.as_str()?; - let (_, tuple) = new.split_once(": ")?; - Some(serde_json::Value::String(format!("\"{id}\": {tuple}"))) - } - - #[test] - fn vlt_rerun_collapses_a_server_appended_chain_into_one_link() { - let first = vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-pa", "ua"); - let mut second = vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-pb", "ub"); - second.original = first.new.clone(); - let mut ledger = vec![first, second.clone()]; - let mut fresh = vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-pc", "uc"); - fresh.original = second.new.clone(); - let merged = rebase_vlt_edits( - &mut ledger, - std::slice::from_ref(&fresh), - Some(&vlt_lock_with(&["~npm~x@1.0.0"])), - ); - assert_eq!(merged, [true]); - assert_eq!( - ledger, - [FileEdit { - original: vlt_edit("x@1.0.0", "~npm~x@1.0.0", "", "").original, - ..fresh - }] - ); - } - - #[test] - fn vlt_rerun_after_a_peer_context_rekey_keeps_the_pristine_slots() { - let old_id = "~npm~x@1.0.0~peer.0df72515a50372ba"; - let new_id = "~npm~x@1.0.0~peer.32643a3290c32d5d"; - let recorded = vlt_edit("x@1.0.0~peer.0df72515a50372ba", old_id, "sha512-p1", "u1"); - let mut ledger = vec![ - vlt_edit("y@1.0.0", "~npm~y@1.0.0", "sha512-p", "u"), - recorded.clone(), - ]; - let mut fresh = vlt_edit("x@1.0.0~peer.32643a3290c32d5d", new_id, "sha512-p2", "u2"); - fresh.original = vlt_pinned_at(&recorded, new_id); - let merged = rebase_vlt_edits( - &mut ledger, - std::slice::from_ref(&fresh), - Some(&vlt_lock_with(&[new_id, "~npm~y@1.0.0"])), - ); - assert_eq!(merged, [true]); - assert_eq!(ledger.len(), 2); - assert_eq!(ledger[1].key, fresh.key); - assert_eq!(ledger[1].new, fresh.new); - assert_eq!( - ledger[1].original, - Some(serde_json::Value::String(format!( - "\"{new_id}\": [0,\"x\",\"sha512-reg\"]" - ))) - ); - - let mut pristine = vec![recorded.clone()]; - let relocked = vlt_edit("x@1.0.0~peer.32643a3290c32d5d", new_id, "sha512-p2", "u2"); - let merged = rebase_vlt_edits( - &mut pristine, - std::slice::from_ref(&relocked), - Some(&vlt_lock_with(&[new_id])), - ); - assert_eq!(merged, [false], "a re-lock that dropped the pin appends"); - assert_eq!(pristine, [recorded]); - } - - #[test] - fn vlt_relocked_dep_id_that_kept_the_pin_keeps_the_pristine_slots() { - let recorded = vlt_edit("x@1.0.0", "·npm·x@1.0.0", "sha512-p1", "u1"); - let mut ledger = vec![recorded.clone()]; - let mut fresh = vlt_edit("x@1.0.0", "~npm~x@1.0.0", "sha512-p2", "u2"); - fresh.original = vlt_pinned_at(&recorded, "~npm~x@1.0.0"); - let merged = rebase_vlt_edits( - &mut ledger, - std::slice::from_ref(&fresh), - Some(&vlt_lock_with(&["~npm~x@1.0.0"])), - ); - assert_eq!(merged, [true]); - assert_eq!( - ledger, - [FileEdit { - original: Some(serde_json::Value::String( - "\"~npm~x@1.0.0\": [0,\"x\",\"sha512-reg\"]".into() - )), - ..fresh - }] - ); - } - - #[test] - fn vlt_edit_of_a_live_sibling_dep_id_is_appended() { - let mut ledger = vec![vlt_edit("x@1.0.0", "··x@1.0.0", "sha512-p", "u")]; - let fresh = vlt_edit("x@1.0.0", "·npm·x@1.0.0", "sha512-p", "u"); - let merged = rebase_vlt_edits( - &mut ledger, - std::slice::from_ref(&fresh), - Some(&vlt_lock_with(&["··x@1.0.0", "·npm·x@1.0.0"])), - ); - assert_eq!(merged, [false]); - assert_eq!(ledger.len(), 1); - } - #[test] fn next_steps_after_a_takeover_name_the_removed_vendored_state() { assert_eq!( - format_next_steps(&["package-lock.json".to_string()], true, true)[0], - "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ - entries and artifacts) and package-lock.json to keep the redirect." - ); - assert_eq!( - format_next_steps(&["pnpm-lock.yaml".to_string()], false, true)[0], + format_next_steps(&["pnpm-lock.yaml".to_string()], true)[0], "Commit .socket/vendor/ (the removed vendored ledger entries and artifacts) and \ pnpm-lock.yaml to keep the redirect." ); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 20ab9896f..0b9ff3c37 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -21,7 +21,7 @@ use socket_patch_core::telemetry::{ spawn_patch_scan_failed, spawn_patch_scanned, PendingTelemetry, }; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; -use socket_patch_core::utils::purl::{normalize_purl, purl_name_version, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::vendor::VendorState; use socket_patch_core::vex::discover::{LedgerLiveness, WiringMode}; use std::collections::{HashMap, HashSet}; @@ -687,19 +687,6 @@ async fn fetch_patch_details( (results, failures) } -/// The human hosted arm's stand-in for the lenient loader's advisory: a -/// malformed redirect ledger the engine would report as a hard error, on a -/// run that returned BEFORE the engine (empty discovery, nothing -/// downloadable, a detail-fetch failure). Read-only — -/// the file is never moved; `--silent` mutes it like every advisory. -fn warn_unreported_corrupt_ledger(common: &crate::args::GlobalArgs, corrupt: Option<&str>) { - if let Some(corrupt) = corrupt { - if !common.silent { - eprintln!("Warning: {corrupt}"); - } - } -} - /// Fold a [`discover_selected`] failure into a JSON caller's `result` and /// print it. The discovery counts already in `result` stay — they were /// computed from the (successful) batch phase — while `status`/`error` @@ -800,6 +787,7 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - strict: args.common.strict, ecosystems: args.common.ecosystems.clone(), persist_blobs: args.mode != Some(ScanMode::Vendored), + patch_server_url: args.common.patch_server_url.clone(), } } @@ -816,33 +804,29 @@ fn download_run<'a>(args: &ScanArgs, api_client: &'a ApiClient) -> DownloadRun<' } // --------------------------------------------------------------------------- -// Cross-mode ledger takeover detection (hosted ⇄ vendored) +// Cross-mode takeover detection (hosted over vendored) // --------------------------------------------------------------------------- // -// Hosted mode writes `.socket/vendor/redirect-state.json`; vendored mode -// writes `.socket/vendor/state.json` (+ committed tarballs). Switching a -// project's mode rewires the lockfile but leaves the OLD mode's ledger on -// disk asserting wiring that is no longer live, which misleads anything -// auditing a ledger (including `vex`). Detect the overlap so each flow can -// warn. The VENDORED flows clean the superseded redirect-ledger halves -// themselves (always announced); the HOSTED direction stays warn-only -// (removing a vendored entry deletes committed artifacts — `remove -// `'s job). +// Vendored mode writes `.socket/vendor/state.json` (+ committed tarballs); +// hosted mode keeps no ledger — its lockfile pins are the only record. +// Redirecting a vendored package to the hosted patch server rewires the +// lockfile but leaves the vendored ledger entry on disk asserting wiring that +// is no longer live, which misleads anything auditing the ledger (including +// `vex`). Detect the overlap so the hosted flow can warn (removing a vendored +// entry deletes committed artifacts — `remove `'s job). The reverse +// direction needs no advisory: once the lock routes a package to +// `.socket/vendor/`, no hosted state is left to go stale. // -// The overlap only proves BOTH ledgers name the package, not which won. The -// takeover DIRECTION comes from the current lockfile wiring per package -// (`classify_overlap_takeover`), never from which command is running; -// remediation points at the ledger that does NOT match the live lock, and a -// package the lock proves neither way stays silent. +// The overlap only proves the vendored ledger and a hosted pin both name the +// package, not which won. The takeover DIRECTION comes from the current +// lockfile wiring per package (`classify_overlap_takeover`), never from +// which command is running, and a package the lock proves neither way stays +// silent. /// Warning code emitted by the HOSTED flow when it just redirected package(s) /// a committed vendored ledger still claims (its tarballs are now orphaned). pub(super) const REDIRECT_SUPERSEDES_VENDORED: &str = "redirect_supersedes_vendored"; -/// Warning code emitted by the VENDORED flow when it just vendored package(s) -/// a committed hosted redirect ledger still claims. -pub(super) const VENDOR_SUPERSEDES_REDIRECT: &str = "vendor_supersedes_redirect"; - /// Warning code + detail emitted when `--prune` is combined with /// `--mode hosted`: the hosted flow runs no GC, so the flag would otherwise /// be silently dropped. `--prune` stays accepted (CLI_CONTRACT.md: an @@ -854,12 +838,12 @@ pub(super) const REDIRECT_PRUNE_IGNORED_DETAIL: &str = runs no GC sweep of `.socket/` state; run `scan --mode agent --prune` or \ `scan --mode vendored --prune` to garbage-collect"; -/// The PURLs claimed by BOTH the hosted redirect ledger -/// (`.socket/vendor/redirect-state.json`) and the vendored state ledger -/// (`.socket/vendor/state.json`), sorted, over already-loaded ledgers. A -/// non-empty result means exactly one of the two ledgers is stale for each -/// PURL (a lockfile entry can point only one way). `None`, an empty vendor -/// ledger, or disjoint ledgers (a legitimate split) yield no overlap. +/// The PURLs claimed by BOTH a hosted pin (`redirect`, the lockfiles' +/// hosted state — see [`crate::commands::hosted_state_from_lockfiles`]) and +/// the vendored state ledger (`.socket/vendor/state.json`), sorted. A +/// non-empty result means one of the two is stale for each PURL (a +/// lockfile entry can point only one way). `None`, an empty vendor ledger, +/// or disjoint states (a legitimate split) yield no overlap. fn overlap_from_states( redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, vendor: &VendorState, @@ -867,54 +851,23 @@ fn overlap_from_states( let Some(redirect) = redirect else { return Vec::new(); }; - if vendor.entries.is_empty() { + if vendor.entries.is_empty() || redirect.records.is_empty() { return Vec::new(); } - // Canonicalize both sides (drop qualifiers, percent-decode) so the API - // purl form the redirect records carry matches the vendor entry's base - // purl — mirrors `vendored_ledger_supplement`. + // Canonicalize both sides (drop qualifiers, percent-decode) so the + // hosted pin's purl matches the vendor entry's base purl — mirrors + // `vendored_ledger_supplement`. let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let mut vendor_purls: std::collections::BTreeSet = std::collections::BTreeSet::new(); for (key, entry) in &vendor.entries { vendor_purls.insert(canon(key)); vendor_purls.insert(canon(&entry.base_purl)); } - if !redirect.records.is_empty() { - let redirect_purls: std::collections::BTreeSet = - redirect.records.keys().map(|p| canon(p)).collect(); - return redirect_purls - .intersection(&vendor_purls) - .cloned() - .collect(); - } - // The records map can be EMPTY while the ledger still asserts stale lock - // wiring (every per-uuid record fetch failed: `record_fetch_failed`), so - // fall back to matching the vendored purls against the recorded edit - // keys — npm `node_modules/` (possibly nested), pnpm/yarn/cargo/uv - // `@` (vlt `@~`), bun - // `/`, gem/composer/pypi bare ``. Name-level matching - // can over-claim across versions, but `classify_overlap_takeover` still - // requires the live lock to prove one side before anything is reported. - if redirect.edits.is_empty() { - return Vec::new(); - } - vendor_purls - .into_iter() - .filter(|purl| { - let Some((name, version)) = purl_name_version(strip_purl_qualifiers(purl)) else { - return false; - }; - redirect - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .any(|key| { - key == name - || key == format!("{name}@{version}") - || key.starts_with(&format!("{name}@{version}~")) - || key.ends_with(&format!("/{name}")) - }) - }) + let redirect_purls: std::collections::BTreeSet = + redirect.records.keys().map(|p| canon(p)).collect(); + redirect_purls + .intersection(&vendor_purls) + .cloned() .collect() } @@ -926,7 +879,7 @@ fn overlap_from_states( /// / `Discovery::vendor_entry_live`). `redirect` holds the overlap PURLs the /// lock routes to the hosted patch server (the vendored ledger entry is /// stale); `vendored` holds those it routes to a committed -/// `.socket/vendor//` artifact (the redirect record is stale). +/// `.socket/vendor//` artifact. /// /// A PURL the lock proves NEITHER way — a dry-run/no-op that did not rewire it, /// a half-migrated lock naming both, or an ecosystem whose live spec we cannot @@ -936,27 +889,25 @@ fn overlap_from_states( pub(super) struct OverlapTakeover { /// Overlap PURLs whose vendored ledger is stale (lock points hosted). pub redirect: Vec, - /// Overlap PURLs whose redirect ledger is stale (lock points vendored). + /// Overlap PURLs the lock routes to the vendored artifact. pub vendored: Vec, } +/// [`classify_overlap_takeover_with`] over the on-disk state: the +/// lockfiles' hosted pins and the committed vendored ledger. +#[cfg(test)] pub(super) async fn classify_overlap_takeover(common: &GlobalArgs, cwd: &Path) -> OverlapTakeover { - // Both ledgers loaded ONCE here. A malformed ledger classifies like a - // missing one, matching `overlap_from_states` (this path only - // feeds takeover warnings; corruption is a hard error on the + // A malformed vendor ledger classifies like a missing one (this path + // only feeds takeover warnings; corruption is a hard error on the // write/attest paths). - let redirect = socket_patch_core::patch::redirect::load_redirect_state(cwd) - .await - .ok() - .flatten(); + let redirect = crate::commands::hosted_state_from_lockfiles(common, cwd).await; let vendor = socket_patch_core::vendor::load_state(cwd).await.ok(); - classify_overlap_takeover_with(common, cwd, redirect.as_ref(), vendor.as_ref()).await + classify_overlap_takeover_with(common, cwd, Some(&redirect), vendor.as_ref()).await } -/// [`classify_overlap_takeover`] over already-loaded ledgers (the hosted -/// engine must classify against its in-memory post-merge / post-takeover -/// copies, never a pre-takeover snapshot); still reads the LIVE lockfiles -/// in `cwd`. `None` for either ledger yields no overlap. +/// [`classify_overlap_takeover`] over already-loaded state (the hosted +/// engine classifies against its post-takeover vendor ledger); still reads +/// the LIVE lockfiles in `cwd`. `None` for either yields no overlap. pub(super) async fn classify_overlap_takeover_with( common: &GlobalArgs, cwd: &Path, @@ -984,10 +935,8 @@ pub(super) async fn classify_overlap_takeover_with( .entry(canon(&entry.base_purl)) .or_insert(entry); } - // The hosted proof needs the redirect ledger too: each record's patch - // uuid (embedded in every hosted artifact URL, whatever the host) and - // the lockfiles the redirect actually edited. A non-empty overlap - // proves the ledger is `Some`. + // Each hosted pin's patch uuid (embedded in every hosted artifact URL, + // whatever the host). A non-empty overlap proves `redirect` is `Some`. let mut redirect_uuid_by_purl: std::collections::HashMap = std::collections::HashMap::new(); for (key, record) in redirect.iter().flat_map(|r| &r.records) { @@ -996,12 +945,10 @@ pub(super) async fn classify_overlap_takeover_with( .or_insert(record.uuid.as_str()); } let discovery = crate::commands::discover_wiring(common, cwd).await; - let mut liveness = LedgerLiveness::new(cwd, &discovery, redirect); + let mut liveness = LedgerLiveness::new(cwd, &discovery, None); for purl in overlap { let hosted_live = match redirect_uuid_by_purl.get(&purl) { Some(uuid) => liveness.redirect_record(&purl, uuid).await, - // An edits-only ledger (every record fetch failed) names no - // uuid: the lock's own hosted wiring of the package decides. None => discovery.wires_package(&purl, WiringMode::Hosted), }; let vendored_live = match vendor_by_purl.get(&purl) { @@ -1021,159 +968,42 @@ pub(super) async fn classify_overlap_takeover_with( out } -/// Human-readable detail for a mode-takeover warning naming the displaced -/// package(s). `current_is_hosted` selects the direction: `true` when a -/// hosted redirect displaced a vendored ledger, `false` when a vendored run -/// displaced a hosted redirect ledger. +/// Human-readable detail for the hosted-over-vendored takeover warning +/// ([`REDIRECT_SUPERSEDES_VENDORED`]) naming the displaced package(s). /// /// The warning fires PER PACKAGE, so the remediation is per-package and -/// non-destructive: never delete a whole ledger file or a whole -/// `.socket/vendor//` tree, which may still carry LIVE data for -/// packages this takeover did not touch (other records VEX reads, the -/// pre-redirect originals that are the only revert data, other vendored -/// uuid dirs). -/// -/// It must also be COMPLETE per package, or it does not converge: -/// -/// * The vendored direction names the package's `edits` entry alongside its -/// `records` entry: `overlap_from_states` falls back to edit KEYS once -/// `records` is empty, so a records-only cleanup keeps this warning firing. -/// * The hosted direction states `socket-patch remove`'s full blast radius, -/// including the package's `.socket/manifest.json` entry. -pub(super) fn mode_takeover_detail(superseded: &[String], current_is_hosted: bool) -> String { +/// non-destructive: never delete a whole `.socket/vendor//` tree, which +/// may still carry LIVE data for packages this takeover did not touch. It +/// states `socket-patch remove`'s full blast radius, including the +/// package's `.socket/manifest.json` entry. +pub(super) fn mode_takeover_detail(superseded: &[String]) -> String { let list = superseded.join(", "); - if current_is_hosted { - // NEVER offer deleting the `.socket/vendor//` tree here: for - // cargo the leftover `[patch.crates-io]` entry still points at that - // tree, and deleting it hard-fails every cargo invocation ("failed to - // load source for dependency"). Nor `vendor --revert`, which unwinds - // EVERY vendored package including the ones still live in the - // lockfile — `remove ` is the per-package equivalent. - format!( - "hosted redirect superseded the vendored ledger for: {list}. \ - `.socket/vendor/state.json` still claims these package(s) and their \ - committed artifacts under `.socket/vendor/` are now orphaned — the \ - lockfile points at the hosted patch server, not the vendored files. \ - Clean up per package: run `socket-patch remove ` for each \ - package listed above, so audits and VEX do not read superseded \ - wiring. It drops that package's vendored ledger entry and its own \ - `.socket/vendor///` artifact directory, AND deletes that \ - package's now-superseded `.socket/manifest.json` entry — that entry \ - describes the vendored delivery, while the live hosted patch is \ - recorded in `.socket/vendor/redirect-state.json`, which `remove` \ - never touches. In-place file rollback is skipped for vendor-owned \ - package(s), so the installed tree is left as the lockfile wires it; \ - preview with `--dry-run` first. Do not delete the whole \ - `.socket/vendor//` tree and do not run `vendor --revert`: \ - other vendored package(s) may still be live in the lockfile and \ - would break or be mass-reverted." - ) - } else { - // NEVER advise deleting the redirect ledger by hand: it may hold the - // only revert data (FileEdit originals) and VEX records for OTHER - // packages that are still hosted-redirected. - format!( - "vendored artifacts superseded the hosted redirect ledger for: {list}. \ - `.socket/vendor/redirect-state.json` still records a hosted redirect for \ - these package(s), but the lockfile now points at the committed \ - `.socket/vendor/` files. The vendored flows (`socket-patch vendor`, \ - `scan --mode vendored`) reconcile npm-family and cargo package(s) \ - automatically on their next non-dry run, dropping both halves of \ - each superseded entry — the `records` entry AND its matching \ - `edits` (cargo additionally reverts the stale hosted edits on disk \ - first). For other package(s), or if the automatic reconciliation \ - could not run, clean up by hand: delete only these package(s)' \ - entries under `records` AND their matching entries under `edits`, \ - so audits and VEX do not read superseded wiring. \ - Both halves matter: the leftover `edits` are that package's stale \ - pre-redirect originals, which a later redirect revert would replay \ - over the live vendored wiring — and an `edits` entry left behind \ - still names the package, so a ledger whose last record you just \ - deleted keeps reading as superseded and this warning keeps firing. \ - Do not delete the ledger file itself: it may still hold live \ - redirect records for other package(s), plus the recorded \ - pre-redirect lockfile originals (`edits`) a future revert needs \ - for them." - ) - } -} - -/// Detail for the vendored-direction takeover warning on the run that -/// RECONCILED the ledger in place (non-dry-run, npm-family): past tense, -/// stating what was dropped and where the revert data now lives. The code -/// stays `vendor_supersedes_redirect` (codes are stable; only the detail -/// differs), and it fires once — the reconciled ledger no longer overlaps. -pub(super) fn mode_takeover_reconciled_detail( - reconciled: &[String], - npmrc_unwound: bool, -) -> String { - let list = reconciled.join(", "); - // Only a run that actually unwound the hosted npm allow-remote - // auto-config says so (with its npm >= 12 caveat). - let npmrc = if npmrc_unwound { - " The hosted redirect's `.npmrc` `allow-remote=all` auto-config was \ - unwound too (a redirect-created file deleted, an appended line \ - removed): the vendored `file:` specs do not need it. If you later \ - restore the hosted lock wiring with `vendor --revert`, npm >=12 \ - refuses it (EALLOWREMOTE) until `allow-remote=all` is back — re-run \ - `scan --mode hosted` afterwards to re-establish it and its ledger \ - record." - } else { - "" - }; + // NEVER offer deleting the `.socket/vendor//` tree here: for cargo + // the leftover `[patch.crates-io]` entry still points at that tree, and + // deleting it hard-fails every cargo invocation ("failed to load source + // for dependency"). Nor `vendor --revert`, which unwinds EVERY vendored + // package including the ones still live in the lockfile — `remove + // ` is the per-package equivalent. format!( - "vendored artifacts superseded the hosted redirect ledger for: {list}; \ - reconciled automatically. Both halves of each superseded entry — the \ - package's `records` entry AND its matching `edits` — were dropped \ - from `.socket/vendor/redirect-state.json` (an emptied ledger is \ - deleted). The lockfile points at the committed `.socket/vendor/` \ - files, and the pre-vendor lock values (including the hosted-spliced \ - fragment) are preserved as the vendor ledger's wiring originals, so \ - `vendor --revert` still restores the hosted lock wiring \ - byte-for-byte.{npmrc} Ledger data for other, still-redirected \ - package(s) was left untouched. No action needed." + "hosted redirect superseded the vendored ledger for: {list}. \ + `.socket/vendor/state.json` still claims these package(s) and their \ + committed artifacts under `.socket/vendor/` are now orphaned — the \ + lockfile points at the hosted patch server, not the vendored files. \ + Clean up per package: run `socket-patch remove ` for each \ + package listed above, so audits and VEX do not read superseded \ + wiring. It drops that package's vendored ledger entry and its own \ + `.socket/vendor///` artifact directory, AND deletes that \ + package's now-superseded `.socket/manifest.json` entry — that entry \ + describes the vendored delivery, while the live hosted patch is \ + recorded in the lockfile itself. In-place file rollback is skipped \ + for vendor-owned package(s), so the installed tree is left as the \ + lockfile wires it; preview with `--dry-run` first. Do not delete the \ + whole `.socket/vendor//` tree and do not run `vendor --revert`: \ + other vendored package(s) may still be live in the lockfile and \ + would break or be mass-reverted." ) } -/// Drop the superseded purls' `records` + `edits` from the redirect ledger -/// and persist it (atomic write; an emptied ledger is deleted). Called ONLY -/// with purls [`classify_overlap_takeover`] proved vendored-live AND -/// hosted-dead: that gate makes the drop lossless (the vendor ledger's -/// wiring `original` embeds the hosted-spliced fragment, so `vendor -/// --revert` needs nothing from these records). `Ok(Some(npmrc))`: -/// reconciled, with the `.npmrc` allow-remote unwind outcome; `Ok(None)`: -/// nothing matched (caller falls back to the manual advisory); `Err`: the -/// ledger could not be read or persisted (fail closed: on-disk ledger -/// untouched or fully pre-drop). -async fn reconcile_superseded_redirect( - cwd: &Path, - purls: &[String], -) -> Result, String> { - let mut state = match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { - Ok(Some(state)) => state, - Ok(None) => return Ok(None), - Err(corrupt) => return Err(corrupt.to_string()), - }; - let mut dropped = false; - for purl in purls { - dropped |= socket_patch_core::patch::redirect::drop_superseded_purl(&mut state, purl); - } - if !dropped { - return Ok(None); - } - // The dropped npm purls may have been the last entries the hosted - // `.npmrc` `allow-remote=all` auto-config served: unwind it before - // persisting, so a hosted→vendored migration leaves no loosened install - // policy behind (vendored `file:` specs are gated by `allow-file`). - let npmrc = - socket_patch_core::patch::redirect::npmrc::unwind_unneeded_npmrc(cwd, &mut state, false) - .await?; - socket_patch_core::patch::redirect::persist_redirect_state(cwd, &state) - .await - .map_err(|e| e.to_string())?; - Ok(Some(npmrc)) -} - /// Record a run-level advisory: stderr `Warning (code): detail` in human /// mode (informational, so muted by `--silent`) and `warnings[]` on the /// envelope for JSON consumers. Shared by the vendored flows here and in @@ -1193,82 +1023,6 @@ pub(super) fn push_run_warning( }); } -/// Cross-mode takeover advisory shared by every VENDORED flow (`vendor`, -/// `scan --mode vendored`): when this ledger and a committed hosted redirect -/// ledger both claim package(s) AND the live lockfile proves vendored won, -/// the redirect ledger records for those package(s) are stale. Warn once at -/// the envelope level (JSON `warnings[]` and stderr) — and, for npm-family -/// package(s) on a non-dry run, reconcile the ledger in place (cargo -/// reverts + drops BEFORE vendoring in `vendor.rs`; npm-family needs no -/// on-disk revert, since vendoring already overwrote the hosted splice and -/// recorded it as the wiring `original`). The reverse direction -/// (`redirect_supersedes_vendored`) is deliberately untouched. -pub(super) async fn note_vendor_supersedes_redirect( - env: &mut crate::json_envelope::Envelope, - cwd: &Path, - common: &GlobalArgs, -) { - // Only the package(s) the LIVE lockfile routes to `.socket/vendor/`. - let superseded = classify_overlap_takeover(common, cwd).await.vendored; - if superseded.is_empty() { - return; - } - // Reconciliation is gated, each fail-closed to the manual advisory: - // never under --dry-run; only npm-family purls (cargo reverts in - // vendor.rs, and other ecosystems' vendor wiring is not verified to - // embed the hosted originals); only purls classified above. - let (reconcilable, manual): (Vec, Vec) = if common.dry_run { - (Vec::new(), superseded) - } else { - superseded - .into_iter() - .partition(|purl| purl.starts_with("pkg:npm/")) - }; - if !manual.is_empty() { - push_run_warning( - env, - common, - VENDOR_SUPERSEDES_REDIRECT, - mode_takeover_detail(&manual, /*current_is_hosted=*/ false), - ); - } - if reconcilable.is_empty() { - return; - } - match reconcile_superseded_redirect(cwd, &reconcilable).await { - Ok(Some(npmrc)) => { - push_run_warning( - env, - common, - VENDOR_SUPERSEDES_REDIRECT, - mode_takeover_reconciled_detail(&reconcilable, npmrc.file_changed), - ); - for (code, detail) in npmrc.warnings { - push_run_warning(env, common, &code, detail); - } - } - // Nothing matched to drop — do not claim a reconciliation that did - // not happen; hand out the manual remediation instead. - Ok(None) => push_run_warning( - env, - common, - VENDOR_SUPERSEDES_REDIRECT, - mode_takeover_detail(&reconcilable, /*current_is_hosted=*/ false), - ), - Err(e) => push_run_warning( - env, - common, - VENDOR_SUPERSEDES_REDIRECT, - format!( - "{} Automatic reconciliation failed ({e}); the ledger was left \ - as it was, so this warning will fire again until the cleanup \ - above succeeds.", - mode_takeover_detail(&reconcilable, /*current_is_hosted=*/ false) - ), - ), - } -} - /// Top-level `warnings[]` JSON for scan's envelope from `(code, detail)` /// pairs (see [`unsupported_layout_warnings`]). Same `{code, detail}` object /// shape as the run-level `warnings[]` on the unified envelope. @@ -1322,21 +1076,21 @@ pub(super) const API_BATCH_FAILED: &str = "api_batch_failed"; /// prefix. (Every query failing is the discovery error envelope instead.) pub(super) const PATCH_DETAILS_FAILED: &str = "patch_details_failed"; -/// The scanned purls whose HOSTED redirect wiring is still live: the -/// redirect ledger records the purl AND lockfile discovery proves the -/// current lockfile still routes it to that hosted patch — core +/// The scanned purls whose HOSTED redirect wiring is still live: a hosted +/// pin names the purl (`redirect_state`, the lockfiles' hosted state — see +/// [`crate::commands::hosted_state_from_lockfiles`]) AND lockfile discovery +/// proves the current lockfile still routes it to that hosted patch — core /// `Discovery::redirect_record_live`, the same liveness rule `vex` gates -/// redirect-ledger attestations on. +/// hosted attestations on. /// /// Deliberately NOT routed through [`classify_overlap_takeover`]: that /// classifier keys on purls present in BOTH ledgers, so hosted-only wiring /// can never trigger it (pinned by /// `hosted_only_wiring_fires_agent_probe_not_the_overlap_classifier`). /// -/// Silent cases (each pinned by a test): ledger absent/malformed or -/// `records` empty (even while `edits` remain); purl not scanned this run; -/// the live lock does not prove hosted wiring — never guess from ledger -/// presence alone. +/// Silent cases (each pinned by a test): no hosted pin (a pre-v5 ledger is +/// not hosted state); purl not scanned this run; the live lock does not +/// prove hosted wiring. pub(super) async fn hosted_wiring_retained_purls( common: &GlobalArgs, redirect_state: Option<&socket_patch_core::patch::redirect::RedirectState>, @@ -1366,7 +1120,7 @@ pub(super) async fn hosted_wiring_retained_purls( } let cwd = &common.cwd; let discovery = crate::commands::discover_wiring(common, cwd).await; - let mut liveness = LedgerLiveness::new(cwd, &discovery, Some(redirect)); + let mut liveness = LedgerLiveness::new(cwd, &discovery, None); let mut out = Vec::new(); for (purl, uuid) in candidates { if liveness.redirect_record(&purl, uuid).await { @@ -1379,28 +1133,20 @@ pub(super) async fn hosted_wiring_retained_purls( } /// 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). +/// real options — stay hosted, migrate via the vendored flow, or restore +/// the upstream registry entries via `rollback`. pub(super) fn hosted_wiring_retained_detail(retained: &[String]) -> String { let list = retained.join(", "); format!( "agent-mode scan left the hosted redirect wiring live for: {list}. \ 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 \ + server — an agent run patches installed files in place but does \ 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." + package(s) over in the lockfile), or restore their upstream \ + registry entries with `socket-patch rollback`." ) } @@ -1429,22 +1175,19 @@ pub(super) fn vendored_ownership_retained_detail(purls: &[String]) -> String { } /// Additive top-level `redirectState` block for the scan `--json` envelope: -/// the hosted redirect ledger's records — project STATE, so a descriptive -/// block rather than a warning — plus the scanned purls whose hosted -/// lockfile wiring the live lock still proves. +/// the hosted pins the lockfiles wire — project STATE, so a descriptive +/// block rather than a warning — plus the scanned purls among them. /// -/// `None` (key omitted, additive contract) when the ledger is absent or its -/// `records` are empty — an edits-only ledger asserts no patches. +/// `None` (key omitted, additive contract) when no lockfile pins a hosted +/// patch. /// -/// Shape: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: -/// [purl] }`. `mode` is the constant [`crate::commands::HOSTED_MODE_LABEL`], -/// never the ledger's own `mode` string (older ledgers carry `"redirect"`). -/// Each record's `purl` is canonicalized (qualifiers stripped, -/// percent-decoded) to the spelling `wiringLive` carries; `ledgerKey` is the -/// ledger's verbatim key. `wiring_live` is the caller's -/// [`hosted_wiring_retained_purls`] result, computed once per run. A record -/// with no proof means the wiring was unwound, the lock is unreadable, or -/// the purl was not crawled this run — never "still live". +/// Shape: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. `mode` +/// is the constant [`crate::commands::HOSTED_MODE_LABEL`]. Each record's +/// `purl` is canonicalized (qualifiers stripped, percent-decoded) to the +/// spelling `wiringLive` carries. `wiring_live` is the caller's +/// [`hosted_wiring_retained_purls`] result, computed once per run: the pins +/// this run crawled (a pin whose package was not crawled is still wired, +/// just not covered by this run). pub(super) fn redirect_state_json( redirect_state: Option<&socket_patch_core::patch::redirect::RedirectState>, wiring_live: &[String], @@ -1460,14 +1203,12 @@ pub(super) fn redirect_state_json( .map(|(key, record)| { serde_json::json!({ "purl": canon(key), - "ledgerKey": key, "uuid": record.uuid, }) }) .collect(); Some(serde_json::json!({ "mode": crate::commands::HOSTED_MODE_LABEL, - "ledger": socket_patch_core::patch::redirect::REDIRECT_STATE_REL, "records": records, "wiringLive": wiring_live, })) @@ -1881,11 +1622,13 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // `redirectState` rides the empty-discovery envelope too // (same rule as the ≥1-package path). `wiringLive` is empty // by construction: this run covered zero packages. - let redirect_state = crate::commands::load_redirect_state_lenient( - &args.common.cwd, - args.common.silent, - ) - .await; + let redirect_state = (!args.common.is_global()).then_some( + crate::commands::hosted_state_from_lockfiles( + &args.common, + &args.common.cwd, + ) + .await, + ); if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) { result["redirectState"] = state; } @@ -2143,50 +1886,26 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // Read existing manifest once for update detection. let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); - // Hosted and vendored modes record their patches ONLY in their ledgers, - // so both ledgers' purl→uuid records are folded into update detection - // (otherwise their `updates[]` would stay empty). A malformed redirect - // ledger is only warned about here (--silent mutes it). A HOSTED run - // does not warn: its engine loads the ledger strictly and reports the - // corruption once as a hard error; the human hosted arm's returns - // BEFORE the engine (empty discovery, nothing downloadable, a - // detail-fetch failure) print it via `warn_unreported_corrupt_ledger`. - let (redirect_state, hosted_corrupt_ledger) = if hosted { - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { - Ok(state) => (state, None), - Err(corrupt) => (None, Some(corrupt.to_string())), - } - } else { - // `load_redirect_state_lenient`, with the scan event flushed before - // its warning (possibly this run's first write since the event). - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { - Ok(state) => (state, None), - Err(corrupt) => { - if !args.common.silent { - telemetry.flush().await; - eprintln!("Warning: {corrupt}"); - } - (None, None) - } - } - }; - // The hosted pins the lockfiles wire count too: the lockfile is the - // record of a hosted redirect even where no ledger was committed. - let hosted_pins: Vec<(String, String)> = + // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no + // hosted ledger) and vendored mode ONLY in its ledger, so the hosted + // pins and the vendor ledger's purl→uuid records are folded into update + // detection (otherwise their `updates[]` would stay empty). + let hosted_pin_list: Vec = if args.common.is_global() { Vec::new() } else { - crate::commands::discover_wiring(&args.common, &args.common.cwd) - .await - .refs - .into_iter() - .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) - .map(|r| (r.purl, r.uuid)) - .collect() + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &crate::commands::discover_wiring(&args.common, &args.common.cwd).await, + ) }; + let redirect_state = (!args.common.is_global()) + .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); + let hosted_pins: Vec<(String, String)> = hosted_pin_list + .iter() + .map(|pin| (pin.purl.clone(), pin.uuid.clone())) + .collect(); let update_manifest = merge_ledger_records_for_updates( existing_manifest.as_ref(), - redirect_state.as_ref(), vendor_state.as_ref().ok(), &hosted_pins, ); @@ -2477,7 +2196,6 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !silent { println!("\nNo patches available for installed packages."); } - warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); return finish_human(0).await; } @@ -2620,7 +2338,6 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if !silent { println!("\nNo downloadable patches (paid subscription required)."); } - warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); return finish_human(0).await; } @@ -2644,7 +2361,6 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { Ok(s) => s, // `discover_selected` already printed the failure to stderr. Err((code, _)) => { - warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); return code; } }; @@ -2990,20 +2706,18 @@ mod tests { } /// The load-then-derive form of [`overlap_from_states`]: the unit - /// tests' entry point (production classifies over ledgers it already - /// holds via `classify_overlap_takeover_with`). A malformed redirect - /// ledger classifies like a missing one — this path only feeds takeover - /// WARNINGS; the corruption itself is a hard error on every path that - /// would write or attest from the ledger. - async fn overlapping_ledger_purls(cwd: &Path) -> Vec { - let redirect = socket_patch_core::patch::redirect::load_redirect_state(cwd) - .await - .ok() - .flatten(); + /// tests' entry point (production classifies over state it already + /// holds via `classify_overlap_takeover_with`). The hosted side is the + /// lockfiles' hosted pins — never a pre-v5 redirect ledger on disk — and + /// a malformed vendor ledger classifies like a missing one: this path + /// only feeds takeover WARNINGS; the corruption itself is a hard error on + /// every path that would write or attest from the ledger. + async fn overlapping_purls(common: &GlobalArgs, cwd: &Path) -> Vec { + let redirect = crate::commands::hosted_state_from_lockfiles(common, cwd).await; let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else { return Vec::new(); }; - overlap_from_states(redirect.as_ref(), &vendor) + overlap_from_states(Some(&redirect), &vendor) } use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use std::collections::HashMap; @@ -3157,7 +2871,7 @@ mod tests { assert_eq!(crawl_scope(true, None), None); } - // ---- cross-mode ledger takeover (hosted ⇄ vendored) -------------------- + // ---- cross-mode takeover (hosted over vendored) ------------------------ const TAKEOVER_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; @@ -3173,8 +2887,10 @@ mod tests { } } - /// Write a hosted redirect ledger (`.socket/vendor/redirect-state.json`) - /// recording a redirect for each PURL. + /// Write a PRE-V5 hosted redirect ledger + /// (`.socket/vendor/redirect-state.json`) recording a redirect for each + /// PURL. v5 never writes one and never reads it for hosted state: the + /// tests plant it only to prove it is ignored. async fn write_redirect_ledger(root: &Path, purls: &[&str]) { use socket_patch_core::patch::redirect::RedirectState; let mut state = RedirectState::new(); @@ -3191,6 +2907,18 @@ mod tests { .unwrap(); } + /// An in-memory hosted state holding one [`takeover_record`] per PURL + /// under the given (possibly non-canonical) keys — the shape + /// [`crate::commands::hosted_state_from_pins`] builds, for the block + /// builder and the probe's own liveness gate. + fn pinned_state(purls: &[&str]) -> socket_patch_core::patch::redirect::RedirectState { + let mut state = socket_patch_core::patch::redirect::RedirectState::new(); + for purl in purls { + state.records.insert((*purl).to_string(), takeover_record()); + } + state + } + /// Write a vendored state ledger (`.socket/vendor/state.json`) with one /// entry per PURL, in the committed camelCase wire shape. async fn write_vendor_ledger(root: &Path, purls: &[&str]) { @@ -3223,43 +2951,93 @@ mod tests { } #[tokio::test] - async fn overlapping_ledgers_flag_the_taken_over_package() { - // Both ledgers claim minimist ⇒ one mode took the lockfile over from - // the other and the displaced ledger is stale. The detection names - // exactly the overlapping PURL. + async fn hosted_pin_over_a_vendored_entry_flags_the_taken_over_package() { + // The lock pins minimist to the hosted patch server while the vendored + // ledger still claims it ⇒ one mode took the lockfile over from the + // other. The detection names exactly the overlapping PURL. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_lock_pointing_at_hosted(root, "minimist", "1.2.2").await; write_vendor_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; - let superseded = overlapping_ledger_purls(root).await; + let superseded = overlapping_purls(&common_at(root), root).await; assert_eq!(superseded, vec!["pkg:npm/minimist@1.2.2".to_string()]); } #[tokio::test] - async fn single_ledger_present_flags_nothing() { - // A first-time redirect (only the redirect ledger, no vendored ledger) - // displaces nothing — no warning. Guards against warning on the FIRST - // scan of a fresh project. + async fn single_side_present_flags_nothing() { + // A first-time redirect (a hosted pin, no vendored ledger) displaces + // nothing — no warning. Guards against warning on the FIRST scan of a + // fresh project. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; - assert!(overlapping_ledger_purls(root).await.is_empty()); + write_lock_pointing_at_hosted(root, "minimist", "1.2.2").await; + assert!(overlapping_purls(&common_at(root), root).await.is_empty()); - // And a project with no ledgers at all. + // And a project with no lockfile and no ledgers at all. let tmp2 = tempfile::tempdir().unwrap(); - assert!(overlapping_ledger_purls(tmp2.path()).await.is_empty()); + assert!(overlapping_purls(&common_at(tmp2.path()), tmp2.path()) + .await + .is_empty()); } #[tokio::test] - async fn disjoint_ledgers_are_not_a_takeover() { - // A legitimate split — one package redirected, a DIFFERENT one - // vendored — is not a takeover: neither ledger's wiring is stale. + async fn disjoint_states_are_not_a_takeover() { + // A legitimate split — one package pinned hosted, a DIFFERENT one + // vendored — is not a takeover: neither side's wiring is stale. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_lock_pointing_at_hosted(root, "minimist", "1.2.2").await; write_vendor_ledger(root, &["pkg:npm/lodash@4.17.21"]).await; - assert!(overlapping_ledger_purls(root).await.is_empty()); + assert!(overlapping_purls(&common_at(root), root).await.is_empty()); + } + + #[tokio::test] + async fn legacy_redirect_ledger_is_not_hosted_state() { + // v5 derives hosted state from the lockfiles only: a pre-v5 ledger + // still claiming minimist, with no lockfile pinning it hosted, makes + // no overlap with the vendored ledger — and no directional warning. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; + + assert!(overlapping_purls(&common_at(root), root).await.is_empty()); + assert_eq!( + classify_overlap_takeover(&common_at(root), root).await, + OverlapTakeover::default(), + "a legacy ledger must never be read as hosted state" + ); + } + + /// The overlap keys on hosted RECORDS only: a state carrying edits but no + /// records (the shape a pre-v5 run with failed record fetches persisted) + /// names no package, so nothing overlaps. + #[tokio::test] + async fn edits_only_hosted_state_names_no_package() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; + let vendor = socket_patch_core::vendor::load_state(root).await.unwrap(); + + let mut edits_only = socket_patch_core::patch::redirect::RedirectState::new(); + edits_only + .edits + .push(socket_patch_core::patch::redirect::FileEdit { + path: "package-lock.json".to_string(), + kind: "redirect_npm_lock_entry".to_string(), + action: "modified".to_string(), + key: Some("node_modules/minimist".to_string()), + original: None, + new: None, + }); + assert!(overlap_from_states(Some(&edits_only), &vendor).is_empty()); + assert!(overlap_from_states(None, &vendor).is_empty()); + assert_eq!( + overlap_from_states(Some(&pinned_state(&["pkg:npm/minimist@1.2.2"])), &vendor), + vec!["pkg:npm/minimist@1.2.2".to_string()], + "positive control: a record names the package" + ); } #[test] @@ -3277,29 +3055,15 @@ mod tests { } #[test] - fn takeover_detail_names_direction_package_and_remediation() { + fn takeover_detail_names_package_and_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; - // Vendored displaced a hosted redirect: name the stale ledger, but - // NEVER advise deleting it by hand. - let vendored = mode_takeover_detail(&purls, /*current_is_hosted=*/ false); - assert!(vendored.contains("pkg:npm/minimist@1.2.2")); - assert!(vendored.contains("redirect-state.json")); - assert!( - !vendored.contains("Remove the stale redirect ledger"), - "must not advise deleting the redirect ledger: {vendored}" - ); - assert!( - vendored.contains("Do not delete"), - "must warn against hand-deleting the ledger: {vendored}" - ); - // Hosted displaced a vendored ledger: per-package `remove ` is // the offered remediation; `vendor --revert` is named only as // something NOT to run (it mass-reverts). Deleting the // `.socket/vendor//` tree by hand hard-breaks cargo resolution // while `[patch.crates-io]` still references it. - let hosted = mode_takeover_detail(&purls, /*current_is_hosted=*/ true); + let hosted = mode_takeover_detail(&purls); assert!(hosted.contains("pkg:npm/minimist@1.2.2")); assert!(hosted.contains("state.json")); assert!(hosted.contains("orphaned")); @@ -3309,42 +3073,17 @@ mod tests { "deleting the vendor tree must not be offered as an equal \ alternative: {hosted}" ); - - // The two warning codes are distinct routing tags. - assert_ne!(VENDOR_SUPERSEDES_REDIRECT, REDIRECT_SUPERSEDES_VENDORED); + // v5 keeps no hosted ledger, so the detail must not point at one. + assert!( + !hosted.contains("redirect-state.json"), + "the detail must not name the retired hosted ledger: {hosted}" + ); } // ---- agent-flow hosted-wiring retention (hosted → agent conversion) ---- - /// Redirect ledger with one record per PURL AND a recorded `yarn.lock` - /// edit — the shape a real hosted run leaves behind (the edit is what - /// lets the ledger-file fallback scan the lock). - async fn write_redirect_ledger_with_edit(root: &Path, purls: &[&str]) { - use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; - let mut state = RedirectState::new(); - for purl in purls { - state.records.insert((*purl).to_string(), takeover_record()); - } - state.edits.push(FileEdit { - path: "yarn.lock".to_string(), - kind: "redirect_yarn_entry".to_string(), - action: "rewritten".to_string(), - key: Some("minimist@1.2.2".to_string()), - original: Some(serde_json::Value::String("registry original".to_string())), - new: None, - }); - let dir = root.join(".socket/vendor"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write( - dir.join("redirect-state.json"), - serde_json::to_string_pretty(&state).unwrap(), - ) - .await - .unwrap(); - } - /// yarn classic lock whose resolved URL is the hosted artifact (carries - /// the record uuid) — the live-hosted-wiring proof. + /// the patch uuid) — the live-hosted-wiring proof. async fn write_hosted_yarn_lock(root: &Path, uuid: &str) { tokio::fs::write( root.join("yarn.lock"), @@ -3358,10 +3097,25 @@ mod tests { .unwrap(); } - async fn load_ledger(root: &Path) -> Option { - socket_patch_core::patch::redirect::load_redirect_state(root) - .await - .unwrap() + /// yarn classic lock resolving minimist from the public registry — no + /// hosted pin. + async fn write_registry_yarn_lock(root: &Path) { + tokio::fs::write( + root.join("yarn.lock"), + "# yarn lockfile v1\n\n\nminimist@^1.2.2:\n version \"1.2.2\"\n \ + resolved \"https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#bbbb\"\n \ + integrity sha512-orig==\n", + ) + .await + .unwrap(); + } + + /// The project's hosted state as production derives it: the lockfiles' + /// hosted pins (`Some`, as a non-global scan passes it). + async fn hosted_state( + common: &GlobalArgs, + ) -> Option { + Some(crate::commands::hosted_state_from_lockfiles(common, &common.cwd).await) } /// `GlobalArgs` rooted at `root` (the classifiers read the live @@ -3379,12 +3133,11 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let purl = "pkg:npm/minimist@1.2.2"; - write_redirect_ledger_with_edit(root, &[purl]).await; write_hosted_yarn_lock(root, TAKEOVER_UUID).await; // Hosted-only wiring (no vendor state.json) is structurally // invisible to the hosted⇄vendored overlap classifier… - assert!(overlapping_ledger_purls(root).await.is_empty()); + assert!(overlapping_purls(&common_at(root), root).await.is_empty()); assert_eq!( classify_overlap_takeover(&common_at(root), root).await, OverlapTakeover::default() @@ -3392,46 +3145,39 @@ mod tests { // …but the agent flow's direct probe sees it for scanned purls. let scanned: HashSet = [purl.to_string()].into_iter().collect(); - let ledger = load_ledger(root).await; + let state = hosted_state(&common_at(root)).await; let retained = - hosted_wiring_retained_purls(&common_at(root), ledger.as_ref(), &scanned).await; + hosted_wiring_retained_purls(&common_at(root), state.as_ref(), &scanned).await; assert_eq!(retained, vec![purl.to_string()]); } #[tokio::test] - async fn hosted_retained_probe_is_silent_without_live_records_or_wiring() { + async fn hosted_retained_probe_is_silent_without_live_pins_or_wiring() { let purl = "pkg:npm/minimist@1.2.2"; let scanned: HashSet = [purl.to_string()].into_iter().collect(); - // (a) Records retired (a hosted→vendored pre-revert drops RECORDS - // while the `edits` remain): silent even with the uuid still in the - // lock text. + // (a) Registry-clean lock beside a pre-v5 ledger still recording the + // redirect: the lockfiles hold no pin, and the legacy ledger is + // never consulted. let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &[]).await; - write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; - let ledger = load_ledger(tmp.path()).await; + write_redirect_ledger(tmp.path(), &[purl]).await; + write_registry_yarn_lock(tmp.path()).await; + let common = common_at(tmp.path()); + let state = hosted_state(&common).await; + assert!(state.as_ref().is_some_and(|s| s.records.is_empty())); assert!( - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &scanned) + hosted_wiring_retained_purls(&common, state.as_ref(), &scanned) .await .is_empty(), - "records gone ⇒ silent (pre-reverted wiring must not re-warn)" + "no pin ⇒ silent (a legacy ledger must not re-warn)" ); - // (b) Registry-clean lock with a live record: the live lock is the - // truth source — never guess from ledger presence alone. - let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; - tokio::fs::write( - tmp.path().join("yarn.lock"), - "# yarn lockfile v1\n\n\nminimist@^1.2.2:\n version \"1.2.2\"\n \ - resolved \"https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#bbbb\"\n \ - integrity sha512-orig==\n", - ) - .await - .unwrap(); - let ledger = load_ledger(tmp.path()).await; + // (b) A state record the live lock does not back (the lock was + // re-resolved after the state was taken): the live lock is the truth + // source — never guess from state presence alone. + let stale = pinned_state(&[purl]); assert!( - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &scanned) + hosted_wiring_retained_purls(&common, Some(&stale), &scanned) .await .is_empty(), "registry-clean lock ⇒ silent" @@ -3439,25 +3185,23 @@ mod tests { // (c) The purl was not scanned this run. let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + let common = common_at(tmp.path()); let other: HashSet = ["pkg:npm/lodash@4.17.21".to_string()].into_iter().collect(); - let ledger = load_ledger(tmp.path()).await; + let state = hosted_state(&common).await; assert!( - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &other) + hosted_wiring_retained_purls(&common, state.as_ref(), &other) .await .is_empty(), "unscanned purl ⇒ silent" ); - // (d) No ledger at all. - let tmp = tempfile::tempdir().unwrap(); - write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + // (d) No hosted state at all (a global scan passes `None`). assert!( - hosted_wiring_retained_purls(&common_at(tmp.path()), None, &scanned) + hosted_wiring_retained_purls(&common, None, &scanned) .await .is_empty(), - "no ledger ⇒ silent" + "no state ⇒ silent" ); } @@ -3490,20 +3234,29 @@ mod tests { ), ] { let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; tokio::fs::write(tmp.path().join("vlt-lock.json"), text) .await .unwrap(); - let ledger = load_ledger(tmp.path()).await; - let retained = - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &scanned) - .await; + let common = common_at(tmp.path()); let want = if live { vec![purl.to_string()] } else { Vec::new() }; + // The lockfiles' own pins, as production derives them… + let state = hosted_state(&common).await; + let pinned: Vec = state + .iter() + .flat_map(|s| s.records.keys().cloned()) + .collect(); + assert_eq!(pinned, want, "pins: {what}"); + let retained = hosted_wiring_retained_purls(&common, state.as_ref(), &scanned).await; assert_eq!(retained, want, "{what}"); + // …and the probe's own liveness gate over a record the lock may + // not back. + let retained = + hosted_wiring_retained_purls(&common, Some(&pinned_state(&[purl])), &scanned).await; + assert_eq!(retained, want, "liveness: {what}"); } } @@ -3511,15 +3264,17 @@ mod tests { fn agent_retention_details_name_packages_and_safe_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; - // hosted_wiring_retained: names the purl and the real options, - // never hand-deleting the ledger. + // hosted_wiring_retained: names the purl and the real options + // (stay hosted, migrate to vendored, or restore upstream via + // rollback), and no longer points at a hosted ledger. let hosted = hosted_wiring_retained_detail(&purls); assert!(hosted.contains("pkg:npm/minimist@1.2.2")); assert!(hosted.contains("scan --mode hosted")); assert!(hosted.contains("scan --mode vendored")); + assert!(hosted.contains("socket-patch rollback")); assert!( - hosted.contains("Do not delete"), - "must warn against hand-deleting the ledger: {hosted}" + !hosted.contains("redirect-state.json"), + "v5 keeps no hosted ledger to name: {hosted}" ); // vendored_ownership_retained: names the purl and the per-package @@ -3534,10 +3289,10 @@ mod tests { ); assert!(vendored.contains("scan --mode agent")); - // Distinct routing tags, also distinct from the takeover family. + // Distinct routing tags, also distinct from the takeover code. assert_ne!(HOSTED_WIRING_RETAINED, VENDORED_OWNERSHIP_RETAINED); assert_ne!(HOSTED_WIRING_RETAINED, REDIRECT_SUPERSEDES_VENDORED); - assert_ne!(VENDORED_OWNERSHIP_RETAINED, VENDOR_SUPERSEDES_REDIRECT); + assert_ne!(VENDORED_OWNERSHIP_RETAINED, REDIRECT_SUPERSEDES_VENDORED); } // ---- redirectState envelope block (read-only cross-mode visibility) ---- @@ -3545,94 +3300,81 @@ mod tests { // hosted/vendored runs don't) is pinned by `tests/scan_invariants.rs`; // these pin the block builder's own gates and shape. - /// Records present ⇒ the block exists with each record's canonicalized - /// purl + verbatim ledger key, the constant mode label, and the - /// caller-supplied wiringLive. Records absent (edits-only ledger, no - /// ledger) ⇒ `None`, so the envelope key stays additive. + /// Pins present ⇒ the block exists with each pin's canonical purl + + /// uuid, the constant mode label, and the caller-supplied wiringLive — + /// and no pre-v5 `ledger` / `ledgerKey` fields. No pin (a + /// registry-clean lock, even beside a legacy ledger; no state) ⇒ `None`, + /// so the envelope key stays additive. #[tokio::test] - async fn redirect_state_block_gates_on_records_and_splits_live_proof() { + async fn redirect_state_block_gates_on_pins_and_splits_live_proof() { let purl = "pkg:npm/minimist@1.2.2"; let scanned: HashSet = [purl.to_string()].into_iter().collect(); - // Records, but no lockfile on disk: listed, with the EMPTY wiringLive - // the probe computes (the ledger's word is never promoted to a - // live-lock proof). + // A hosted pin the run did not crawl: listed, with an EMPTY + // wiringLive (the pin is wired, just not covered by this run). let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; - let ledger = load_ledger(tmp.path()).await; - let wiring = - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &scanned).await; + write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + let common = common_at(tmp.path()); + let state = hosted_state(&common).await; + let unscanned: HashSet = HashSet::new(); + let wiring = hosted_wiring_retained_purls(&common, state.as_ref(), &unscanned).await; assert_eq!(wiring, Vec::::new()); let block = - redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); + redirect_state_json(state.as_ref(), &wiring).expect("pins present ⇒ block present"); assert_eq!(block["mode"], "hosted"); - assert_eq!(block["ledger"], ".socket/vendor/redirect-state.json"); assert_eq!( block["records"], - serde_json::json!([{ "purl": purl, "ledgerKey": purl, "uuid": TAKEOVER_UUID }]) + serde_json::json!([{ "purl": purl, "uuid": TAKEOVER_UUID }]) ); assert_eq!(block["wiringLive"], serde_json::json!([])); + let keys: Vec<&str> = block + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + ["mode", "records", "wiringLive"], + "v5 block carries no `ledger` key: {block}" + ); - // Live lock present too: the same purl graduates into wiringLive - // (a fresh run re-parses the inventory, so re-take it here). - write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; - let wiring = - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &scanned).await; + // Crawled this run: the same purl graduates into wiringLive. + let wiring = hosted_wiring_retained_purls(&common, state.as_ref(), &scanned).await; let block = - redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); + redirect_state_json(state.as_ref(), &wiring).expect("pins present ⇒ block present"); assert_eq!(block["wiringLive"], serde_json::json!([purl])); - // Edits-only ledger (records retired) ⇒ no block. + // Registry-clean lock beside a legacy ledger that still records the + // redirect ⇒ no pin ⇒ no block. let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &[]).await; - let ledger = load_ledger(tmp.path()).await; + write_redirect_ledger(tmp.path(), &[purl]).await; + write_registry_yarn_lock(tmp.path()).await; + let state = hosted_state(&common_at(tmp.path())).await; assert!( - redirect_state_json(ledger.as_ref(), &[]).is_none(), - "an edits-only ledger asserts no records" + redirect_state_json(state.as_ref(), &[]).is_none(), + "a legacy ledger alone asserts no hosted pin" ); - // No ledger ⇒ no block. + // No state ⇒ no block. assert!(redirect_state_json(None, &[]).is_none()); } /// The records↔wiringLive join is a plain string compare: each record's - /// `purl` is canonicalized to exactly the spelling the probe emits, with - /// the ledger's raw key preserved as `ledgerKey`. Pinned on a - /// percent-encoded scoped npm name and a `?platform=`-qualified gem purl. + /// `purl` is canonicalized to exactly the spelling the probe emits. + /// Pinned on a percent-encoded scoped npm name and a + /// `?platform=`-qualified gem purl. #[tokio::test] async fn redirect_state_records_canonicalize_to_the_wiring_live_spelling() { - use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; - let scoped_key = "pkg:npm/%40scope%2Fpkg@1.0.0"; let scoped_canon = "pkg:npm/@scope/pkg@1.0.0"; let gem_key = "pkg:gem/nokogiri@1.13.3?platform=ruby"; let gem_canon = "pkg:gem/nokogiri@1.13.3"; let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - state - .records - .insert(scoped_key.to_string(), takeover_record()); - state.records.insert(gem_key.to_string(), takeover_record()); - // A recorded yarn.lock edit + a lock entry resolving the scoped - // package from its hosted artifact — live hosted wiring for the - // scoped purl. - state.edits.push(FileEdit { - path: "yarn.lock".to_string(), - kind: "redirect_yarn_entry".to_string(), - action: "rewritten".to_string(), - key: Some("@scope/pkg@1.0.0".to_string()), - original: Some(serde_json::Value::String("orig".to_string())), - new: None, - }); - let dir = tmp.path().join(".socket/vendor"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write( - dir.join("redirect-state.json"), - serde_json::to_string_pretty(&state).unwrap(), - ) - .await - .unwrap(); + let state = pinned_state(&[scoped_key, gem_key]); + // A lock entry resolving the scoped package from its hosted + // artifact — live hosted wiring for the scoped purl. tokio::fs::write( tmp.path().join("yarn.lock"), format!( @@ -3645,25 +3387,23 @@ mod tests { .unwrap(); let scanned: HashSet = [scoped_canon.to_string()].into_iter().collect(); - let ledger = load_ledger(tmp.path()).await; let wiring = - hosted_wiring_retained_purls(&common_at(tmp.path()), ledger.as_ref(), &scanned).await; + hosted_wiring_retained_purls(&common_at(tmp.path()), Some(&state), &scanned).await; assert_eq!( wiring, vec![scoped_canon.to_string()], - "the text proof (uuid in the recorded lock) claims the scoped purl" + "the hosted pin in the lock claims the scoped purl" ); let block = - redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); + redirect_state_json(Some(&state), &wiring).expect("records present ⇒ block present"); assert_eq!( block["records"], serde_json::json!([ - { "purl": gem_canon, "ledgerKey": gem_key, "uuid": TAKEOVER_UUID }, - { "purl": scoped_canon, "ledgerKey": scoped_key, "uuid": TAKEOVER_UUID }, + { "purl": gem_canon, "uuid": TAKEOVER_UUID }, + { "purl": scoped_canon, "uuid": TAKEOVER_UUID }, ]), - "records carry the canonical purl (wiringLive's spelling) plus \ - the verbatim ledger key; block={block}" + "records carry the canonical purl (wiringLive's spelling); block={block}" ); let live: Vec<&str> = block["wiringLive"] .as_array() @@ -3686,17 +3426,15 @@ mod tests { } } - /// The block's `mode` is the constant label, not the ledger's opaque - /// `mode` string: a ledger carrying `"redirect"` still labels as - /// `"hosted"`. - #[tokio::test] - async fn redirect_state_mode_is_the_constant_label_for_legacy_ledgers() { - let tmp = tempfile::tempdir().unwrap(); - write_redirect_ledger_with_edit(tmp.path(), &["pkg:npm/minimist@1.2.2"]).await; - let mut ledger = load_ledger(tmp.path()).await.unwrap(); - ledger.mode = "redirect".to_string(); + /// The block's `mode` is the constant label, not the state's opaque + /// `mode` string: a state carrying the legacy `"redirect"` still labels + /// as `"hosted"`. + #[test] + fn redirect_state_mode_is_the_constant_label_for_legacy_states() { + let mut state = pinned_state(&["pkg:npm/minimist@1.2.2"]); + state.mode = "redirect".to_string(); let block = - redirect_state_json(Some(&ledger), &[]).expect("records present ⇒ block present"); + redirect_state_json(Some(&state), &[]).expect("records present ⇒ block present"); assert_eq!(block["mode"], "hosted"); } @@ -3797,7 +3535,6 @@ mod tests { // generic wiring scan. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &[CARGO_PURL]).await; write_cargo_vendor_ledger(root).await; write_cargo_hosted_takeover_files(root).await; @@ -3809,15 +3546,16 @@ mod tests { ); assert!( takeover.vendored.is_empty(), - "the INVERSE warning must not fire (pre-fix bug): {takeover:?}" + "the INVERSE direction must not be reported: {takeover:?}" ); } #[tokio::test] - async fn cargo_takeover_classifies_vendored_when_the_lock_is_detached() { + async fn cargo_lock_routed_to_vendored_yields_no_hosted_pin() { // The genuine vendored-live shape: detached lock entry (no source) + - // [patch.crates-io] pointing at the entry's committed copy. The - // redirect ledger is the stale one. + // [patch.crates-io] pointing at the entry's committed copy. The lock + // pins nothing hosted, so there is no hosted state to overlap — even + // with a pre-v5 ledger still claiming the crate. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_redirect_ledger(root, &[CARGO_PURL]).await; @@ -3840,19 +3578,19 @@ mod tests { .await .unwrap(); - let takeover = classify_overlap_takeover(&cargo_common_at(root), root).await; + let common = cargo_common_at(root); + assert!(overlapping_purls(&common, root).await.is_empty()); assert_eq!( - takeover.vendored, - vec![CARGO_PURL.to_string()], - "{takeover:?}" + classify_overlap_takeover(&common, root).await, + OverlapTakeover::default() ); - assert!(takeover.redirect.is_empty(), "{takeover:?}"); } #[tokio::test] async fn cargo_takeover_stays_silent_when_the_lock_points_at_crates_io() { - // Both ledgers claim the purl but a third party re-resolved the lock - // back to crates.io: neither mode is live — no directional warning. + // A third party re-resolved the lock back to crates.io: no hosted pin + // is left (a legacy ledger claiming the crate does not count), so + // no directional warning. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_redirect_ledger(root, &[CARGO_PURL]).await; @@ -3883,6 +3621,7 @@ mod tests { /// (`-.tgz`, what [`write_lock_pointing_at_vendored`] /// wires). async fn write_vendor_ledger_wired(root: &Path, purls: &[&str]) { + use socket_patch_core::utils::purl::purl_name_version; let entries: serde_json::Map = purls .iter() .map(|purl| { @@ -3973,87 +3712,76 @@ mod tests { #[tokio::test] async fn hosted_flow_stays_silent_when_the_lock_still_points_at_vendored() { - // Both ledgers claim minimist, but the LIVE lockfile still resolves it - // to the committed `.socket/vendor/` artifact — vendored is live. A - // hosted dry-run/no-op must NOT emit `redirect_supersedes_vendored`, - // which would point cleanup at the LIVE vendored ledger. + // The vendored ledger (and a pre-v5 redirect ledger) claim minimist, + // but the LIVE lockfile resolves it to the committed + // `.socket/vendor/` artifact — vendored is live and no hosted pin + // exists. A hosted dry-run/no-op must NOT emit + // `redirect_supersedes_vendored`, which would point cleanup at the + // LIVE vendored ledger; nor is there any hosted state to call stale. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - let takeover = classify_overlap_takeover(&common_at(root), root).await; - // The hosted flow keys its warning off `.redirect` — empty here, so it - // stays silent instead of accusing the live vendored ledger. - assert!( - takeover.redirect.is_empty(), - "hosted flow must not warn when the lock is vendored: {takeover:?}" - ); - // Truthful direction: vendored won ⇒ the redirect ledger is the stale one. + assert!(overlapping_purls(&common_at(root), root).await.is_empty()); assert_eq!( - takeover.vendored, - vec!["pkg:npm/minimist@1.2.2".to_string()] + classify_overlap_takeover(&common_at(root), root).await, + OverlapTakeover::default(), + "a vendored lock leaves no hosted pin to overlap" ); - // The raw overlap is non-empty: only the direction gate keeps it quiet. - assert!(!overlapping_ledger_purls(root).await.is_empty()); } #[tokio::test] - async fn vendored_flow_stays_silent_when_the_lock_still_points_at_hosted() { - // Mirror: both ledgers claim minimist, but the LIVE lockfile resolves it - // to the hosted patch server — hosted is live. A vendored dry-run/no-op - // must NOT emit `vendor_supersedes_redirect` and point cleanup at the - // live redirect ledger. + async fn hosted_lock_classifies_the_vendored_ledger_as_superseded() { + // The vendored ledger claims minimist, but the LIVE lockfile resolves + // it to the hosted patch server — hosted is live, so the vendored + // ledger is the stale one. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; write_lock_pointing_at_hosted(root, "minimist", "1.2.2").await; let takeover = classify_overlap_takeover(&common_at(root), root).await; - assert!( - takeover.vendored.is_empty(), - "vendored flow must not warn when the lock is hosted: {takeover:?}" - ); - // Truthful direction: hosted won ⇒ the vendored ledger is the stale one. assert_eq!( takeover.redirect, vec!["pkg:npm/minimist@1.2.2".to_string()] ); + assert!(takeover.vendored.is_empty(), "{takeover:?}"); } #[tokio::test] - async fn overlap_without_a_lock_to_prove_direction_stays_silent_both_ways() { - // Both ledgers overlap, but no lockfile proves which mode is live. Rather - // than guess the direction from which command is running, both flows stay - // silent — the raw overlap still fires, only the direction is gated. + async fn half_migrated_locks_naming_both_stay_silent() { + // One lockfile pins minimist hosted while another still routes it to + // the committed vendored artifact: the raw overlap fires, but neither + // direction is proven, so the classifier stays silent rather than + // guess from which command is running. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; + write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; + write_hosted_yarn_lock(root, TAKEOVER_UUID).await; - let takeover = classify_overlap_takeover(&common_at(root), root).await; - assert!( - takeover.redirect.is_empty() && takeover.vendored.is_empty(), - "no lock proof ⇒ no directional warning: {takeover:?}" - ); assert_eq!( - overlapping_ledger_purls(root).await, + overlapping_purls(&common_at(root), root).await, vec!["pkg:npm/minimist@1.2.2".to_string()] ); + assert_eq!( + classify_overlap_takeover(&common_at(root), root).await, + OverlapTakeover::default(), + "both sides live ⇒ no directional warning" + ); } // ---- remediation is per-package and non-destructive --------------------- #[test] fn takeover_detail_remediation_is_per_package_and_non_destructive() { - // Cleanup must be scoped per named package: whole-ledger / whole-tree - // deletion would destroy live data for packages the takeover did not - // touch. + // Cleanup must be scoped per named package: whole-tree deletion would + // destroy live data for packages the takeover did not touch. let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; - let hosted = mode_takeover_detail(&purls, /*current_is_hosted=*/ true); + let hosted = mode_takeover_detail(&purls); // The sanctioned per-purl cleanup command… assert!( hosted.contains("socket-patch remove "), @@ -4073,21 +3801,6 @@ mod tests { !hosted.contains("vendor --revert` before redirecting"), "hosted remediation must not advise a blanket revert: {hosted}" ); - - let vendored = mode_takeover_detail(&purls, /*current_is_hosted=*/ false); - // Only the named packages' records — never the whole ledger file. - assert!( - vendored.contains("only these package(s)"), - "vendored remediation must be per-package: {vendored}" - ); - assert!( - !vendored.contains("Remove the stale redirect ledger"), - "vendored remediation must not advise deleting the ledger: {vendored}" - ); - assert!( - vendored.contains("Do not delete the ledger file"), - "vendored remediation must warn against file deletion: {vendored}" - ); } #[test] @@ -4095,7 +3808,7 @@ mod tests { // `socket-patch remove ` also deletes the package's // `.socket/manifest.json` entry; the hosted text must say so. let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; - let hosted = mode_takeover_detail(&purls, /*current_is_hosted=*/ true); + let hosted = mode_takeover_detail(&purls); assert!( !hosted.contains("drops only that entry"), @@ -4105,152 +3818,16 @@ mod tests { hosted.contains("`.socket/manifest.json`"), "hosted remediation must name the manifest entry `remove` deletes: {hosted}" ); - // …and must place the LIVE hosted patch, so "manifest entry deleted" - // does not read as "the hosted patch was dropped too". + // …and must place the LIVE hosted patch (the lockfile pin itself — + // v5 keeps no hosted ledger), so "manifest entry deleted" does not + // read as "the hosted patch was dropped too". assert!( - hosted.contains("redirect-state.json"), + hosted.contains("recorded in the lockfile itself"), "hosted remediation must say where the live hosted patch lives: {hosted}" ); } - // ---- takeover blind spots: degraded ledgers and hosted-proof gaps ------ - - fn redirect_edit(path: &str, key: &str) -> socket_patch_core::patch::redirect::FileEdit { - socket_patch_core::patch::redirect::FileEdit { - path: path.to_string(), - kind: "redirect_npm_lock_entry".to_string(), - action: "modified".to_string(), - key: Some(key.to_string()), - original: None, - new: None, - } - } - - /// Like [`write_redirect_ledger`] but with explicit `edits` (and possibly - /// NO records — the degraded shape a run with failed record fetches - /// persists). - async fn write_redirect_ledger_with_edits( - root: &Path, - purls: &[&str], - edits: Vec, - ) { - use socket_patch_core::patch::redirect::RedirectState; - let mut state = RedirectState::new(); - for purl in purls { - state.records.insert((*purl).to_string(), takeover_record()); - } - state.edits = edits; - let dir = root.join(".socket/vendor"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write( - dir.join("redirect-state.json"), - serde_json::to_string_pretty(&state).unwrap(), - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn overlap_detected_when_redirect_ledger_has_edits_but_no_records() { - // A hosted run where every per-uuid record fetch failed persists a - // ledger with edits but an EMPTY records map (`record_fetch_failed`). - // That ledger still asserts stale lock wiring, so a vendored takeover - // of the same package must still be flagged. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &[], - vec![redirect_edit("package-lock.json", "node_modules/minimist")], - ) - .await; - write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; - write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - - assert_eq!( - overlapping_ledger_purls(root).await, - vec!["pkg:npm/minimist@1.2.2".to_string()], - "an edits-only redirect ledger must still count as overlapping" - ); - let takeover = classify_overlap_takeover(&common_at(root), root).await; - assert_eq!( - takeover.vendored, - vec!["pkg:npm/minimist@1.2.2".to_string()], - "the vendored takeover of a degraded redirect ledger must be flagged" - ); - assert!(takeover.redirect.is_empty(), "{takeover:?}"); - } - - #[tokio::test] - async fn degraded_ledger_matches_a_vlt_variant_key_at_the_tilde_boundary() { - for (key, overlaps) in [ - ("minimist@1.2.2~peer.2", true), - ("minimist@1.2.2~_croot_s_g_s#a", true), - ("minimist@1.2.20~peer.2", false), - ] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let mut edit = redirect_edit("vlt-lock.json", key); - edit.kind = socket_patch_core::patch::redirect::vlt::KIND.to_string(); - write_redirect_ledger_with_edits(root, &[], vec![edit]).await; - write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; - assert_eq!( - overlapping_ledger_purls(root).await, - if overlaps { - vec!["pkg:npm/minimist@1.2.2".to_string()] - } else { - Vec::new() - }, - "{key}" - ); - } - } - - #[tokio::test] - async fn following_the_vendored_remediation_clears_the_warning() { - // The vendored remediation names the matching `edits` entries as - // well as `records` (leftover edits keep matching through the - // degraded-ledger fallback); carrying it out in full must leave - // nothing to warn about. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &["pkg:npm/minimist@1.2.2"], - vec![redirect_edit("package-lock.json", "node_modules/minimist")], - ) - .await; - write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; - write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - - let before = classify_overlap_takeover(&common_at(root), root).await; - assert_eq!( - before.vendored, - vec!["pkg:npm/minimist@1.2.2".to_string()], - "the vendored takeover must be flagged first: {before:?}" - ); - let detail = mode_takeover_detail(&before.vendored, /*current_is_hosted=*/ false); - assert!( - detail.contains("`edits`"), - "the remediation must name the edits entries: {detail}" - ); - - // Exactly what the remediation prescribes for this ledger: the - // package's `records` entry AND its matching `edits` entry gone, the - // ledger file itself left in place. - write_redirect_ledger_with_edits(root, &[], Vec::new()).await; - - let after = classify_overlap_takeover(&common_at(root), root).await; - assert_eq!( - after, - OverlapTakeover::default(), - "following the remediation must clear the warning: {after:?}" - ); - assert!( - overlapping_ledger_purls(root).await.is_empty(), - "no residue may keep the ledgers reading as overlapping" - ); - } + // ---- hosted-proof gaps: other hosts and lock formats ------------------- /// A grant token as it appears between the host and the patch uuid in /// hosted artifact URLs. @@ -4258,13 +3835,13 @@ mod tests { #[tokio::test] async fn hosted_direction_provable_on_non_default_patch_host() { - // Hosted artifact URLs embed the record's patch uuid on ANY host - // (staging / self-hosted `--patch-server-url` deployments), so the - // liveness proof must not be pinned to the `patch.socket.dev` - // hostname. + // Hosted artifact URLs embed the pin's patch uuid on ANY host the + // operator configured (staging / self-hosted `--patch-server-url` + // deployments), so the proof must not be pinned to the + // `patch.socket.dev` hostname — but an unconfigured host is a user's + // own dependency source, never a pin. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; let lock = serde_json::json!({ "name": "app", @@ -4288,28 +3865,31 @@ mod tests { .await .unwrap(); - let takeover = classify_overlap_takeover(&common_at(root), root).await; + let configured = GlobalArgs { + patch_server_url: Some("https://patches.example.com".to_string()), + ..common_at(root) + }; + let takeover = classify_overlap_takeover(&configured, root).await; assert_eq!( takeover.redirect, vec!["pkg:npm/minimist@1.2.2".to_string()], - "a non-default patch host must still prove hosted is live" + "a configured non-default patch host must still prove hosted is live" ); assert!(takeover.vendored.is_empty(), "{takeover:?}"); + + assert_eq!( + classify_overlap_takeover(&common_at(root), root).await, + OverlapTakeover::default(), + "an unconfigured host is not a hosted pin" + ); } #[tokio::test] async fn hosted_direction_provable_for_bun_url_tuple() { - // The bun inventory skips the URL 3-tuples hosted mode writes, so - // hosted liveness must be provable from the redirect-edited lockfile - // text (the record's uuid outside any vendored path). + // bun records the hosted artifact as a URL 3-tuple; the pin must be + // found there. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &["pkg:npm/minimist@1.2.2"], - vec![redirect_edit("bun.lock", "minimist")], - ) - .await; write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; tokio::fs::write( root.join("bun.lock"), @@ -4333,17 +3913,11 @@ mod tests { #[tokio::test] async fn hosted_direction_provable_for_berry_archive_url() { - // The berry inventory always emits `resolved: None`; the hosted URL - // lives percent-encoded in the `::__archiveUrl=` binding. The uuid - // survives encoding verbatim, so the text proof must see it. + // The hosted URL lives percent-encoded in berry's `::__archiveUrl=` + // binding. The uuid survives encoding verbatim, so the pin must be + // found there. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &["pkg:npm/minimist@1.2.2"], - vec![redirect_edit("yarn.lock", "minimist@1.2.2")], - ) - .await; write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; tokio::fs::write( root.join("yarn.lock"), @@ -4366,45 +3940,33 @@ mod tests { } #[tokio::test] - async fn vendored_path_uuid_does_not_prove_hosted() { + async fn vendored_path_uuid_is_not_a_hosted_pin() { // The vendored wiring embeds the SAME patch uuid in its - // `.socket/vendor///` path. When the redirect ledger - // names the same lockfile, those occurrences must NOT read as - // hosted proof — the lock points at the vendored files. + // `.socket/vendor///` path. That occurrence must NOT read + // as a hosted pin — the lock points at the vendored files. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &["pkg:npm/minimist@1.2.2"], - vec![redirect_edit("package-lock.json", "node_modules/minimist")], - ) - .await; - write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await; write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - let takeover = classify_overlap_takeover(&common_at(root), root).await; + let state = crate::commands::hosted_state_from_lockfiles(&common_at(root), root).await; assert!( - takeover.redirect.is_empty(), - "a vendored-path uuid must not prove hosted: {takeover:?}" - ); - assert_eq!( - takeover.vendored, - vec!["pkg:npm/minimist@1.2.2".to_string()] + state.records.is_empty(), + "a vendored-path uuid must not be a hosted pin: {:?}", + state.records.keys().collect::>() ); } - // ---- takeover detection degradation: corrupt / probe-less ledgers ------ + // ---- takeover detection degradation: corrupt / probe-less state -------- #[tokio::test] async fn corrupt_vendor_state_json_degrades_to_no_overlap() { // A hand-corrupted (or torn mid-write) `.socket/vendor/state.json` // must classify like a missing one: this path only feeds takeover // WARNINGS, and the vendored write paths hard-error on corruption - // themselves. A valid redirect ledger alone must not produce a - // spurious overlap. + // themselves. A hosted pin alone must not produce a spurious overlap. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - write_redirect_ledger(root, &["pkg:npm/minimist@1.2.2"]).await; + write_lock_pointing_at_hosted(root, "minimist", "1.2.2").await; let dir = root.join(".socket/vendor"); tokio::fs::create_dir_all(&dir).await.unwrap(); tokio::fs::write(dir.join("state.json"), "not-json {{{") @@ -4412,7 +3974,7 @@ mod tests { .unwrap(); assert!( - overlapping_ledger_purls(root).await.is_empty(), + overlapping_purls(&common_at(root), root).await.is_empty(), "a corrupt vendor ledger must degrade to no-overlap" ); assert_eq!( @@ -4424,24 +3986,22 @@ mod tests { #[tokio::test] async fn cargo_overlap_with_no_lock_to_probe_stays_silent() { - // Both ledgers claim the cargo purl but there is NO Cargo.lock (a - // fresh checkout / deleted lock): discovery finds no cargo wiring - // either way, which proves neither direction — the classifier must - // stay silent rather than guess. + // The vendored ledger (and a pre-v5 redirect ledger) claim the cargo + // purl but there is NO Cargo.lock (a fresh checkout / deleted lock): + // discovery finds no hosted pin, so nothing overlaps and the + // classifier stays silent rather than guess. let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_redirect_ledger(root, &[CARGO_PURL]).await; write_cargo_vendor_ledger(root).await; - // The raw overlap fires (both ledgers name the purl)… - assert_eq!( - overlapping_ledger_purls(root).await, - vec![CARGO_PURL.to_string()], - "the overlap itself must be detected" + let common = cargo_common_at(root); + assert!( + overlapping_purls(&common, root).await.is_empty(), + "no lock ⇒ no hosted pin ⇒ no overlap" ); - // …but with no lock to prove a direction, both buckets stay empty. assert_eq!( - classify_overlap_takeover(&common_at(root), root).await, + classify_overlap_takeover(&common, root).await, OverlapTakeover::default(), "no Cargo.lock ⇒ neither direction proven ⇒ silent" ); @@ -4559,266 +4119,6 @@ mod tests { ); } - // ---- note_vendor_supersedes_redirect: warning + npm auto-reconcile ------ - - const NPM_TAKEOVER_PURL: &str = "pkg:npm/minimist@1.2.2"; - - fn vendor_env() -> crate::json_envelope::Envelope { - crate::json_envelope::Envelope::new(crate::json_envelope::Command::Vendor) - } - - /// `GlobalArgs` for the advisory: `json` keeps the stderr print quiet - /// (the envelope `warnings[]` is what the tests read). - fn takeover_common() -> GlobalArgs { - GlobalArgs { - json: true, - ..GlobalArgs::default() - } - } - - /// The WET npm takeover: redirect ledger records the purl (with a - /// version-exact keyed edit `drop_superseded_purl` can claim), the - /// vendored ledger is wired, and the LIVE lock points at the committed - /// vendored artifact. - async fn write_wet_npm_takeover(root: &Path) { - write_redirect_ledger_with_edits( - root, - &[NPM_TAKEOVER_PURL], - vec![redirect_edit("package-lock.json", "minimist@1.2.2")], - ) - .await; - write_vendor_ledger_wired(root, &[NPM_TAKEOVER_PURL]).await; - write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - } - - #[tokio::test] - async fn vendored_takeover_wet_npm_run_reconciles_the_ledger_once() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_wet_npm_takeover(root).await; - - let mut env = vendor_env(); - note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; - - assert_eq!( - env.warnings.len(), - 1, - "exactly one warning: {:?}", - env.warnings - ); - assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); - assert!( - env.warnings[0].detail.contains("reconciled automatically"), - "a wet npm run must report the past-tense reconciled detail: {}", - env.warnings[0].detail - ); - assert!( - env.warnings[0].detail.contains(NPM_TAKEOVER_PURL), - "the warning must name the package: {}", - env.warnings[0].detail - ); - - // Both halves dropped; the emptied ledger is deleted outright. - assert!( - load_ledger(root).await.is_none(), - "an emptied redirect ledger must be deleted" - ); - - // Fires once: the reconciled project no longer overlaps. - let mut env2 = vendor_env(); - note_vendor_supersedes_redirect(&mut env2, root, &takeover_common()).await; - assert!( - env2.warnings.is_empty(), - "a reconciled takeover must not re-warn: {:?}", - env2.warnings - ); - } - - /// The reconcile's `.npmrc` unwind surfaces its own warnings - /// (`redirect_npmrc_allow_remote_modified`), and the detail mentions - /// `.npmrc` with the npm 12 EALLOWREMOTE caveat. - #[tokio::test] - async fn vendored_takeover_reconcile_surfaces_the_npmrc_unwind() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &[NPM_TAKEOVER_PURL], - vec![ - redirect_edit("package-lock.json", "minimist@1.2.2"), - socket_patch_core::patch::redirect::FileEdit { - path: ".npmrc".into(), - kind: "redirect_npmrc_allow_remote".into(), - action: "created".into(), - key: Some("allow-remote".into()), - original: None, - new: Some(serde_json::json!("all")), - }, - ], - ) - .await; - write_vendor_ledger_wired(root, &[NPM_TAKEOVER_PURL]).await; - write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - // The user added their own setting to the redirect-created file. - tokio::fs::write(root.join(".npmrc"), "allow-remote=all\nfund=false\n") - .await - .unwrap(); - - let mut env = vendor_env(); - note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; - - let codes: Vec<&str> = env.warnings.iter().map(|w| w.code.as_str()).collect(); - assert_eq!( - codes, - [ - VENDOR_SUPERSEDES_REDIRECT, - "redirect_npmrc_allow_remote_modified" - ], - "{:?}", - env.warnings - ); - let detail = &env.warnings[0].detail; - assert!(detail.contains("reconciled automatically"), "{detail}"); - assert!(detail.contains("`.npmrc` `allow-remote=all`"), "{detail}"); - assert!(detail.contains("EALLOWREMOTE"), "{detail}"); - assert_eq!( - tokio::fs::read_to_string(root.join(".npmrc")) - .await - .unwrap(), - "fund=false\n", - "only our line removed" - ); - assert!(load_ledger(root).await.is_none(), "emptied ledger deleted"); - - // Without a recorded `.npmrc` edit the detail stays silent on it. - assert!(!mode_takeover_reconciled_detail(&["p".into()], false).contains(".npmrc")); - } - - #[tokio::test] - async fn vendored_takeover_dry_run_warns_manual_and_leaves_the_ledger() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_wet_npm_takeover(root).await; - let ledger_path = root.join(".socket/vendor/redirect-state.json"); - let before = tokio::fs::read(&ledger_path).await.unwrap(); - - let mut env = vendor_env(); - let common = GlobalArgs { - dry_run: true, - ..takeover_common() - }; - note_vendor_supersedes_redirect(&mut env, root, &common).await; - - assert_eq!(env.warnings.len(), 1, "{:?}", env.warnings); - assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); - // A dry run hands out the MANUAL remediation (never the past-tense - // reconciled text — nothing was mutated). - assert!( - env.warnings[0].detail.contains("clean up by hand"), - "dry-run must carry the manual advisory: {}", - env.warnings[0].detail - ); - assert!( - !env.warnings[0].detail.contains("reconciled automatically"), - "dry-run must not claim a reconciliation: {}", - env.warnings[0].detail - ); - let after = tokio::fs::read(&ledger_path).await.unwrap(); - assert_eq!( - before, after, - "a dry run must leave the ledger byte-identical" - ); - } - - #[tokio::test] - async fn degraded_ledger_reconcile_matches_nothing_and_falls_back_to_manual() { - // The degraded record-fetch-failed ledger: records EMPTY, one - // version-blind path-keyed edit. The overlap fallback flags it, but - // `drop_superseded_purl` (fail-closed: no record uuid to anchor on, - // key not version-exact) drops nothing — the warning must hand out - // the manual remediation, never claim a reconciliation. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_redirect_ledger_with_edits( - root, - &[], - vec![redirect_edit("package-lock.json", "node_modules/minimist")], - ) - .await; - write_vendor_ledger_wired(root, &[NPM_TAKEOVER_PURL]).await; - write_lock_pointing_at_vendored(root, "minimist", "1.2.2").await; - let ledger_path = root.join(".socket/vendor/redirect-state.json"); - let before = tokio::fs::read(&ledger_path).await.unwrap(); - - let mut env = vendor_env(); - note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; - - assert_eq!(env.warnings.len(), 1, "{:?}", env.warnings); - assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); - assert_eq!( - env.warnings[0].detail, - mode_takeover_detail(&[NPM_TAKEOVER_PURL.to_string()], false), - "an Ok(None) reconcile must fall back to the manual detail verbatim" - ); - let after = tokio::fs::read(&ledger_path).await.unwrap(); - assert_eq!( - before, after, - "a no-op reconcile must leave the degraded ledger byte-identical" - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn reconcile_persist_failure_fails_closed_with_manual_advice() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_wet_npm_takeover(root).await; - let vendor_dir = root.join(".socket/vendor"); - let ledger_path = vendor_dir.join("redirect-state.json"); - let before = tokio::fs::read(&ledger_path).await.unwrap(); - - std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); - // Root ignores mode bits; skip there (CI containers sometimes run as root). - if std::fs::File::create(vendor_dir.join("probe")).is_ok() { - let _ = std::fs::remove_file(vendor_dir.join("probe")); - let _ = std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o755)); - eprintln!("skipping: running as root, 0555 does not block writes"); - return; - } - - let mut env = vendor_env(); - note_vendor_supersedes_redirect(&mut env, root, &takeover_common()).await; - - // Restore BEFORE asserting so a failure never leaks an undeletable - // tempdir. - std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o755)).unwrap(); - - assert_eq!(env.warnings.len(), 1, "{:?}", env.warnings); - assert_eq!(env.warnings[0].code, VENDOR_SUPERSEDES_REDIRECT); - assert!( - env.warnings[0] - .detail - .contains("Automatic reconciliation failed"), - "the persist failure must be surfaced inside the warning: {}", - env.warnings[0].detail - ); - assert!( - env.warnings[0].detail.starts_with(&mode_takeover_detail( - &[NPM_TAKEOVER_PURL.to_string()], - false - )), - "the failure text must ride on the full manual remediation: {}", - env.warnings[0].detail - ); - // Fail closed: the atomic writer left the ledger fully pre-drop. - let after = tokio::fs::read(&ledger_path).await.unwrap(); - assert_eq!( - before, after, - "a failed persist must leave the ledger untouched" - ); - } - /// A failed embedded VEX's discovery diagnostics reach the scan JSON: /// appended after existing `warnings[]` (layout refusals), or creating /// the array; an empty list leaves the object untouched. diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 5457311c1..7930eb432 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -46,7 +46,7 @@ use crate::ui::{plural, print_json}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use super::{ discover_selected, download_params, embed_vex_into_json, emit_discovery_error_json, - note_vendor_supersedes_redirect, push_run_warning, ScanArgs, + push_run_warning, ScanArgs, }; /// Run-level warning: a `.socket/manifest.json` record for a purl the @@ -190,9 +190,8 @@ async fn run_scan_vendor_step( let manifest_path = common.resolved_manifest_path(); let socket_dir = common.socket_dir(); let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - // The guard lives to the end of the step so the ledger migration and - // the redirect-ledger reconcile in `note_vendor_supersedes_redirect` - // run under the lock too. + // The guard lives to the end of the step so the ledger migration runs + // under the lock too. let _guard = crate::commands::lock_cli::acquire_with_status(&socket_dir, timeout).map_err(|e| { let (code, message) = lock_failure(&e, timeout); @@ -230,7 +229,6 @@ async fn run_scan_vendor_step( env.mark_partial_failure(); } note_classic_migration_risk(&mut env, &common.cwd, common); - note_vendor_supersedes_redirect(&mut env, &common.cwd, common).await; Ok((has_errors, env)) } @@ -241,7 +239,7 @@ async fn run_scan_vendor_step( /// `no_local_source` fold (staging could not obtain the patch content — /// offline, or the view fetch failed). #[allow(clippy::too_many_arguments)] -async fn stage_and_vendor( +pub(crate) async fn stage_and_vendor( common: &GlobalArgs, socket_dir: &Path, manifest: &PatchManifest, diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5da61dd9b..275cf5037 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -26,6 +26,7 @@ use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{verify_file_patch, PatchSources}; +use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::telemetry::{track_patch_vendor_failed, track_patch_vendored}; use socket_patch_core::utils::concurrent::{ordered_concurrent, registry_concurrency}; use socket_patch_core::utils::group_commit::GroupCommit; @@ -656,6 +657,34 @@ pub async fn run(args: VendorArgs) -> i32 { // vendored` projects have `.socket/` but never a manifest. Nothing is // locked or written on this path. if !args.revert && tokio::fs::metadata(&manifest_path).await.is_err() { + // A hosted project (no manifest, hosted pins in its lockfiles) + // ejects: its patch set is the lockfiles' hosted pins. + if !args.common.is_global() { + let inventory = crate::commands::hosted_inventory(&args.common, &args.common.cwd).await; + // Contested hosted wiring: the patch set cannot be read off the + // lockfiles, and a "nothing to vendor" answer would hide it. + if let Some(refusal) = inventory.contested_refusal() { + return emit_eject_refusal(&args.common, "hosted_wiring_contested", &refusal); + } + let pins = hosted_pins_in_scope(&args.common, inventory.pins); + if !pins.is_empty() { + // Eject needs every patch record from the API: an offline + // run (or dry run) refuses before any request. + if args.common.offline { + return emit_eject_refusal( + &args.common, + "offline_eject_unavailable", + &format!( + "ejecting {} needs {} patch record(s) from the Socket API, and this \ + run is offline; re-run without --offline", + plural(pins.len(), "hosted package", "hosted packages"), + pins.len() + ), + ); + } + return run_eject(&args, pins).await; + } + } // A requested `--vex` still attests what the `.socket/vendor` // ledgers and lockfiles already wire. Same contract as `apply --vex` // with no manifest: nothing referenced anywhere keeps exit 0; any @@ -823,13 +852,9 @@ pub async fn run(args: VendorArgs) -> i32 { } note_classic_migration_risk(&mut env, &args.common.cwd, &args.common); - // Same cross-mode takeover advisory the scan-driven vendored flow emits: - // surface a redirect ledger that this run (or an earlier one) superseded. - super::scan::note_vendor_supersedes_redirect(&mut env, &args.common.cwd, &args.common).await; - // That advisory may persist the redirect ledger, so it ran under the - // lock; everything below is output and telemetry, so release the lock - // before the telemetry round-trip. + // Everything below is output and telemetry, so release the lock before + // the telemetry round-trip. drop(lock); if args.common.json { @@ -850,6 +875,487 @@ pub async fn run(args: VendorArgs) -> i32 { exit } +/// A refused eject: the JSON error envelope (`status: error`) or an +/// `Error:` line (printed even under `--silent`). Exit 1; nothing touched. +fn emit_eject_refusal(common: &GlobalArgs, code: &'static str, message: &str) -> i32 { + if common.json { + let mut env = Envelope::new(Command::Vendor); + env.dry_run = common.dry_run; + env.mark_error(EnvelopeError::new(code, message.to_string())); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error ({code}): {message}"); + } + 1 +} + +/// The hosted pins whose ecosystem `--ecosystems` selects. +fn hosted_pins_in_scope(common: &GlobalArgs, pins: Vec) -> Vec { + pins.into_iter() + .filter(|pin| { + socket_patch_core::utils::purl::purl_parts(&pin.purl) + .is_some_and(|(eco, _, _)| ecosystem_in_scope(common, &eco)) + }) + .collect() +} + +/// What a wet eject can touch, captured before it touches anything: every +/// regular file directly in the project root, the hosted pins' files and +/// the restore's files (nested locks included), the project's cargo and +/// maven config files, the vendor ledger, and the set of vendored uuid +/// directories. [`EjectSnapshot::restore`] puts all of it back and removes +/// what the eject created. +struct EjectSnapshot { + root: std::path::PathBuf, + files: Vec<(String, Option>)>, + root_files: std::collections::BTreeSet, + vendor_dirs: std::collections::BTreeSet, +} + +impl EjectSnapshot { + const EXTRA: [&'static str; 5] = [ + ".cargo/config", + ".cargo/config.toml", + ".mvn/maven.config", + ".mvn/checksums/checksums.sha256", + socket_patch_core::vendor::VENDOR_STATE_REL, + ]; + + async fn root_file_names(root: &Path) -> std::io::Result> { + let mut out = std::collections::BTreeSet::new(); + let mut dir = tokio::fs::read_dir(root).await?; + while let Some(entry) = dir.next_entry().await? { + if entry.file_type().await?.is_file() { + out.insert(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(out) + } + + fn vendor_dir_set(root: &Path) -> std::collections::BTreeSet { + let base = root.join(".socket/vendor"); + let mut out = std::collections::BTreeSet::new(); + for eco in std::fs::read_dir(&base).into_iter().flatten().flatten() { + if eco.file_type().is_ok_and(|t| t.is_dir()) { + for unit in std::fs::read_dir(eco.path()).into_iter().flatten().flatten() { + out.insert(unit.path()); + } + } + } + out + } + + async fn take(root: &Path, touched: &[String]) -> std::io::Result { + let root_files = Self::root_file_names(root).await?; + let mut rels: std::collections::BTreeSet = root_files.clone(); + rels.extend(touched.iter().cloned()); + rels.extend(Self::EXTRA.iter().map(|s| s.to_string())); + let mut files = Vec::with_capacity(rels.len()); + for rel in rels { + let bytes = match tokio::fs::read(root.join(&rel)).await { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + files.push((rel, bytes)); + } + Ok(EjectSnapshot { + root: root.to_path_buf(), + files, + root_files, + vendor_dirs: Self::vendor_dir_set(root), + }) + } + + async fn restore(&self) -> Result<(), String> { + let mut errors: Vec = Vec::new(); + for (rel, bytes) in &self.files { + let path = self.root.join(rel); + let result = match bytes { + Some(bytes) => { + socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode(&path, bytes).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) = result { + errors.push(format!("{rel}: {e}")); + } + } + // Root files the eject created. + if let Ok(now) = Self::root_file_names(&self.root).await { + for name in now.difference(&self.root_files) { + if self.files.iter().any(|(rel, _)| rel == name) { + continue; + } + if let Err(e) = tokio::fs::remove_file(self.root.join(name)).await { + errors.push(format!("{name}: {e}")); + } + } + } + // Vendored uuid dirs the eject created. + for dir in Self::vendor_dir_set(&self.root).difference(&self.vendor_dirs) { + if let Err(e) = remove_tree_and_prune(dir, &self.root.join(SOCKET_DIR)).await { + errors.push(format!("{}: {e}", dir.display())); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + /// The project files for the manual remedy. + fn files_hint(&self) -> String { + self.files + .iter() + .filter(|(_, bytes)| bytes.is_some()) + .map(|(rel, _)| rel.as_str()) + .collect::>() + .join(" ") + } +} + +/// Standalone `vendor` in a hosted project — no manifest, hosted pins in the +/// lockfiles: EJECT. The patch set is the pins themselves (purl + the uuid +/// in each hosted URL); each record is fetched from the API, vendored into +/// `.socket/vendor/` exactly like `scan --mode vendored`, and the lock is +/// rewired from hosted to vendored (the engine's takeover restores each +/// pin's upstream registry entry first, so `vendor --revert` later returns +/// the project to upstream, not to hosted). +async fn run_eject(args: &VendorArgs, pins: Vec) -> i32 { + let common = &args.common; + let (client, use_public_proxy) = + get_api_client_with_overrides(common.api_client_overrides()).await; + let (api_token, org_slug) = (client.api_token().cloned(), client.org_slug().cloned()); + if !common.json && !common.silent { + println!( + "{} {} into .socket/vendor/...", + if common.dry_run { "Would eject" } else { "Ejecting" }, + plural(pins.len(), "hosted package", "hosted packages") + ); + } + + // One view per distinct uuid, fetched concurrently and consumed in pin + // order; the views' blobs seed the in-memory staging. + let mut records: HashMap = HashMap::new(); + let mut blobs: HashMap> = HashMap::new(); + let mut fetch_failures: Vec<(String, String)> = Vec::new(); + let mut views = std::pin::pin!(ordered_concurrent( + pins.iter(), + socket_patch_core::utils::concurrent::api_concurrency_for( + client.uses_public_proxy(), + pins.len(), + ), + |pin| { + let client = &client; + async move { client.fetch_patch(&pin.uuid).await } + }, + )); + for pin in &pins { + let Some(view) = views.next().await else { + break; + }; + match view { + Ok(Some(patch)) => { + for info in patch.files.values() { + let (Some(b64), Some(hash)) = (&info.blob_content, &info.after_hash) else { + continue; + }; + if !socket_patch_core::patch::apply::is_valid_blob_hash(hash) + || blobs.contains_key(hash) + { + continue; + } + if let Ok(bytes) = crate::commands::get::base64_decode(b64) { + blobs.insert(hash.clone(), bytes); + } + } + let (_, record) = crate::commands::get::record_from_patch_response(&patch); + records.insert(pin.purl.clone(), record); + } + Ok(None) => fetch_failures.push(( + pin.purl.clone(), + format!("patch {} was not found on the API", pin.uuid), + )), + Err(e) => fetch_failures.push(( + pin.purl.clone(), + format!("could not fetch patch {}: {e}", pin.uuid), + )), + } + } + + // All or nothing: a record the API cannot serve refuses the whole eject + // before anything is touched, so every package stays hosted. + if !fetch_failures.is_empty() { + let mut env = Envelope::new(Command::Vendor); + env.dry_run = common.dry_run; + for (purl, detail) in &fetch_failures { + report_vendor_failure(common, purl, detail); + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("patch_fetch_failed", detail.clone()), + ); + } + env.mark_error(EnvelopeError::new( + "eject_refused", + "not every hosted patch record could be fetched; nothing was changed", + )); + if common.json { + println!("{}", env.to_pretty_json()); + } + track_outcomes_for_vendor(true, &env, common.dry_run, api_token.as_deref(), org_slug.as_deref()) + .await; + return 1; + } + + // Plan the upstream restore before touching anything: every pin must + // re-resolve to its registry entry (a dry resolve), or the eject is + // refused whole with each pin's remedy. + let origins = crate::commands::rollback::patch_server_origins(common); + let plan = socket_patch_core::patch::redirect::upstream::restore_upstream( + &common.cwd, + &pins, + &socket_patch_core::patch::redirect::upstream::RestoreOptions { + dry_run: true, + offline: common.offline, + patch_server_origins: origins.clone(), + bun_lockb: true, + }, + ) + .await; + let refused: Vec<(String, String)> = plan + .refused() + .map(|(pin, why)| (pin.purl.clone(), why.to_string())) + .collect(); + if !refused.is_empty() { + let mut env = Envelope::new(Command::Vendor); + env.dry_run = common.dry_run; + for (purl, why) in &refused { + report_vendor_failure(common, purl, why); + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("redirect_revert_failed", why.clone()), + ); + } + env.mark_error(EnvelopeError::new( + "eject_refused", + "not every hosted pin can be restored to its upstream registry entry; nothing was \ + changed", + )); + if common.json { + println!("{}", env.to_pretty_json()); + } + track_outcomes_for_vendor(true, &env, common.dry_run, api_token.as_deref(), org_slug.as_deref()) + .await; + return 1; + } + + // A dry run stops at the verified plan: restoring the live lock to + // preview the vendor step would be a write. + if common.dry_run { + let mut env = Envelope::new(Command::Vendor); + env.dry_run = true; + for pin in &pins { + env.record(PatchEvent::new(PatchAction::Applied, pin.purl.clone()).with_reason( + "eject_planned", + format!( + "would restore the upstream registry entry ({}) and vendor the patch", + pin.files.join(", ") + ), + )); + if !common.json && !common.silent { + println!( + "Would eject {} (restore {}, then vendor into .socket/vendor/)", + pin.purl, + pin.files.join(", ") + ); + } + } + if args.vex.vex.is_some() && !common.json && !common.silent { + println!("{}", crate::commands::vex::format_vex_dry_run_skip("vendored")); + } + if common.json { + println!("{}", env.to_pretty_json()); + } + track_outcomes_for_vendor(false, &env, true, api_token.as_deref(), org_slug.as_deref()).await; + return 0; + } + + // One transaction under one apply lock: snapshot what the eject can + // touch, restore every pin upstream (so the vendor engine resolves the + // pristine registry package even in a fresh checkout with nothing + // installed), vendor, and on ANY failure put the snapshot back — a + // failed eject leaves the project hosted, exactly as it was. + let socket_dir = common.socket_dir(); + let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); + let guard = match crate::commands::lock_cli::acquire_with_status(&socket_dir, timeout) { + Ok(guard) => guard, + Err(e) => { + let (code, message) = crate::commands::lock_cli::lock_failure(&e, timeout); + return emit_eject_refusal(common, code, &message); + } + }; + let touched: Vec = pins + .iter() + .flat_map(|p| p.files.iter().cloned()) + .chain(plan.reverted_files.iter().cloned()) + .collect(); + let snapshot = match EjectSnapshot::take(&common.cwd, &touched).await { + Ok(snapshot) => snapshot, + Err(e) => { + drop(guard); + return emit_eject_refusal( + common, + "eject_refused", + &format!("could not snapshot the project before ejecting: {e}"), + ); + } + }; + let mut env = Envelope::new(Command::Vendor); + let restore = socket_patch_core::patch::redirect::upstream::restore_upstream( + &common.cwd, + &pins, + &socket_patch_core::patch::redirect::upstream::RestoreOptions { + dry_run: false, + offline: common.offline, + patch_server_origins: origins, + bun_lockb: true, + }, + ) + .await; + let restore_failure = restore + .refused() + .map(|(_, why)| why.to_string()) + .next() + .or_else(|| restore.flush_error.clone()); + let mut exit: i32; + if let Some(why) = restore_failure { + env.mark_error(EnvelopeError::new("redirect_revert_failed", why.clone())); + if !common.json { + eprintln!("Error: {}", crate::commands::rollback::capitalize_first(&why)); + } + exit = 1; + } else { + for (code, detail) in &restore.warnings { + env.warnings.push(RunWarning { + code: code.to_string(), + detail: detail.clone(), + }); + } + let manifest = PatchManifest { + patches: records, + setup: None, + }; + match crate::commands::scan::vendor_flow::stage_and_vendor( + common, + &socket_dir, + &manifest, + blobs, + client.clone(), + use_public_proxy, + &mut env, + None, + ) + .await + { + Ok(has_errors) => exit = i32::from(has_errors), + Err((code, message)) => { + env.mark_error(EnvelopeError::new(code, message.clone())); + if !common.json { + eprintln!( + "{}", + crate::commands::scan::vendor_flow::format_vendor_step_error(code, &message) + ); + } + exit = 1; + } + } + } + if exit != 0 { + match snapshot.restore().await { + Ok(()) => env.warnings.push(RunWarning { + code: "eject_rolled_back".to_string(), + detail: "the eject did not complete, so every file it touched was restored: the \ + project is still hosted, exactly as before" + .to_string(), + }), + Err(e) => { + let detail = format!( + "the eject did not complete and restoring the pre-eject files failed ({e}); \ + restore them from version control (`git checkout -- {}`)", + snapshot.files_hint() + ); + if !common.json { + eprintln!("Error: {detail}"); + } + env.mark_error(EnvelopeError::new("eject_rollback_failed", detail)); + } + } + if env.error.is_none() { + env.mark_partial_failure(); + } + } + note_classic_migration_risk(&mut env, &common.cwd, common); + drop(guard); + + // Embedded VEX: same contract as the manifest-driven arm — only on + // success, never on a dry run, and a requested-but-failed VEX flips the + // exit code. The ejected project has no manifest. + if exit == 0 { + if let Some(vex_path) = args.vex.vex.as_ref() { + if common.dry_run { + if !common.json && !common.silent { + println!("{}", crate::commands::vex::format_vex_dry_run_skip("vendored")); + } + } else { + let params = args.vex.to_build_params(); + let manifest_path = common.resolved_manifest_path(); + match generate_vex_without_manifest(common, ¶ms, &manifest_path).await { + ManifestlessVex::Written(summary) => { + env.vex = Some(VexSummary { + path: vex_path.display().to_string(), + statements: summary.statements, + format: "openvex-0.2.0".to_string(), + warnings: summary.warnings, + }); + } + ManifestlessVex::NothingToAttest(warnings) => { + env.warnings.extend(warnings); + if !common.json && !common.silent { + println!("{}", crate::commands::vex::format_vex_nothing_to_attest()); + } + } + ManifestlessVex::Failed(e) => { + env.warnings.extend(e.embedded_warnings()); + env.mark_error(EnvelopeError::new(e.code, e.message.clone())); + if !common.json { + e.print_embedded(common); + } + exit = 1; + } + } + } + } + } + + if common.json { + println!("{}", env.to_pretty_json()); + } + track_outcomes_for_vendor( + exit != 0, + &env, + common.dry_run, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + exit +} + /// The no-manifest warning when the vendor ledger cannot be read either. fn no_manifest_ledger_unreadable(err: &str) -> String { format!( @@ -1582,9 +2088,7 @@ async fn plan_service_downloads( if bun_refusal.is_some_and(|r| r.applies_to(candidate)) { continue; } - if socket_patch_core::patch::redirect::redirect_revert_supported(candidate) - && takeover_blocked(candidate) - { + if takeover_blocked(candidate) { continue; } // The npm backends re-wire a committed artifact the ledger @@ -1918,10 +2422,9 @@ pub(crate) async fn vendor_records_reusing( // version) is deferred rather than fetched: the backend refuses // it — at its turn, in its own words — before anything reads // the source, so the refusal costs no registry request. A purl - // the hosted redirect ledger claims keeps the eager fetch: its - // takeover reverts the hosted lock edits first, which rewrites - // the text the gates read (and a malformed redirect ledger - // defers nothing). + // the lockfiles pin hosted keeps the eager fetch: its takeover + // restores the upstream lock entry first, which rewrites the + // text the gates read. let lock_candidates: Vec<(&str, &str)> = missing .iter() .zip(&rungs) @@ -1939,15 +2442,14 @@ pub(crate) async fn vendor_records_reusing( }) .collect(); if !lock_candidates.is_empty() { - let claimed: Option> = - match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await - { - Ok(Some(state)) => { - Some(state.records.keys().map(|k| canonical_purl(k)).collect()) - } - Ok(None) => Some(Vec::new()), - Err(_) => None, - }; + let claimed: Option> = Some( + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &crate::commands::discover_wiring(common, &common.cwd).await, + ) + .into_iter() + .map(|pin| canonical_purl(&pin.purl)) + .collect(), + ); if let Some(claimed) = claimed { let unclaimed: Vec<(&str, &str)> = lock_candidates .into_iter() @@ -2195,17 +2697,19 @@ pub(crate) async fn vendor_records_reusing( let mut matched: HashSet = HashSet::new(); let mut handled_bases: HashSet = HashSet::new(); - // The hosted redirect ledger, for cross-mode takeovers: vendoring a purl - // it still claims must revert the hosted edits FIRST (see the dispatch - // loop below). Loaded once; mutated + persisted per reverted purl. With - // a MALFORMED ledger a claimed purl is indistinguishable from an - // unclaimed one, so every takeover-capable purl fails closed; other - // purls proceed. - let (mut redirect_ledger, redirect_ledger_corrupt) = - match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { - Ok(state) => (state, None), - Err(corrupt) => (None, Some(corrupt)), - }; + // The lockfiles' hosted pins, for cross-mode takeovers: vendoring a purl + // the lockfiles still pin hosted must restore its upstream registry + // entry FIRST (see the dispatch loop below). Discovered once, before any + // write of this run. + let hosted_pins: Vec = + socket_patch_core::patch::redirect::upstream::HostedPin::all( + &crate::commands::discover_wiring(common, &common.cwd).await, + ); + let hosted_pin_of = |purl: &str| { + hosted_pins + .iter() + .find(|pin| canonical_purl(&pin.purl) == canonical_purl(purl)) + }; // Yarn berry takeover preflight (see // `socket_patch_core::vendor::yarn_berry_vendor_preflight`): the berry @@ -2242,14 +2746,7 @@ pub(crate) async fn vendor_records_reusing( // is still decided at the loop's own call (see `VendorPrefetch`). let service_prefetch = match service.filter(|cfg| !common.dry_run && cfg.wants_prefetch()) { Some(cfg) => { - let takeover_blocked = |purl: &str| { - redirect_ledger_corrupt.is_some() - || redirect_ledger.as_ref().is_some_and(|l| { - l.records - .keys() - .any(|k| canonical_purl(k) == canonical_purl(purl)) - }) - }; + let takeover_blocked = |purl: &str| hosted_pin_of(purl).is_some(); let planned = plan_service_downloads( &common.cwd, force, @@ -2402,42 +2899,23 @@ pub(crate) async fn vendor_records_reusing( continue; } - // Cross-mode takeover: vendoring over a LIVE hosted redirect - // must first revert the hosted edits from the redirect ledger. - // Cargo: `[patch.crates-io]` only patches crates-io-sourced - // deps, so vendoring on top of the hosted registry pin leaves the - // project unbuildable. npm family: without the pre-revert the - // vendor ledger records the grant-tokenized HOSTED lock fragment - // as its pre-vendor original. In every ecosystem the pre-revert - // hands the vendor detach the PRISTINE registry fragment to - // record. A purl whose hosted edits cannot be cleanly reverted is - // REFUSED; the cargo backend's `hosted_redirect_live` guard - // backstops states with no usable ledger. - if socket_patch_core::patch::redirect::redirect_revert_supported(candidate) { - if let Some(corrupt) = &redirect_ledger_corrupt { - has_errors = true; - env.record( - PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( - "redirect_ledger_corrupt", - format!( - "cannot vendor over a possibly-live hosted redirect: \ - {corrupt}" - ), - ), - ); - report_vendor_failure(common, candidate, &corrupt.to_string()); - continue; - } - let claimed = redirect_ledger.as_ref().is_some_and(|l| { - l.records - .keys() - .any(|k| canonical_purl(k) == canonical_purl(candidate)) - }); + // Cross-mode takeover: vendoring over a LIVE hosted pin must + // first restore the upstream registry entry (v5 keeps no hosted + // ledger: the entry is re-resolved from the registry). Cargo: + // `[patch.crates-io]` only patches crates-io-sourced deps, so + // vendoring on top of the hosted registry pin leaves the project + // unbuildable. npm family: without the restore the vendor ledger + // records the grant-tokenized HOSTED lock fragment as its + // pre-vendor original. In every ecosystem the restore hands the + // vendor detach the PRISTINE registry entry to record. A purl + // whose upstream entry cannot be restored is REFUSED; the cargo + // backend's `hosted_redirect_live` guard backstops the rest. + if let Some(pin) = hosted_pin_of(candidate) { // The refusal the berry backend would raise after the - // revert, raised HERE instead — the same `failed` event, + // restore, raised HERE instead — the same `failed` event, // code and detail, in the dry run and the wet run alike — - // so the hosted wiring and redirect ledger stay untouched. - if claimed && candidate.starts_with("pkg:npm/") { + // so the hosted wiring stays untouched. + if candidate.starts_with("pkg:npm/") { let refusal = berry_takeover_refusal .get_or_init(|| { socket_patch_core::vendor::yarn_berry_vendor_preflight(&common.cwd) @@ -2453,177 +2931,103 @@ pub(crate) async fn vendor_records_reusing( continue; } } - if claimed && common.dry_run { - // Probe the takeover exactly as the wet run would (a dry - // revert on a throwaway clone), so the preview never - // promises a takeover the wet run then refuses. - let mut probe = redirect_ledger.clone().expect("claimed implies Some"); - match socket_patch_core::patch::redirect::revert_redirect_purl( - &common.cwd, - &mut probe, + let origins = crate::commands::rollback::patch_server_origins(common); + let vlt_lock = socket_patch_core::utils::fs::read_regular_to_string( + &common + .cwd + .join(socket_patch_core::constants::npm_family::VLT_LOCK), + ) + .await + .ok(); + let targets = vlt_lock + .as_deref() + .map(|lock| { + socket_patch_core::patch::redirect::vlt_heal::lock_targets( + lock, + &origins, + std::slice::from_ref(candidate), + ) + }) + .unwrap_or_default(); + let restore = socket_patch_core::patch::redirect::upstream::restore_upstream( + &common.cwd, + std::slice::from_ref(pin), + &socket_patch_core::patch::redirect::upstream::RestoreOptions { + dry_run: common.dry_run, + offline: common.offline, + patch_server_origins: origins, + bun_lockb: true, + }, + ) + .await; + let refusal = restore + .refused() + .map(|(_, why)| why.to_string()) + .next() + .or_else(|| restore.flush_error.clone()); + if let Some(detail) = refusal { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( + "redirect_revert_failed", + format!("cannot vendor over the live hosted pin: {detail}"), + ), + ); + report_vendor_failure( + common, candidate, - true, - ) - .await - { - Ok(revert) => { - record_warning( - env, - candidate, - &VendorWarning::new( - "vendor_would_revert_redirect", - format!( - "{} is hosted-redirected; a non-dry-run vendor will \ - revert the hosted redirect edits first, then vendor \ - (mode takeover)", - normalize_purl(candidate) - ), - ), - common, - ); - // The backend preview below reads the lock from - // disk, where the hosted wiring is still live. - // Bun's hosted rewrite REPLACES the entry's - // `name@version` spec, so the backend would refuse - // a `vendor_lock_entry_not_found` the wet run never - // sees: the advisory already states the plan, so - // the preview stops here. - if revert - .reverted_files - .iter() - .any(|f| f == "bun.lock" || f == "bun.lockb") - { - continue; - } - } - Err(detail) => { - has_errors = true; - env.record( - PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( - "redirect_revert_failed", - format!( - "cannot vendor over the live hosted redirect: \ - {detail}" - ), - ), - ); - report_vendor_failure( - common, - candidate, - &format!("cannot revert the hosted redirect: {detail}"), - ); - continue; - } - } - } else if claimed { - let ledger = redirect_ledger.as_mut().expect("claimed implies Some"); - let vlt_lock = socket_patch_core::utils::fs::read_regular_to_string( - &common - .cwd - .join(socket_patch_core::constants::npm_family::VLT_LOCK), - ) - .await - .ok(); - let targets = socket_patch_core::patch::redirect::vlt_heal::ledger_targets( - ledger, - std::slice::from_ref(candidate), - vlt_lock.as_deref(), + &format!("cannot restore the upstream entry: {detail}"), ); - match socket_patch_core::patch::redirect::revert_redirect_purl( - &common.cwd, - ledger, + continue; + } + for (code, detail) in &restore.warnings { + record_warning(env, candidate, &VendorWarning::new(code, detail.clone()), common); + } + if common.dry_run { + record_warning( + env, candidate, - false, - ) - .await + &VendorWarning::new( + "vendor_would_revert_redirect", + format!( + "{} is hosted; a non-dry-run vendor will restore its upstream \ + registry entry first, then vendor (mode takeover)", + normalize_purl(candidate) + ), + ), + common, + ); + // The backend preview below reads the lock from disk, + // where the hosted wiring is still live. Bun's hosted + // rewrite REPLACES the entry's `name@version` spec, so the + // backend would refuse a `vendor_lock_entry_not_found` + // the wet run never sees: the advisory already states the + // plan, so the preview stops here. + if restore + .reverted_files + .iter() + .any(|f| f == "bun.lock" || f == "bun.lockb") { - Ok(revert) => { - // Advisories from the same transaction (a - // redirect-created `.npmrc` modified since — - // kept, only the `allow-remote=all` line removed). - for (code, detail) in &revert.warnings { - if code == "redirect_npmrc_allow_remote_modified" { - record_warning( - env, - candidate, - &VendorWarning::new( - "redirect_npmrc_allow_remote_modified", - detail.clone(), - ), - common, - ); - } - } - if let Err(e) = - socket_patch_core::patch::redirect::persist_redirect_state( - &common.cwd, - ledger, - ) - .await - { - // The hosted edits are reverted but the ledger - // still claims them; vendoring now would leave - // a ledger asserting wiring that is gone. Fail - // closed for this purl. - has_errors = true; - let detail = format!( - "reverted the hosted redirect but could not update \ - .socket/vendor/redirect-state.json: {e}" - ); - report_vendor_failure(common, candidate, &detail); - env.record( - PatchEvent::new(PatchAction::Failed, candidate.clone()) - .with_error("redirect_ledger_write_failed", detail), - ); - continue; - } - let reverted_what = if candidate.starts_with("pkg:cargo/") { - "the hosted edits (Cargo.toml registry pin, Cargo.lock \ - source/checksum, registries block)" - } else if candidate.starts_with("pkg:golang/") { - "the hosted edits (go.mod replace, the socket module's go.sum \ - lines, the pruned upstream go.sum lines)" - } else { - "the hosted lockfile edits back to their pre-redirect \ - registry values" - }; - if !targets.is_empty() { - vlt_takeover_targets.insert(candidate.clone(), targets); - } - record_warning( - env, - candidate, - &VendorWarning::new( - "vendor_takeover_reverted_redirect", - format!( - "{} was hosted-redirected; reverted {reverted_what} \ - and dropped the redirect-ledger record before \ - vendoring (mode takeover)", - normalize_purl(candidate) - ), - ), - common, - ); - } - Err(detail) => { - has_errors = true; - env.record( - PatchEvent::new(PatchAction::Failed, candidate.clone()).with_error( - "redirect_revert_failed", - format!( - "cannot vendor over the live hosted redirect: \ - {detail}" - ), - ), - ); - report_vendor_failure( - common, - candidate, - &format!("cannot revert the hosted redirect: {detail}"), - ); - continue; - } + continue; + } + } else { + if !targets.is_empty() { + vlt_takeover_targets.insert(candidate.clone(), targets); } + record_warning( + env, + candidate, + &VendorWarning::new( + "vendor_takeover_reverted_redirect", + format!( + "{} was hosted; restored its upstream registry entry ({}) \ + before vendoring (mode takeover)", + normalize_purl(candidate), + restore.reverted_files.join(", ") + ), + ), + common, + ); } } @@ -3278,6 +3682,56 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { } } + // `--revert` returns to UPSTREAM: a package vendored over hosted wiring + // before v5 recorded the hosted fragment as its pre-vendor original, so + // its revert just wired it back to the patch server. Restore those pins + // to their upstream registry entries too (a wet run only — a dry revert + // wrote nothing to inspect). + if !common.dry_run { + let reverted: HashSet = env + .events + .iter() + .filter(|e| e.action == PatchAction::Removed) + .filter_map(|e| e.purl.as_deref().map(canonical_purl)) + .collect(); + let rehosted: Vec = + HostedPin::all(&crate::commands::discover_wiring(common, &common.cwd).await) + .into_iter() + .filter(|pin| reverted.contains(&canonical_purl(&pin.purl))) + .collect(); + if !rehosted.is_empty() { + let leg = crate::commands::rollback::run_hosted_leg(common, &rehosted).await; + for purl in &leg.reverted { + record_warning( + env, + purl, + &VendorWarning::new( + "vendor_revert_restored_upstream", + format!( + "{purl} was vendored over a hosted pin before v5, so its revert \ + re-wired it to the hosted patch server; restored its upstream \ + registry entry" + ), + ), + common, + ); + } + for (purl, why) in &leg.failed { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("hosted_restore_failed", why.clone()), + ); + } + for (code, detail) in &leg.warnings { + env.warnings.push(RunWarning { + code: code.clone(), + detail: detail.clone(), + }); + } + } + } + // Orphan sweep: uuid dirs on disk with no ledger entry (a hand-edited // state file, or artifacts left by an interrupted run). Unparseable dirs // are reported, never deleted — and neither are dirs a lockfile still diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 4ece01fbe..cae0946dc 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf}; use clap::Args; use socket_patch_core::crawlers::Ecosystem; use socket_patch_core::manifest::operations::read_manifest; -use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vendor::state::VendorState; use socket_patch_core::vex::{ @@ -179,6 +179,7 @@ impl VexEmbedArgs { dry_run: false, product_flag: "--vex-product", npm_prior: None, + hosted_records: Default::default(), } } } @@ -219,6 +220,12 @@ pub(crate) struct VexBuildParams { /// choice and order are unchanged. Ignored when taken with other crawler /// options. The standalone `vex` passes `None` and walks the tree. pub npm_prior: Option, + /// Embedded hosted `scan --vex` only: the patch records THIS RUN + /// fetched for the pins it confirmed, keyed by purl. v5 hosted mode + /// keeps no ledger, so these are the in-run attestation's hosted + /// records (the post-install standalone `vex` fetches them from the + /// API instead). Empty everywhere else. + pub hosted_records: std::collections::BTreeMap, } /// Successful result of [`generate_vex`]. @@ -337,6 +344,7 @@ pub async fn run(args: VexArgs) -> i32 { dry_run: args.common.dry_run, product_flag: "--product", npm_prior: None, + hosted_records: Default::default(), }; let manifest_path = args.common.resolved_manifest_path(); @@ -1064,24 +1072,30 @@ async fn generate_vex_from_manifest_path_inner( } }; let had_manifest_file = manifest_file.is_some(); - // Both ledgers are attestation inputs (records, and the entries whose - // wiring liveness gates them), so a MALFORMED one is a hard error: - // attesting with its contents silently dropped would produce a false — - // or silently partial — document. A missing ledger is simply empty. + // The vendor ledger is an attestation input (records, and the entries + // whose wiring liveness gates them), so a MALFORMED one is a hard error + // (below). v5 hosted mode keeps no ledger: hosted references come from + // the lockfiles, their records from the API. A pre-v5 redirect ledger + // is read (never written) only as an extra local record source for the + // pins it still describes, so a malformed one is an advisory: its + // records are simply not consulted. let redirect = match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await { Ok(state) => state, Err(corrupt) => { - // Not core's Display: that text ("... so it will not be - // overwritten") is written for the hosted `scan` writer, and - // `vex` only reads the ledger. - let message = format!( - "The redirect ledger {} is malformed ({}); cannot attest redirected patches. \ - Repair its JSON or restore it from version control, then re-run.", - corrupt.path.display(), - corrupt.detail + note_warning( + warnings, + common, + "redirect_ledger_corrupt", + format!( + "the pre-v5 redirect ledger {} is malformed ({}); its records were not \ + consulted. socket-patch v5 no longer uses it: delete it, or restore it \ + from version control.", + corrupt.path.display(), + corrupt.detail + ), ); - return Err(fail(common, "redirect_ledger_corrupt", message).await); + None } }; let vendor = match socket_patch_core::vendor::load_state(&common.cwd).await { @@ -1103,6 +1117,16 @@ async fn generate_vex_from_manifest_path_inner( for diag in &discovery.diagnostics { note_warning(warnings, common, diag.code, diag.detail.clone()); } + // This run's hosted records (embedded hosted `scan --vex`) join a + // pre-v5 ledger's as the hosted record source, newest wins. + let redirect = if params.hosted_records.is_empty() { + redirect + } else { + let mut state = + redirect.unwrap_or_else(socket_patch_core::patch::redirect::RedirectState::new); + state.records.extend(params.hosted_records.clone()); + Some(state) + }; let sources = Sources { manifest: manifest_file.unwrap_or_else(PatchManifest::new), vendor, @@ -1956,6 +1980,7 @@ mod npm_prior_tests { dry_run: false, product_flag: "--vex-product", npm_prior: prior, + hosted_records: Default::default(), }; let manifest_path = common.resolved_manifest_path(); match generate_vex_from_manifest_path(common, ¶ms, &manifest_path).await { diff --git a/crates/socket-patch-cli/src/hosted_memory/ledger.rs b/crates/socket-patch-cli/src/hosted_memory/ledger.rs deleted file mode 100644 index 2961d107d..000000000 --- a/crates/socket-patch-cli/src/hosted_memory/ledger.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! The redirect ledger (`.socket/vendor/redirect-state.json`) in memory: -//! loaded strictly (a malformed ledger is a project error, never a fresh -//! start), merged exactly like the disk flow (edits appended unless already -//! recorded, `REBASE_KINDS` rebased, records extended newest-wins), and -//! serialized with the disk writer's bytes (`to_vec_pretty` + `\n`). - -use std::collections::BTreeMap; - -use socket_patch_core::manifest::schema::PatchRecord; -use socket_patch_core::patch::redirect::{ - CorruptRedirectState, FileEdit, RedirectState, REDIRECT_STATE_REL, -}; -use socket_patch_core::vendor::lock_inventory::{MemoryEntry, MemoryProject}; - -use crate::commands::scan::hosted::{rebase_vlt_edits, REBASE_KINDS}; - -/// Load the project's ledger: `Ok(None)` when absent, `Err` (the disk -/// message) when present but unreadable or malformed. -pub(crate) fn load(project: &MemoryProject, root: &str) -> Result, String> { - let path = super::roots::join_root(root, REDIRECT_STATE_REL); - let corrupt = |detail: String, unreadable: bool| { - CorruptRedirectState { - path: path.clone().into(), - detail, - quarantined_to: None, - unreadable, - } - .to_string() - }; - let bytes: &[u8] = match project.get(REDIRECT_STATE_REL) { - None => return Ok(None), - Some(MemoryEntry::Text(text)) => text.as_bytes(), - Some(MemoryEntry::Binary(bytes)) => bytes, - Some(MemoryEntry::Present) => { - return Err(corrupt("file content was not provided".into(), true)) - } - Some(MemoryEntry::Symlink) => return Err(corrupt("is a symbolic link".into(), true)), - }; - serde_json::from_slice(bytes) - .map(Some) - .map_err(|e| corrupt(format!("invalid JSON: {e}"), false)) -} - -/// Merge this run's `edits` and `records` into `ledger` (the disk flow's -/// merge, verbatim). `files` are the pre-rewrite candidate contents the -/// rebase drift check reads. -pub(crate) fn merge( - ledger: &mut RedirectState, - edits: &[FileEdit], - records: BTreeMap, - files: &BTreeMap, -) { - ledger.mode = "hosted".to_string(); - let vlt_merged = rebase_vlt_edits( - &mut ledger.edits, - edits, - files - .get(socket_patch_core::constants::npm_family::VLT_LOCK) - .map(String::as_str), - ); - let mut rebased: Vec = Vec::new(); - for edit in edits.iter().filter(|e| { - REBASE_KINDS.contains(&e.kind.as_str()) - && e.kind != socket_patch_core::patch::redirect::vlt::KIND - }) { - let siblings: Vec = ledger - .edits - .iter() - .enumerate() - .filter(|(_, old)| { - old.path == edit.path && old.kind == edit.kind && old.key == edit.key - }) - .map(|(i, _)| i) - .collect(); - let before = files.get(&edit.path).map(String::as_str).unwrap_or(""); - let drifted = !siblings.is_empty() - && siblings.iter().all(|&i| { - ledger.edits[i] - .new - .as_ref() - .and_then(serde_json::Value::as_str) - .is_none_or(|new| !before.contains(new)) - }); - if !drifted { - continue; - } - let nth = edits - .iter() - .filter(|e| e.path == edit.path && e.kind == edit.kind && e.key == edit.key) - .position(|e| std::ptr::eq(e, edit)) - .unwrap_or(0); - if let Some(&target) = siblings.get(nth) { - if !rebased.contains(&target) { - if edit.kind == "redirect_pdm_lock_package" { - ledger.edits[target].original = edit.original.clone(); - } - ledger.edits[target].new = edit.new.clone(); - ledger.edits[target].action = edit.action.clone(); - rebased.push(target); - } - } - } - let recorded = ledger.edits.len(); - for (i, edit) in edits.iter().enumerate() { - if vlt_merged[i] { - continue; - } - let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) - && rebased.iter().any(|&t| { - let old = &ledger.edits[t]; - old.path == edit.path - && old.kind == edit.kind - && old.key == edit.key - && old.new == edit.new - }); - if !is_rebased && !ledger.edits[..recorded].contains(edit) { - ledger.edits.push(edit.clone()); - } - } - ledger.records.extend(records); -} - -/// The ledger's on-disk bytes. -pub(crate) fn serialize(ledger: &RedirectState) -> Result { - let mut bytes = serde_json::to_vec_pretty(ledger).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - String::from_utf8(bytes).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn edit(kind: &str, new: &str) -> FileEdit { - FileEdit { - path: "poetry.lock".into(), - kind: kind.into(), - action: "replaced".into(), - key: Some("k".into()), - original: Some(serde_json::json!("orig")), - new: Some(serde_json::json!(new)), - } - } - - #[test] - fn corrupt_and_absent_ledgers() { - let mut p = MemoryProject::new(); - assert!(load(&p, "").unwrap().is_none()); - p.insert_text(REDIRECT_STATE_REL, "{not json"); - let err = load(&p, "sub").unwrap_err(); - assert!( - err.contains("sub/.socket/vendor/redirect-state.json"), - "{err}" - ); - assert!(err.contains("malformed"), "{err}"); - p.insert_symlink(REDIRECT_STATE_REL); - assert!(load(&p, "").unwrap_err().contains("cannot be read")); - } - - #[test] - fn merge_appends_new_edits_and_rebases_drifted_fragments() { - let mut ledger = RedirectState::new(); - ledger.edits.push(edit("redirect_npm_lock_entry", "a")); - ledger - .edits - .push(edit("redirect_poetry_lock_package", "old-new")); - let files = BTreeMap::from([("poetry.lock".to_string(), "relocked".to_string())]); - merge( - &mut ledger, - &[ - edit("redirect_npm_lock_entry", "a"), - edit("redirect_npm_lock_entry", "b"), - edit("redirect_poetry_lock_package", "fresh"), - ], - BTreeMap::new(), - &files, - ); - let news: Vec<&str> = ledger - .edits - .iter() - .map(|e| e.new.as_ref().and_then(|v| v.as_str()).unwrap()) - .collect(); - assert_eq!(news, vec!["a", "fresh", "b"]); - let text = serialize(&ledger).unwrap(); - assert!(text.ends_with("}\n")); - } -} diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-cli/src/hosted_memory/mod.rs index b5f8b0f3d..7d2e9e2c7 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-cli/src/hosted_memory/mod.rs @@ -2,7 +2,8 @@ //! filesystem, no subprocesses, no environment reads, no telemetry. Every //! patch lookup goes through the caller's [`PatchApi`]; the caller hands //! in the repository's candidate files (chosen by [`select_paths`]) and -//! gets back the changed files, ledger included. +//! gets back the changed files (v5 hosted mode keeps no ledger: the +//! rewritten lockfiles are the whole record). //! //! Per project root the result matches `scan --mode hosted --json` over a //! checkout holding the same files (the parity tests hold the two paths to @@ -40,8 +41,6 @@ use std::time::Instant; use socket_patch_core::api::client::PatchApi; use socket_patch_core::api::types::{PatchResponse, PatchSearchResult}; use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::manifest::schema::PatchRecord; -use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; use socket_patch_core::utils::cargo_workspace::member_manifests_in; use socket_patch_core::vendor::lock_inventory::{ inventory_project_diagnosed_in, MemoryEntry, MemoryProject, ProjectView, @@ -49,7 +48,6 @@ use socket_patch_core::vendor::lock_inventory::{ use tokio_util::sync::CancellationToken; pub(crate) mod discover; -pub(crate) mod ledger; pub mod limits; pub(crate) mod redirect; pub(crate) mod roots; @@ -110,7 +108,6 @@ struct RootState { /// (oversize, LFS pointers, presence-only): the disk flow would read /// them, so a rewrite that depends on one is refused. unreadable: BTreeSet, - ledger: Option, purls: Vec, summary: ProjectSummary, packages: Vec, @@ -405,7 +402,6 @@ async fn engine( root: root.clone(), project: Some(project), unreadable, - ledger: None, purls: Vec::new(), summary: ProjectSummary::default(), packages: Vec::new(), @@ -423,13 +419,6 @@ async fn engine( let Some(project) = state.project.as_ref() else { continue; }; - match ledger::load(project, &state.root) { - Ok(loaded) => state.ledger = loaded, - Err(message) => { - state.fail("corrupt_ledger", message); - continue; - } - } let (entries, unsupported) = inventory_project_diagnosed_in(&ProjectView::Memory(project)).await; for (code, detail) in crate::commands::scan::unsupported_layout_warnings(&unsupported) { @@ -711,7 +700,7 @@ async fn engine( }) } -/// Records → ledger merge → the project's result and changed files. +/// Records → the project's result and changed files. fn finish_root( state: &mut RootState, done: Rewritten, @@ -731,22 +720,19 @@ fn finish_root( npm_warnings, } = done; let root = state.root.clone(); - let mut record_map: BTreeMap = BTreeMap::new(); + // No ledger keeps the records; the fetch mirrors the disk flow's, so a + // record the API cannot serve warns the same way. let mut record_warnings: Vec = Vec::new(); if !dry_run { for (purl, uuid) in &confirmed { match records.get(uuid) { - Some(Some(response)) => { - let (rec_purl, record) = - crate::commands::get::record_from_patch_response(response); - record_map.insert(rec_purl, record); - } + Some(Some(_)) => {} _ => record_warnings.push(serde_json::json!({ "code": "record_fetch_failed", "detail": format!( "{purl} redirected, but its patch record could not be fetched; \ - it will be missing from VEX until `socket-patch scan --mode \ - hosted` is re-run" + this run's VEX attestation omits it (`socket-patch vex` \ + fetches it again once the API answers)" ), })), } @@ -754,34 +740,6 @@ fn finish_root( } let mut project_changes: Vec<(String, String)> = Vec::new(); - let mut ledger_error: Option = None; - if !dry_run && (!rewrite.edits.is_empty() || !record_map.is_empty()) { - let mut ledger = state.ledger.take().unwrap_or_default(); - ledger::merge(&mut ledger, &rewrite.edits, record_map, &planned.files); - match ledger::serialize(&ledger) { - Ok(text) => { - if planned.project.text(REDIRECT_STATE_REL) != Some(text.as_str()) { - project_changes.push((REDIRECT_STATE_REL.to_string(), text)); - } - } - Err(message) => { - ledger_error = Some(ProjectError { - code: "ledger_serialize_failed".into(), - message, - }) - } - } - } - if let Some(error) = ledger_error { - return ProjectResult { - root, - redirect: serde_json::json!({ "mode": "hosted" }), - summary: state.summary.clone(), - redirected: Vec::new(), - skipped: planned.skipped, - error: Some(error), - }; - } for (rel, content) in &rewrite.files { if planned.project.text(rel) != Some(content.as_str()) { project_changes.push((rel.clone(), content.clone())); @@ -894,7 +852,6 @@ mod tests { root: root.to_string(), project: Some(project), unreadable: BTreeSet::new(), - ledger: None, purls: Vec::new(), summary: ProjectSummary::default(), packages: Vec::new(), diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-cli/src/hosted_memory/select.rs index 67a4b4b74..b7d939af9 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-cli/src/hosted_memory/select.rs @@ -2,7 +2,7 @@ //! per root, the same root-relative candidate set the disk hosted flow //! reads (`REDIRECT_CANDIDATE_FILES`, Python lock / script pairs, Cargo //! member manifests, Rush locks, the install-policy configs, the -//! Plug'n'Play markers and the two `.socket/vendor/` ledgers), plus one +//! Plug'n'Play markers and the vendored ledger), plus one //! presence-only Maven / NuGet marker per ecosystem so a repo holding only //! those still gets its `ecosystem_unsupported_in_memory` warning. @@ -12,7 +12,6 @@ use socket_patch_core::constants::npm_family::{ BUN_LOCKB, PNP_MARKERS, RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR, }; use socket_patch_core::patch::redirect::npmrc::NPMRC_REL; -use socket_patch_core::patch::redirect::REDIRECT_STATE_REL; use socket_patch_core::utils::python_lock::is_python_lock_name; use super::roots::{ @@ -37,12 +36,7 @@ pub(crate) const VENDOR_STATE_REL: &str = ".socket/vendor/state.json"; pub(crate) const RUSH_REPO_STATE_REL: &str = "common/config/rush/repo-state.json"; /// Root-relative text files read beyond `REDIRECT_CANDIDATE_FILES`. -const EXTRA_TEXT_FILES: [&str; 4] = [ - PNPM_WORKSPACE_REL, - NPMRC_REL, - VENDOR_STATE_REL, - REDIRECT_STATE_REL, -]; +const EXTRA_TEXT_FILES: [&str; 3] = [PNPM_WORKSPACE_REL, NPMRC_REL, VENDOR_STATE_REL]; /// The one directory name the disk Cargo member walk never enters (it /// follows `members`, `exclude`, path dependencies and `[patch]` paths @@ -363,7 +357,6 @@ mod tests { s.fetch_text, vec![ ".npmrc", - ".socket/vendor/redirect-state.json", "package-lock.json", "tool.py", "tool.py.lock", @@ -465,12 +458,14 @@ mod tests { } #[test] - fn candidate_listing_is_sorted_and_names_the_ledgers() { + fn candidate_listing_is_sorted_and_names_the_vendored_ledger_only() { let listed = candidate_files(); let mut sorted = listed.clone(); sorted.sort(); assert_eq!(listed, sorted); - assert!(listed + assert!(listed.iter().any(|f| f == ".socket/vendor/state.json")); + // v5 hosted mode keeps no ledger, so the pre-v5 one is never read. + assert!(!listed .iter() .any(|f| f == ".socket/vendor/redirect-state.json")); assert!(listed.iter().any(|f| f == "package-lock.json")); diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 5e5dd9919..a15c8724a 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -954,19 +954,20 @@ fn silent_keeps_missing_manifest_error_on_stderr_via_binary() { } // --------------------------------------------------------------------------- -// Hosted redirect-ledger records — `scan --mode hosted` records its patches -// ONLY in `.socket/vendor/redirect-state.json` (it never writes -// `.socket/manifest.json`), so `list` on a purely hosted-wired project used -// to hard-fail `manifest_not_found` while patches were demonstrably live -// (verified against production on bundler 1.17/2.7/4.0 — the gem live-matrix -// D3 defect). `list` now folds the ledger's records in, labeled as hosted; -// when both stores exist, both are shown. +// Hosted lockfile pins — `scan --mode hosted` writes ONLY lockfile edits (v5 +// keeps no hosted ledger and never writes `.socket/manifest.json`), so the +// lockfiles are the sole record of a hosted patch. `list` on a purely +// hosted-wired project lists those pins (labeled hosted, naming the +// lockfiles that wire them) and exits 0 instead of `manifest_not_found`; +// when a manifest exists too, both are shown. A pre-v5 +// `.socket/vendor/redirect-state.json` is read only to DETAIL a pin with the +// same purl + uuid — it never lists anything by itself. // --------------------------------------------------------------------------- const HOSTED_PURL: &str = "pkg:npm/hosted-pkg@2.0.0"; const HOSTED_UUID: &str = "22222222-2222-4222-8222-222222222222"; -/// A patch record for the redirect ledger, distinguishable from the +/// A patch record for the pre-v5 redirect ledger, distinguishable from the /// manifest fixture's record. fn hosted_record(uuid: &str) -> PatchRecord { let mut files = HashMap::new(); @@ -998,27 +999,75 @@ fn hosted_record(uuid: &str) -> PatchRecord { } } -// The redirect-ledger writer lives in `tests/common/mod.rs` +/// Write `/package-lock.json` resolving every `(purl, uuid)` from its +/// hosted Socket patch URL on `https://patch.socket.dev` — the shape `scan +/// --mode hosted` leaves (the lock is the hosted state). +fn write_hosted_lock(root: &Path, pins: &[(&str, &str)]) { + let mut packages = serde_json::Map::new(); + packages.insert( + String::new(), + serde_json::json!({ "name": "app", "version": "1.0.0" }), + ); + for (purl, uuid) in pins { + let (name, version) = purl + .strip_prefix("pkg:npm/") + .and_then(|nv| nv.rsplit_once('@')) + .expect("an unscoped npm purl"); + packages.insert( + format!("node_modules/{name}"), + serde_json::json!({ + "version": version, + "resolved": format!( + "https://patch.socket.dev/patch/npm/{name}/{version}/\ + 11111111-2222-4333-8444-555555555555/{uuid}/{name}-{version}.tgz" + ), + "integrity": "sha512-UEFUQ0hFRHBhdGNoZWRQQVRDSEVEcGF0Y2hlZA==", + }), + ); + } + std::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": packages, + })) + .unwrap(), + ) + .unwrap(); +} + +fn list_json(out: &std::process::Output) -> serde_json::Value { + serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()).unwrap_or_else(|e| { + panic!( + "stdout must be valid JSON ({e}); stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }) +} + +// The pre-v5 redirect-ledger writer lives in `tests/common/mod.rs` // (`common::write_redirect_ledger`) — shared with the other suites that -// seed hosted state, so the fixture can never drift from the on-disk schema. +// seed legacy hosted state, so the fixture can never drift from the schema. #[test] -fn hosted_only_project_list_json_lists_ledger_records_via_binary() { - // No manifest at all — only the hosted redirect ledger. `list --json` - // must exit 0 with the hosted records as labeled discovered events, not - // `manifest_not_found`. +fn hosted_only_project_list_json_lists_lockfile_pins_via_binary() { + // No manifest, no ledger — only a lockfile wiring a hosted patch. + // `list --json` must exit 0 with the pin as a labeled discovered event. let tmp = tempfile::tempdir().unwrap(); - common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + write_hosted_lock(tmp.path(), &[(HOSTED_PURL, HOSTED_UUID)]); let out = run_list_binary(tmp.path(), &["--json"]); assert_eq!( out.status.code(), Some(0), - "hosted-only list --json must exit 0 (records found), stderr={}", + "hosted-only list --json must exit 0 (a pin was found), stderr={}", String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); assert_eq!(v["command"], "list"); assert_eq!(v["status"], "success", "envelope={v}"); assert_eq!(v["summary"]["discovered"], 1, "envelope={v}"); @@ -1029,27 +1078,85 @@ fn hosted_only_project_list_json_lists_ledger_records_via_binary() { assert_eq!(event["action"], "discovered"); assert_eq!(event["purl"], HOSTED_PURL); assert_eq!(event["uuid"], HOSTED_UUID); - // The hosted label: a consumer must be able to tell a redirect-ledger - // record from a manifest entry. + // The hosted label: a consumer must be able to tell a lockfile pin from + // a manifest entry, and see which files wire it. assert_eq!(event["details"]["mode"], "hosted", "envelope={v}"); assert_eq!( - event["details"]["ledger"], ".socket/vendor/redirect-state.json", + event["details"]["lockfiles"], + serde_json::json!(["package-lock.json"]), "envelope={v}" ); - // The rich metadata rides along exactly like a manifest entry's. - assert_eq!(event["details"]["tier"], "free"); + assert!( + event["details"].get("ledger").is_none(), + "a hosted pin names no ledger: {v}" + ); + // Without a pre-v5 record the details live on the API: uuid only. + assert_eq!(event["details"]["tier"], "", "envelope={v}"); + assert_eq!( + event["details"]["vulnerabilities"], + serde_json::json!([]), + "envelope={v}" + ); + assert!( + !tmp.path().join(".socket").exists(), + "list never writes hosted state" + ); +} + +#[test] +fn legacy_ledger_details_the_matching_hosted_pin_via_binary() { + // A committed pre-v5 ledger recording the SAME purl + uuid as the pin + // supplies its details (tier, vulns, …); the entry is still labeled by + // its lockfiles, never by the ledger. + let tmp = tempfile::tempdir().unwrap(); + write_hosted_lock(tmp.path(), &[(HOSTED_PURL, HOSTED_UUID)]); + common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + let ledger = tmp.path().join(".socket/vendor/redirect-state.json"); + let ledger_before = std::fs::read(&ledger).unwrap(); + + let v = list_json(&run_list_binary(tmp.path(), &["--json"])); + assert_eq!(v["summary"]["discovered"], 1, "envelope={v}"); + let event = &v["events"][0]; + assert_eq!(event["uuid"], HOSTED_UUID, "envelope={v}"); + assert_eq!(event["details"]["mode"], "hosted", "envelope={v}"); + assert_eq!( + event["details"]["lockfiles"], + serde_json::json!(["package-lock.json"]), + "envelope={v}" + ); + assert!(event["details"].get("ledger").is_none(), "envelope={v}"); + assert_eq!(event["details"]["tier"], "free", "envelope={v}"); assert_eq!(event["details"]["exportedAt"], "2024-02-02T00:00:00Z"); let vulns = event["details"]["vulnerabilities"] .as_array() .expect("vulnerabilities array"); - assert_eq!(vulns[0]["id"], "GHSA-host-host-host"); - assert_eq!(vulns[0]["severity"], "critical"); + assert_eq!(vulns[0]["id"], "GHSA-host-host-host", "envelope={v}"); + assert_eq!(vulns[0]["severity"], "critical", "envelope={v}"); + assert_eq!( + std::fs::read(&ledger).unwrap(), + ledger_before, + "list never rewrites the pre-v5 ledger" + ); + + // A ledger record for the same purl under ANOTHER uuid describes some + // other patch: the pin lists with its uuid only. + common::write_redirect_ledger( + tmp.path(), + &[( + HOSTED_PURL, + hosted_record("33333333-3333-4333-8333-333333333333"), + )], + ); + let v = list_json(&run_list_binary(tmp.path(), &["--json"])); + assert_eq!(v["summary"]["discovered"], 1, "envelope={v}"); + assert_eq!(v["events"][0]["uuid"], HOSTED_UUID, "envelope={v}"); + assert_eq!(v["events"][0]["details"]["tier"], "", "envelope={v}"); } #[test] fn hosted_only_project_list_plain_labels_hosted_via_binary() { let tmp = tempfile::tempdir().unwrap(); - common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + write_hosted_lock(tmp.path(), &[(HOSTED_PURL, HOSTED_UUID)]); let out = run_list_binary(tmp.path(), &[]); let stdout = String::from_utf8_lossy(&out.stdout); @@ -1061,7 +1168,7 @@ fn hosted_only_project_list_plain_labels_hosted_via_binary() { ); assert!( stdout.contains("Found 1 patch:"), - "count header must include the hosted record: {stdout}" + "count header must include the hosted pin: {stdout}" ); assert!( stdout.contains(&format!("Package: {HOSTED_PURL}")), @@ -1071,32 +1178,32 @@ fn hosted_only_project_list_plain_labels_hosted_via_binary() { stdout.contains(&format!("UUID: {HOSTED_UUID}")), "missing hosted uuid: {stdout}" ); - // The human line must label the record as hosted and name the ledger. + // The human line labels the pin as hosted and names the lockfile. assert!( - stdout.contains("Mode: hosted"), - "hosted record must be labeled: {stdout}" + stdout.contains("Mode: hosted (wired in package-lock.json)"), + "hosted pin must be labeled with its lockfile: {stdout}" ); assert!( - stdout.contains(".socket/vendor/redirect-state.json"), - "the label must name the ledger the record came from: {stdout}" + !stdout.contains("redirect-state.json"), + "v5 hosted state is never attributed to a ledger: {stdout}" ); } #[test] -fn manifest_and_hosted_ledger_coexist_via_binary() { - // Manifest entry + hosted records, including one purl present in BOTH +fn manifest_and_hosted_pins_coexist_via_binary() { + // Manifest entry + hosted pins, including one purl present in BOTH // stores: both are shown (labeled apart), globally purl-sorted with the // manifest entry first on a tie. let tmp = tempfile::tempdir().unwrap(); write_manifest_in(tmp.path(), &populated_manifest()); - common::write_redirect_ledger( + write_hosted_lock( tmp.path(), &[ - (HOSTED_PURL, hosted_record(HOSTED_UUID)), + (HOSTED_PURL, HOSTED_UUID), // Same purl as the manifest fixture, different uuid. ( "pkg:npm/test-pkg@1.0.0", - hosted_record("33333333-3333-4333-8333-333333333333"), + "33333333-3333-4333-8333-333333333333", ), ], ); @@ -1108,8 +1215,7 @@ fn manifest_and_hosted_ledger_coexist_via_binary() { "list over both stores must exit 0, stderr={}", String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); assert_eq!(v["summary"]["discovered"], 3, "envelope={v}"); let events = v["events"].as_array().expect("events array"); let listed: Vec<(&str, &str, bool)> = events @@ -1137,16 +1243,43 @@ fn manifest_and_hosted_ledger_coexist_via_binary() { true ), ], - "both stores' records must be listed, purl-sorted, manifest entry \ - before the hosted record on a purl tie; envelope={v}" + "both stores' entries must be listed, purl-sorted, manifest entry \ + before the hosted pin on a purl tie; envelope={v}" ); } +#[test] +fn legacy_ledger_record_without_a_pin_is_not_listed_via_binary() { + // A pre-v5 ledger whose record no lockfile wires any more (the lock was + // re-resolved to the registry) asserts no live patch: with no manifest + // and no pin, `list` stays on the manifest_not_found path, and a + // manifest-backed listing does not pick the stale record up either. + let tmp = tempfile::tempdir().unwrap(); + common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + + let out = run_list_binary(tmp.path(), &["--json"]); + let v = list_json(&out); + assert_eq!(out.status.code(), Some(1), "no pin anywhere: {v}"); + assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); + + write_manifest_in(tmp.path(), &populated_manifest()); + let out = run_list_binary(tmp.path(), &["--json"]); + let v = list_json(&out); + assert_eq!(out.status.code(), Some(0), "envelope={v}"); + let purls: Vec<&str> = v["events"] + .as_array() + .expect("events array") + .iter() + .map(|e| e["purl"].as_str().expect("purl")) + .collect(); + assert_eq!(purls, vec!["pkg:npm/test-pkg@1.0.0"], "envelope={v}"); +} + #[test] fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { - // A ledger with recorded edits but NO records (the post-takeover / - // degraded shape) asserts no patches, so a manifest-less project stays - // on the manifest_not_found path. + // A pre-v5 ledger with recorded edits but NO records asserts no + // patches, so a manifest-less project stays on the manifest_not_found + // path. let tmp = tempfile::tempdir().unwrap(); let vendor_dir = tmp.path().join(".socket/vendor"); std::fs::create_dir_all(&vendor_dir).unwrap(); @@ -1168,8 +1301,7 @@ fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { .unwrap(); let out = run_list_binary(tmp.path(), &["--json"]); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); assert_eq!( out.status.code(), Some(1), @@ -1180,7 +1312,7 @@ fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { #[test] fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_binary() { - // No manifest and a corrupt ledger: the run takes the + // No manifest and a corrupt pre-v5 ledger: the run takes the // manifest_not_found exit, but the ledger corruption must still reach // a JSON consumer via the error envelope's `warnings[]` (stderr is not // the machine channel), and nothing may leak onto stderr. @@ -1190,8 +1322,7 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina std::fs::write(vendor_dir.join("redirect-state.json"), "{ not json").unwrap(); let out = run_list_binary(tmp.path(), &["--json"]); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); assert_eq!(out.status.code(), Some(1)); assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); let warnings = v["warnings"].as_array().expect("warnings[] present"); @@ -1212,18 +1343,46 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina } #[test] -fn corrupt_manifest_with_hosted_ledger_still_manifest_invalid_via_binary() { - // A corrupt manifest is an error state; hosted records must never mask - // it as a healthy hosted-only listing. +fn corrupt_ledger_is_a_warning_and_hosted_pins_still_list_via_binary() { + // A malformed pre-v5 ledger only loses its details: the lockfile pin + // still lists (uuid only), the warning rides `warnings[]`, the ledger is + // left byte-identical. + let tmp = tempfile::tempdir().unwrap(); + write_hosted_lock(tmp.path(), &[(HOSTED_PURL, HOSTED_UUID)]); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("redirect-state.json"), "{ not json").unwrap(); + + let out = run_list_binary(tmp.path(), &["--json"]); + let v = list_json(&out); + assert_eq!(out.status.code(), Some(0), "envelope={v}"); + assert_eq!(v["summary"]["discovered"], 1, "envelope={v}"); + assert_eq!(v["events"][0]["uuid"], HOSTED_UUID, "envelope={v}"); + assert_eq!(v["events"][0]["details"]["mode"], "hosted", "envelope={v}"); + assert!( + v["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "redirect_ledger_corrupt")), + "envelope={v}" + ); + assert_eq!( + std::fs::read_to_string(vendor_dir.join("redirect-state.json")).unwrap(), + "{ not json" + ); +} + +#[test] +fn corrupt_manifest_with_hosted_pin_still_manifest_invalid_via_binary() { + // A corrupt manifest is an error state; hosted pins must never mask it + // as a healthy hosted-only listing. let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); std::fs::create_dir_all(&socket_dir).unwrap(); std::fs::write(socket_dir.join("manifest.json"), "{not json").unwrap(); - common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + write_hosted_lock(tmp.path(), &[(HOSTED_PURL, HOSTED_UUID)]); let out = run_list_binary(tmp.path(), &["--json"]); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); assert_eq!(out.status.code(), Some(1), "corrupt manifest must exit 1"); assert_eq!(v["error"]["code"], "manifest_invalid", "envelope={v}"); } @@ -1231,29 +1390,36 @@ fn corrupt_manifest_with_hosted_ledger_still_manifest_invalid_via_binary() { #[test] fn silent_suppresses_hosted_listing_via_binary() { // `--silent` is "errors only": the hosted listing is muted like the - // manifest one, while the exit code still says records were found. + // manifest one, while the exit code still says entries were found. let tmp = tempfile::tempdir().unwrap(); - common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + write_hosted_lock(tmp.path(), &[(HOSTED_PURL, HOSTED_UUID)]); let out = run_list_binary_scrubbed(tmp.path(), &["--silent"]); assert_eq!( out.status.code(), Some(0), - "hosted-only --silent must exit 0" + "hosted-only --silent must exit 0, stderr={}", + String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); assert!( stdout.trim().is_empty(), "--silent must suppress the hosted listing; got {stdout:?}" ); + // Control: the same project without --silent prints the pin. + let loud = run_list_binary_scrubbed(tmp.path(), &[]); + assert!( + String::from_utf8_lossy(&loud.stdout).contains(&format!("Package: {HOSTED_PURL}")), + "non-silent run must print the listing" + ); } #[test] fn silent_gates_the_malformed_ledger_warning_via_binary() { - // A malformed ledger degrades to "nothing to consult" with a stderr - // warning — and that warning is advisory, so `--silent` ("errors only") - // must mute it like every sibling warning. The listing itself proceeds - // from the manifest either way. + // A malformed pre-v5 ledger degrades to "nothing to consult" with a + // stderr warning — and that warning is advisory, so `--silent` ("errors + // only") must mute it like every sibling warning. The listing itself + // proceeds from the manifest either way. let tmp = tempfile::tempdir().unwrap(); write_manifest_in(tmp.path(), &populated_manifest()); let vendor_dir = tmp.path().join(".socket/vendor"); @@ -1281,27 +1447,24 @@ fn silent_gates_the_malformed_ledger_warning_via_binary() { } // --------------------------------------------------------------------------- -// `--manifest-path` store scoping — both stores must come from the SAME -// project. Resolving the redirect ledger against cwd instead would -// interleave two projects' patch state when `--manifest-path` points at -// another project's manifest (and a LOCAL ledger could suppress the flagged -// project's manifest_not_found). +// `--manifest-path` store scoping — every store must come from the SAME +// project. Discovering hosted pins against cwd instead would interleave two +// projects' patch state when `--manifest-path` points at another project's +// manifest (and a LOCAL lockfile could suppress the flagged project's +// manifest_not_found). // --------------------------------------------------------------------------- #[test] -fn manifest_path_scopes_ledger_to_target_project_via_binary() { - // cwd has its own (decoy) ledger; --manifest-path points at another - // project that has BOTH a manifest and its own ledger. Only the target - // project's stores may be listed. +fn manifest_path_scopes_hosted_pins_to_target_project_via_binary() { + // cwd has its own (decoy) hosted lockfile; --manifest-path points at + // another project that has BOTH a manifest and its own hosted lockfile. + // Only the target project's stores may be listed. let cwd = tempfile::tempdir().unwrap(); - common::write_redirect_ledger( - cwd.path(), - &[("pkg:npm/local-decoy@0.0.1", hosted_record(HOSTED_UUID))], - ); + write_hosted_lock(cwd.path(), &[("pkg:npm/local-decoy@0.0.1", HOSTED_UUID)]); let target = tempfile::tempdir().unwrap(); write_manifest_in(target.path(), &populated_manifest()); - common::write_redirect_ledger(target.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + write_hosted_lock(target.path(), &[(HOSTED_PURL, HOSTED_UUID)]); let manifest_path = target.path().join(".socket/manifest.json"); let out = run_list_binary( @@ -1314,8 +1477,7 @@ fn manifest_path_scopes_ledger_to_target_project_via_binary() { "stderr={}", String::from_utf8_lossy(&out.stderr) ); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); let purls: Vec<&str> = v["events"] .as_array() .expect("events array") @@ -1325,17 +1487,17 @@ fn manifest_path_scopes_ledger_to_target_project_via_binary() { assert_eq!( purls, vec![HOSTED_PURL, "pkg:npm/test-pkg@1.0.0"], - "only the target project's manifest + ledger may be listed — never \ - the cwd's local ledger; envelope={v}" + "only the target project's manifest + hosted pins may be listed — \ + never the cwd's local lockfile; envelope={v}" ); } #[test] -fn local_ledger_never_suppresses_flagged_manifest_not_found_via_binary() { - // --manifest-path points at a project with NO manifest and NO ledger; - // the cwd's local ledger records must not turn that into a success. +fn local_hosted_pins_never_suppress_flagged_manifest_not_found_via_binary() { + // --manifest-path points at a project with NO manifest and NO pins; + // the cwd's local hosted lockfile must not turn that into a success. let cwd = tempfile::tempdir().unwrap(); - common::write_redirect_ledger(cwd.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + write_hosted_lock(cwd.path(), &[(HOSTED_PURL, HOSTED_UUID)]); let target = tempfile::tempdir().unwrap(); let manifest_path = target.path().join(".socket/manifest.json"); @@ -1343,8 +1505,7 @@ fn local_ledger_never_suppresses_flagged_manifest_not_found_via_binary() { cwd.path(), &["--json", "--manifest-path", manifest_path.to_str().unwrap()], ); - let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) - .expect("stdout must be valid JSON"); + let v = list_json(&out); assert_eq!( out.status.code(), Some(1), @@ -1460,15 +1621,13 @@ fn vendored_only_project_list_plain_labels_vendored_via_binary() { } #[test] -fn manifest_hosted_and_vendored_ledgers_coexist_via_binary() { - // One purl in all three stores: every copy listed, purl-sorted, manifest - // then hosted then vendored on the tie. +fn manifest_hosted_pins_and_vendored_ledger_coexist_via_binary() { + // One purl in all three stores (manifest, a hosted lockfile pin, the + // vendor ledger): every copy listed, purl-sorted, manifest then hosted + // then vendored on the tie. let tmp = tempfile::tempdir().unwrap(); write_manifest_in(tmp.path(), &populated_manifest()); - common::write_redirect_ledger( - tmp.path(), - &[("pkg:npm/test-pkg@1.0.0", hosted_record(HOSTED_UUID))], - ); + write_hosted_lock(tmp.path(), &[("pkg:npm/test-pkg@1.0.0", HOSTED_UUID)]); write_vendor_ledger( tmp.path(), &[ @@ -1515,7 +1674,7 @@ fn manifest_hosted_and_vendored_ledgers_coexist_via_binary() { fn record_less_vendor_entry_without_manifest_still_manifest_not_found_via_binary() { // A legacy manifest-tracked entry asserts no patch of its own: with no // manifest and no other store, `list` stays on the manifest_not_found - // path (mirrors the edits-only redirect ledger). + // path (mirrors the edits-only pre-v5 redirect ledger). let tmp = tempfile::tempdir().unwrap(); write_vendor_ledger(tmp.path(), &[(VENDORED_PURL, None)]); @@ -1532,7 +1691,7 @@ fn record_less_vendor_entry_without_manifest_still_manifest_not_found_via_binary #[test] fn silent_gates_the_malformed_vendor_ledger_warning_via_binary() { - // Same posture as the redirect ledger: a corrupt vendor ledger degrades + // Same posture as the pre-v5 redirect ledger: a corrupt vendor ledger degrades // to "nothing to consult" with an advisory stderr warning that --silent // ("errors only") mutes; the manifest still lists, exit 0. let tmp = tempfile::tempdir().unwrap(); @@ -1562,7 +1721,7 @@ fn silent_gates_the_malformed_vendor_ledger_warning_via_binary() { #[test] fn manifest_path_scopes_vendor_ledger_to_target_project_via_binary() { // The vendor ledger resolves at the SAME project root as the manifest - // and the redirect ledger — never the cwd's. + // and the hosted pins — never the cwd's. let cwd = tempfile::tempdir().unwrap(); write_vendor_ledger( cwd.path(), @@ -1601,7 +1760,7 @@ fn manifest_path_scopes_vendor_ledger_to_target_project_via_binary() { // --------------------------------------------------------------------------- // Telemetry — `patch_listed`'s `patches_count` predates the hosted folding -// and dashboards consume it as "manifest patches". Folding hosted records +// and dashboards consume it as "manifest patches". Folding hosted pins // into the SAME field would silently redefine the metric (and double-count // purls present in both stores), so the count stays manifest-only. // --------------------------------------------------------------------------- @@ -1618,17 +1777,17 @@ async fn list_telemetry_counts_manifest_patches_only_via_binary() { .mount(&server) .await; - // 1 manifest patch + 2 hosted records (one sharing the manifest purl): + // 1 manifest patch + 2 hosted pins (one sharing the manifest purl): // the listing shows 3 entries, the metric must still say 1. let tmp = tempfile::tempdir().unwrap(); write_manifest_in(tmp.path(), &populated_manifest()); - common::write_redirect_ledger( + write_hosted_lock( tmp.path(), &[ - (HOSTED_PURL, hosted_record(HOSTED_UUID)), + (HOSTED_PURL, HOSTED_UUID), ( "pkg:npm/test-pkg@1.0.0", - hosted_record("33333333-3333-4333-8333-333333333333"), + "33333333-3333-4333-8333-333333333333", ), ], ); diff --git a/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs b/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs index fd2313ebe..dd1f6e32e 100644 --- a/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs +++ b/crates/socket-patch-cli/tests/common/yarn_classic_vex.rs @@ -161,7 +161,9 @@ impl Wiring { } } - /// The omission code once the ledger outlives a reverted lock. + /// The omission code once the (vendor) ledger outlives a reverted lock. + /// Hosted never reaches it: v5 writes no redirect ledger, so a reverted + /// hosted lock names the patch nowhere. fn unwired(self) -> &'static str { match self { Wiring::Hosted => "redirect_unwired", @@ -345,6 +347,10 @@ impl<'a> ManifestlessVex<'a> { socket_patch_core::patch::redirect::REDIRECT_STATE_REL, ] .map(|rel| (rel, std::fs::read(project.join(rel)).ok())); + assert!( + ledgers[1].1.is_none(), + "{leg}: v5 hosted mode writes no redirect ledger" + ); strip_ledgers(project); let before = self.api.view_requests(self.uuid); let out = run_vex(&binary(), project, &self.online()); diff --git a/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_hosted.rs b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_hosted.rs new file mode 100644 index 000000000..e0b8d253a --- /dev/null +++ b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_hosted.rs @@ -0,0 +1,146 @@ +//! Regression suite for `--ecosystems`-scoped rollback over hosted state. +//! +//! Formerly this pinned a replay leak: against a records-EMPTY degraded +//! redirect ledger, `rollback --ecosystems npm` replayed (and dropped) +//! leftover hosted edits of OTHER ecosystems. v5 keeps no hosted ledger: +//! the hosted pins are discovered from the lockfiles and each in-scope pin +//! is restored to its upstream registry entry on its own, so the invariant +//! becomes: an `--ecosystems`-narrowed run never restores a pin of another +//! ecosystem, and never retires a pre-v5 ledger while any hosted pin (in +//! scope or not) is still wired. The ledger's recorded edits are never +//! replayed at all. +//! +//! The fixture is a requirements.txt hosted-wired to the mock patch host +//! (recognized through `patch_server_url`) beside a pre-v5 ledger written +//! through the exported `socket_patch_core::patch::redirect` types. The +//! file is unhashed apart from the hosted line, so the pypi restore needs +//! no registry lookup and the runs stay offline. +//! +//! `#[serial]`: every command's `run` mirrors env toggles into +//! process-global env vars (`apply_env_toggles`). + +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_core::patch::redirect::{save_redirect_state, FileEdit, RedirectState}; + +const PRISTINE_LINE: &str = "requests==2.31.0"; +const WIRED_LINE: &str = "requests @ http://patch.test/patch/pypi/requests/2.31.0/22222222-2222-4222-8222-222222222222/a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1/requests-2.31.0-py3-none-any.whl --hash=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn requirements_content(line: &str) -> String { + format!("flask==2.0.1\n{line}\n") +} + +/// The requirements-line edit a pre-v5 hosted run recorded. Its recorded +/// original is deliberately NOT what the restore derives (`==2.30.0`): a +/// replay would write it, the restore must not. +fn legacy_requirements_edit() -> FileEdit { + FileEdit { + path: "requirements.txt".to_string(), + kind: "redirect_requirements_line".to_string(), + action: "rewritten".to_string(), + key: Some("requests".to_string()), + original: Some(Value::String("requests==2.30.0".to_string())), + new: Some(Value::String(WIRED_LINE.to_string())), + } +} + +fn ledger_path(root: &Path) -> std::path::PathBuf { + root.join(".socket/vendor/redirect-state.json") +} + +/// requirements.txt still hosted-wired + a pre-v5 records-empty ledger +/// holding the recorded edit. No manifest, no vendor ledger — the hosted +/// pin alone keeps the run off the truly-empty error path. +async fn write_hosted_pypi_fixture(root: &Path) { + std::fs::write( + root.join("requirements.txt"), + requirements_content(WIRED_LINE), + ) + .unwrap(); + let mut state = RedirectState::new(); + state.edits = vec![legacy_requirements_edit()]; + save_redirect_state(root, &state) + .await + .expect("write redirect ledger"); +} + +/// In-process wet rollback (`--json --yes --offline --silent`, the mock +/// patch host recognized as hosted), optionally `--ecosystems`-narrowed. +async fn rollback_in_process(cwd: &Path, ecosystems: Option>) -> i32 { + let args = RollbackArgs { + targets: Vec::new(), + common: socket_patch_cli::args::GlobalArgs { + cwd: cwd.to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + ecosystems, + offline: true, + json: true, + yes: true, + silent: true, + patch_server_url: Some("http://patch.test".to_string()), + ..socket_patch_cli::args::GlobalArgs::default() + }, + one_off: false, + preserve_state: false, + }; + let code = rollback_run(args).await; + // `apply_env_toggles` mirrored `--offline` into the PROCESS env and + // nothing unsets it; scrub so the next in-process run in this + // `#[serial]` process isn't silently forced offline. + std::env::remove_var("SOCKET_OFFLINE"); + code +} + +/// `rollback --ecosystems npm` over a project whose only hosted pin is a +/// PYPI one must restore nothing: the pypi pin stays wired and the pre-v5 +/// ledger stays (a pin is still wired), byte-identical. +#[tokio::test] +#[serial] +async fn ecosystems_scoped_rollback_leaves_other_ecosystems_pins() { + let tmp = tempfile::tempdir().unwrap(); + write_hosted_pypi_fixture(tmp.path()).await; + let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + + let code = rollback_in_process(tmp.path(), Some(vec!["npm".to_string()])).await; + assert_eq!(code, 0, "an npm-scoped run with no npm state is a no-op"); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), + requirements_content(WIRED_LINE), + "an --ecosystems npm rollback must not restore the pypi pin" + ); + assert_eq!( + std::fs::read(ledger_path(tmp.path())).unwrap(), + ledger_before, + "the pre-v5 ledger is kept while a hosted pin is still wired" + ); +} + +/// Control: a pypi-scoped (and an unscoped) rollback restores the pin to +/// the upstream `name==version` line — derived, not replayed from the +/// ledger's recorded original — and then retires the pre-v5 ledger, +/// leaving no `.socket/` files behind. +#[tokio::test] +#[serial] +async fn in_scope_rollback_restores_the_pin_and_retires_the_ledger() { + for ecosystems in [Some(vec!["pypi".to_string()]), None] { + let tmp = tempfile::tempdir().unwrap(); + write_hosted_pypi_fixture(tmp.path()).await; + + let code = rollback_in_process(tmp.path(), ecosystems.clone()).await; + assert_eq!(code, 0, "{ecosystems:?}: the pypi restore must succeed"); + + assert_eq!( + std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), + requirements_content(PRISTINE_LINE), + "{ecosystems:?}: the pin must come back as the upstream requirement" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "{ecosystems:?}: with no hosted pin left the pre-v5 ledger is retired" + ); + } +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs b/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs deleted file mode 100644 index 0f568394f..000000000 --- a/crates/socket-patch-cli/tests/coverage_fix_rollback_ecosystem_scoped_replay.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Regression suite for the `--ecosystems`-scoped rollback replay leak: -//! against a records-EMPTY degraded redirect ledger (a record-fetch-failed -//! hosted run persists its edits with no records), `replay_eligible` was -//! vacuously true for a scope spelled ONLY with `--ecosystems` — the flag -//! never fed the `scoped` flip — so `rollback --ecosystems npm` replayed, -//! and dropped from the ledger, leftover hosted edits of OTHER ecosystems -//! it was never asked about. -//! -//! The fixture hand-writes the ledger through the exported -//! `socket_patch_core::patch::redirect` types (real schema, real edit -//! kind) with the matching redirected fragment on disk — the -//! `in_process_rollback_hosted.rs` pattern. -//! -//! `#[serial]`: every command's `run` mirrors env toggles into -//! process-global env vars (`apply_env_toggles`). - -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_core::patch::redirect::{save_redirect_state, FileEdit, RedirectState}; - -const PRISTINE_LINE: &str = "requests==2.31.0"; -const WIRED_LINE: &str = "requests @ http://patch.test/patch/pypi/requests/2.31.0/22222222-2222-4222-8222-222222222222/a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1/requests-2.31.0-py3-none-any.whl --hash=sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - -fn requirements_content(line: &str) -> String { - format!("flask==2.0.1\n{line}\n") -} - -/// The leftover requirements-source edit a degraded (record-fetch-failed) -/// hosted run leaves behind: `redirect_requirements_line` has no per-purl -/// revert — only the whole-ledger replay can unwind it. -fn requirements_edit() -> FileEdit { - FileEdit { - path: "requirements.txt".to_string(), - kind: "redirect_requirements_line".to_string(), - action: "rewritten".to_string(), - key: Some("requests".to_string()), - original: Some(Value::String(PRISTINE_LINE.to_string())), - new: Some(Value::String(WIRED_LINE.to_string())), - } -} - -fn ledger_path(root: &Path) -> std::path::PathBuf { - root.join(".socket/vendor/redirect-state.json") -} - -/// requirements.txt still wired + a ledger holding the leftover pypi edit -/// and NO records (the record-fetch-failed shape). No manifest, no vendor -/// ledger — the redirect ledger alone keeps the run off the truly-empty -/// error path. -async fn write_degraded_pypi_fixture(root: &Path) { - std::fs::write( - root.join("requirements.txt"), - requirements_content(WIRED_LINE), - ) - .unwrap(); - let mut state = RedirectState::new(); - state.edits = vec![requirements_edit()]; - save_redirect_state(root, &state) - .await - .expect("write redirect ledger"); -} - -/// In-process wet rollback (`--json --yes --offline --silent`), optionally -/// `--ecosystems`-narrowed. -async fn rollback_in_process(cwd: &Path, ecosystems: Option>) -> i32 { - let args = RollbackArgs { - targets: Vec::new(), - common: socket_patch_cli::args::GlobalArgs { - cwd: cwd.to_path_buf(), - manifest_path: ".socket/manifest.json".to_string(), - ecosystems, - offline: true, - json: true, - yes: true, - silent: true, - ..socket_patch_cli::args::GlobalArgs::default() - }, - one_off: false, - preserve_state: false, - }; - let code = rollback_run(args).await; - // `apply_env_toggles` mirrored `--offline` into the PROCESS env and - // nothing unsets it; scrub so the next in-process run in this - // `#[serial]` process isn't silently forced offline. - std::env::remove_var("SOCKET_OFFLINE"); - code -} - -/// Regression: `rollback --ecosystems npm` over the records-empty degraded -/// ledger must NOT replay (and drop) the leftover PYPI edit. An -/// eco-narrowed run is a scoped run — only an unscoped rollback may claim -/// the whole-ledger replay of leftover edits. -#[tokio::test] -#[serial] -async fn ecosystems_scoped_rollback_leaves_other_ecosystems_leftover_edits() { - let tmp = tempfile::tempdir().unwrap(); - write_degraded_pypi_fixture(tmp.path()).await; - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); - - let code = rollback_in_process(tmp.path(), Some(vec!["npm".to_string()])).await; - assert_eq!(code, 0, "an npm-scoped run with no npm state is a no-op"); - - assert_eq!( - std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), - requirements_content(WIRED_LINE), - "an --ecosystems npm rollback must not unwind the pypi redirect edit" - ); - assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "the out-of-scope leftover edit must stay in the ledger" - ); -} - -/// Control: an UNSCOPED rollback -/// still replays the records-empty ledger's leftover edits and deletes -/// the emptied ledger. -#[tokio::test] -#[serial] -async fn unscoped_rollback_still_replays_leftover_edits() { - let tmp = tempfile::tempdir().unwrap(); - write_degraded_pypi_fixture(tmp.path()).await; - - let code = rollback_in_process(tmp.path(), None).await; - assert_eq!(code, 0, "unscoped replay of the leftover edit must succeed"); - - assert_eq!( - std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(), - requirements_content(PRISTINE_LINE), - "the unscoped run must unwind the leftover pypi edit" - ); - assert!( - !ledger_path(tmp.path()).exists(), - "the emptied ledger must be deleted" - ); - assert!( - !tmp.path().join(".socket").exists(), - "the replayed-out project keeps no .socket/ residue" - ); -} diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs index 928010ff9..eb35348e8 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs @@ -82,8 +82,8 @@ async fn mock_hosted_api(server: &MockServer) { }))) .mount(server) .await; - // `view/{uuid}` — the record the wet run persists into the redirect - // ledger after a confirmed redirect. + // `view/{uuid}` — the record the wet run fetches (in memory: stale-install + // probes, in-run VEX) after a confirmed redirect. let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); Mock::given(method("GET")) @@ -547,9 +547,8 @@ async fn human_takeover_prints_migration_lines_and_matching_file_counts() { ); assert!( wet_out.contains( - "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ - entries and artifacts), package.json, pnpm-lock.yaml, and pnpm-workspace.yaml \ - to keep the redirect." + "Commit .socket/vendor/ (the removed vendored ledger entries and artifacts), \ + package.json, pnpm-lock.yaml, and pnpm-workspace.yaml to keep the redirect." ), "stdout=\n{wet_out}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index ba006e945..da232d432 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -294,6 +294,7 @@ fn engine_params(root: &Path) -> DownloadParams { strict: false, ecosystems: None, persist_blobs: true, + patch_server_url: None, all_releases: true, } } diff --git a/crates/socket-patch-cli/tests/covgap_commands_remove.rs b/crates/socket-patch-cli/tests/covgap_commands_remove.rs index fa000a597..49b300deb 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_remove.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_remove.rs @@ -6,9 +6,10 @@ //! exercised in JSON mode. //! //! 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, and every wet run -//! passes `--offline`. Fixture shapes are copied from +//! `common::run_with_env`, which scrubs the ambient `SOCKET_*` env): every +//! fixture is hand-written camelCase JSON, and every wet run passes +//! `--offline` — except the hosted leg's, whose upstream restore re-resolves +//! the registry entry from a mock npm registry (`SOCKET_NPM_REGISTRY`). Fixture shapes are copied from //! remove_invariants.rs / remove_duality_invariants.rs / //! interactive_prompts_e2e.rs. @@ -158,7 +159,8 @@ fn write_vendor_ledger_entry( const DRIFTED_WIRING: &str = r#"[{ "file": "weird.txt", "kind": "npm_lock_entry", "action": "added", "key": "node_modules/x" }]"#; // --------------------------------------------------------------------------- -// Hosted-redirect fixtures (copied from remove_duality_invariants.rs). +// Hosted-redirect fixtures (copied from remove_duality_invariants.rs). The +// lockfile pin IS the hosted state; the ledger text is a pre-v5 leftover. // --------------------------------------------------------------------------- const NPM_PURL: &str = "pkg:npm/left-pad@1.3.0"; @@ -190,7 +192,7 @@ fn redirected_lock_text() -> String { ) } -/// Redirect ledger (real `RedirectState` schema) with ONE npm record and +/// A PRE-V5 redirect ledger (v5 never writes one) with ONE npm record and /// its recorded `redirect_npm_lock_entry` edit matching /// [`redirected_lock_text`]. fn npm_redirect_ledger_text() -> String { @@ -231,9 +233,10 @@ fn write_redirect_ledger_text(root: &Path, text: &str) -> PathBuf { path } -/// The exact bytes the npm redirect revert writes when it restores -/// [`redirected_lock_text`] to the pre-redirect entry (same whole-file -/// derivation the remove_duality_invariants.rs twins use). +/// The exact bytes the upstream restore writes when it puts +/// [`redirected_lock_text`] back on the registry entry the mock registry +/// serves (`ORIG_RESOLVED` / `ORIG_INTEGRITY`; same whole-file derivation +/// the remove_duality_invariants.rs twins use). fn expected_reverted_lock_text() -> String { let mut expected: serde_json::Value = serde_json::from_str(&redirected_lock_text()).expect("parse fixture lock"); @@ -623,27 +626,75 @@ fn remove_detached_vendor_state_write_failure_fails_with_code() { // --------------------------------------------------------------------------- // 5. Hosted leg: dry-run previews (main flow + hosted-only) +// +// v5 hosted state is the lockfile pin itself (no ledger): removing a hosted +// patch restores the pin's DEFAULT UPSTREAM registry entry, re-resolved +// from the mock npm registry below (`SOCKET_NPM_REGISTRY`). // --------------------------------------------------------------------------- -/// A dry-run remove touching hosted records had NEVER run. The preview -/// must leave BOTH the lockfile and the redirect ledger byte-identical -/// while reporting the would-be unwind as a Verified/hosted_reverted -/// event. +/// A mock npm registry serving left-pad@1.3.0's version document with the +/// fixture's upstream tarball + integrity (or nothing: every lookup 404s). +/// wiremock serves from its own thread; the runtime only owns the server. +struct NpmRegistry { + server: wiremock::MockServer, + _rt: tokio::runtime::Runtime, +} + +impl NpmRegistry { + fn start(serve_left_pad: bool) -> Self { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let server = rt.block_on(async { + let server = MockServer::start().await; + if serve_left_pad { + Mock::given(method("GET")) + .and(path("/npm/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": { "tarball": ORIG_RESOLVED, "integrity": ORIG_INTEGRITY } + }))) + .mount(&server) + .await; + } + server + }); + Self { server, _rt: rt } + } + + fn base(&self) -> String { + format!("{}/npm", self.server.uri()) + } +} + +/// `run_remove` with the npm registry pointed at `registry`. +fn run_remove_online(cwd: &Path, args: &[&str], registry: &NpmRegistry) -> (i32, String, String) { + let base = registry.base(); + run_remove(cwd, args, &[("SOCKET_NPM_REGISTRY", base.as_str())]) +} + +fn legacy_ledger_path(root: &Path) -> PathBuf { + root.join(".socket/vendor/redirect-state.json") +} + +/// A dry-run remove touching a hosted pin must leave the lockfile +/// byte-identical (and write no ledger) while reporting the would-be +/// restore as a Verified/hosted_reverted event. #[test] -fn remove_hosted_dry_run_leaves_lock_and_ledger_untouched() { +fn remove_hosted_dry_run_leaves_lock_untouched() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); let lock_before = read_bytes(&lock_path); let socket = write_manifest_files_empty(tmp.path(), NPM_PURL, NPM_UUID); let manifest_before = read_bytes(&socket.join("manifest.json")); + let registry = NpmRegistry::start(true); - let (code, stdout, stderr) = run_remove( + let (code, stdout, stderr) = run_remove_online( tmp.path(), - &[NPM_PURL, "--json", "--yes", "--offline", "--dry-run"], - &[], + &[NPM_PURL, "--json", "--yes", "--dry-run"], + ®istry, ); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout); @@ -657,7 +708,7 @@ fn remove_hosted_dry_run_leaves_lock_and_ledger_untouched() { events.iter().any(|e| e["action"] == "verified" && e["purl"] == NPM_PURL && e["errorCode"] == "hosted_reverted"), - "the hosted unwind preview must be a verified/hosted_reverted event: {events:?}" + "the hosted restore preview must be a verified/hosted_reverted event: {events:?}" ); assert!( events.iter().all(|e| e["action"] != "removed"), @@ -666,10 +717,9 @@ fn remove_hosted_dry_run_leaves_lock_and_ledger_untouched() { // Nothing on disk moved. assert_eq!(read_bytes(&lock_path), lock_before, "lock byte-identical"); - assert_eq!( - read_bytes(&ledger_path), - ledger_before, - "ledger byte-identical" + assert!( + !legacy_ledger_path(tmp.path()).exists(), + "no hosted ledger is ever written" ); assert_eq!( read_bytes(&socket.join("manifest.json")), @@ -685,21 +735,17 @@ fn remove_hosted_only_dry_run_previews_without_mutation() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); let lock_before = read_bytes(&lock_path); + let registry = NpmRegistry::start(true); - let (code, stdout, stderr) = run_remove( - tmp.path(), - &[NPM_PURL, "--json", "--offline", "--dry-run"], - &[], - ); + let (code, stdout, stderr) = + run_remove_online(tmp.path(), &[NPM_PURL, "--json", "--dry-run"], ®istry); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout); assert_eq!(v["dryRun"], true); assert_eq!( v["summary"]["verified"], 1, - "the preview must count the would-be unwind; envelope={v}" + "the preview must count the would-be restore; envelope={v}" ); assert_eq!(v["summary"]["removed"], 0); let events = v["events"].as_array().expect("events array"); @@ -710,14 +756,9 @@ fn remove_hosted_only_dry_run_previews_without_mutation() { "expected a verified/hosted_reverted preview event: {events:?}" ); assert_eq!(read_bytes(&lock_path), lock_before, "lock byte-identical"); - assert_eq!( - read_bytes(&ledger_path), - ledger_before, - "ledger byte-identical" - ); assert!( - !tmp.path().join(".socket/manifest.json").exists(), - "no manifest may be materialized as a side effect" + !tmp.path().join(".socket").exists(), + "no manifest (or any .socket/ state) may be materialized as a side effect" ); } @@ -725,7 +766,7 @@ fn remove_hosted_only_dry_run_previews_without_mutation() { // 6. Hosted leg: --preserve-state note + preserve-state human summary // --------------------------------------------------------------------------- -/// `--preserve-state` still unwinds hosted redirects (hosted has no +/// `--preserve-state` still restores hosted pins (hosted has no /// preservable local state) and the human run must say so on stderr — /// while the manifest entry survives and the final preserve summary /// prints. @@ -734,18 +775,18 @@ fn remove_hosted_preserve_state_notes_no_preservable_state() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); std::fs::write(&lock_path, redirected_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 manifest_before = read_bytes(&socket.join("manifest.json")); + let registry = NpmRegistry::start(true); - let (code, stdout, stderr) = run_remove( + let (code, stdout, stderr) = run_remove_online( tmp.path(), - &[NPM_PURL, "--yes", "--offline", "--preserve-state"], - &[], + &[NPM_PURL, "--yes", "--preserve-state"], + ®istry, ); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("hosted redirects have no preservable local state"), + stderr.contains("hosted wiring has no preservable local state"), "the preserve-state hosted note must reach stderr; got:\n{stderr}" ); assert!( @@ -753,15 +794,11 @@ fn remove_hosted_preserve_state_notes_no_preservable_state() { "the preserve-state summary must print; got:\n{stdout}" ); - // The unwind really happened: lock restored, emptied ledger deleted. + // The restore really happened: the lock holds the upstream entry. assert_eq!( std::fs::read_to_string(&lock_path).unwrap(), expected_reverted_lock_text(), - "the lock must hold exactly the pre-redirect entry" - ); - assert!( - !ledger_path.exists(), - "the emptied redirect ledger must be deleted" + "the lock must hold exactly the upstream registry entry" ); // The state half was preserved: manifest byte-identical. assert_eq!( @@ -772,15 +809,14 @@ fn remove_hosted_preserve_state_notes_no_preservable_state() { } // --------------------------------------------------------------------------- -// 7. Corrupt hosted-redirect ledger: warn-and-continue +// 7. Corrupt pre-v5 hosted ledger: ignored // --------------------------------------------------------------------------- -/// A corrupt redirect ledger must not block a manifest removal: the main -/// flow warns on stderr ("hosted redirects were not examined") and the -/// removal still succeeds. The corrupt ledger is left alone (it may hold -/// revert data a human can repair). +/// A corrupt pre-v5 redirect ledger is inert: hosted state comes from the +/// lockfiles, so a manifest removal neither reads nor warns about it, the +/// removal succeeds, and the file is left alone. #[test] -fn remove_corrupt_hosted_ledger_warns_and_continues_human() { +fn remove_corrupt_legacy_hosted_ledger_is_ignored_human() { let tmp = tempfile::tempdir().expect("tempdir"); let purl = "pkg:npm/__covgap_hcorrupt__@1.0.0"; let socket = @@ -791,11 +827,11 @@ fn remove_corrupt_hosted_ledger_warns_and_continues_human() { let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--yes", "--offline"], &[]); assert_eq!( code, 0, - "a corrupt hosted ledger must not block the removal; stdout=\n{stdout}\nstderr=\n{stderr}" + "a corrupt pre-v5 ledger must not block the removal; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("hosted redirects were not examined"), - "the warning must reach stderr; got:\n{stderr}" + !stderr.contains("redirect") && !stderr.contains("ledger"), + "the pre-v5 ledger is never read, so nothing warns about it; got:\n{stderr}" ); assert!( stdout.contains("Removed 1 patch from manifest:"), @@ -811,15 +847,14 @@ fn remove_corrupt_hosted_ledger_warns_and_continues_human() { assert_eq!( read_bytes(&ledger_path), ledger_before, - "the corrupt ledger must be left alone (it may hold repairable revert data)" + "the corrupt ledger must be left alone" ); } -/// JSON twin: the removal still succeeds and the corrupt ledger is left -/// alone. (Known gap, deliberately NOT pinned here: JSON mode currently -/// carries no machine-visible signal for the skipped hosted leg.) +/// JSON twin: the removal still succeeds with no warning, and the corrupt +/// ledger is left alone. #[test] -fn remove_corrupt_hosted_ledger_json_still_removes() { +fn remove_corrupt_legacy_hosted_ledger_is_ignored_json() { let tmp = tempfile::tempdir().expect("tempdir"); let purl = "pkg:npm/__covgap_hcorrupt__@1.0.0"; write_manifest_files_empty(tmp.path(), purl, "77777777-7777-4777-8777-777777777777"); @@ -833,6 +868,10 @@ fn remove_corrupt_hosted_ledger_json_still_removes() { assert_eq!(v["status"], "success"); assert_eq!(v["summary"]["removed"], 1, "envelope={v}"); assert_eq!(event_purls(&v, "removed"), vec![purl]); + assert!( + v["warnings"].as_array().is_none_or(Vec::is_empty), + "the pre-v5 ledger is never read: no warning; envelope={v}" + ); assert_eq!( read_bytes(&ledger_path), ledger_before, @@ -845,15 +884,13 @@ fn remove_corrupt_hosted_ledger_json_still_removes() { // --------------------------------------------------------------------------- /// With no manifest entry to delete, removing a hosted patch can only mean -/// unwinding its redirect — `--skip-rollback` is refused with +/// restoring its upstream entry — `--skip-rollback` is refused with /// `hosted_state_retained` and nothing moves. #[test] fn remove_hosted_only_skip_rollback_refused() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); let lock_before = read_bytes(&lock_path); let (code, stdout, stderr) = run_remove( @@ -870,21 +907,27 @@ fn remove_hosted_only_skip_rollback_refused() { msg.contains(NPM_PURL) && msg.contains("--skip-rollback"), "the refusal must name the purl and the flag; got: {msg}" ); - assert_eq!(read_bytes(&ledger_path), ledger_before, "ledger untouched"); assert_eq!(read_bytes(&lock_path), lock_before, "lock untouched"); + assert!(!tmp.path().join(".socket").exists(), "nothing written"); } /// Hosted-only human mode: the pre-confirm listing ("The following hosted -/// redirect(s) will be unwound and removed:") must print to stderr, and -/// the wet run must complete the unwind. +/// redirect(s) will be unwound and removed:") must print to stderr, the +/// wet run must restore the upstream entry, and a pre-v5 ledger left +/// beside the pin is retired once no hosted pin remains (it is never the +/// revert source: its recorded original disagrees with the registry). #[test] fn remove_hosted_only_human_lists_redirects_and_unwinds() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); + let ledger_path = write_redirect_ledger_text( + tmp.path(), + &npm_redirect_ledger_text().replace(ORIG_INTEGRITY, "sha512-LEDGERledger=="), + ); + let registry = NpmRegistry::start(true); - let (code, stdout, stderr) = run_remove(tmp.path(), &[NPM_PURL, "--yes", "--offline"], &[]); + let (code, stdout, stderr) = run_remove_online(tmp.path(), &[NPM_PURL, "--yes"], ®istry); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( stderr.contains("The following hosted redirect will be unwound and removed:"), @@ -894,71 +937,76 @@ fn remove_hosted_only_human_lists_redirects_and_unwinds() { stderr.contains(&format!(" - {NPM_PURL}")), "the listing must name the purl; got:\n{stderr}" ); - // The unwind really ran: lock restored byte-exactly, ledger deleted. + assert!( + stdout.contains(&format!( + "Restored {NPM_PURL} to its upstream registry entry" + )), + "the restore line must print; got:\n{stdout}" + ); + // The restore really ran: lock holds the registry's entry byte-exactly. assert_eq!( std::fs::read_to_string(&lock_path).unwrap(), expected_reverted_lock_text(), - "the lock must hold exactly the pre-redirect entry" + "the lock must hold exactly the upstream registry entry" ); assert!( !ledger_path.exists(), - "the emptied redirect ledger must be deleted" + "the pre-v5 ledger is retired once no hosted pin remains" ); } // --------------------------------------------------------------------------- -// 9. Hosted per-purl revert failure fails closed (main flow + hosted-only +// 9. Hosted upstream-restore refusal fails closed (main flow + hosted-only // twin) // --------------------------------------------------------------------------- -/// A package-lock.json that is no longer valid JSON makes the recorded -/// `redirect_npm_lock_entry` revert fail — the remove must abort with -/// `hosted_revert_failed` BEFORE the manifest mutation, leaving every -/// store byte-identical. +/// The registry cannot re-resolve the upstream entry (404): the pin is +/// refused, and the remove must abort with `hosted_revert_failed` BEFORE +/// the manifest mutation, leaving every store byte-identical. #[test] fn remove_hosted_revert_failure_fails_closed() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); - std::fs::write(&lock_path, "{corrupt lock").unwrap(); + std::fs::write(&lock_path, redirected_lock_text()).unwrap(); let lock_before = read_bytes(&lock_path); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); let socket = write_manifest_files_empty(tmp.path(), NPM_PURL, NPM_UUID); let manifest_before = read_bytes(&socket.join("manifest.json")); + let registry = NpmRegistry::start(false); let (code, stdout, stderr) = - run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + run_remove_online(tmp.path(), &[NPM_PURL, "--json", "--yes"], ®istry); assert_eq!( code, 1, - "a failed hosted revert must abort the remove; stdout=\n{stdout}\nstderr=\n{stderr}" + "a refused hosted restore must abort the remove; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout); assert_eq!(v["status"], "error"); assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("could not unwind hosted redirect for pkg:npm/left-pad@1.3.0") + msg.contains("cannot restore pkg:npm/left-pad@1.3.0 to its upstream registry entry") + && msg.contains("404") + && msg.contains("git checkout -- package-lock.json") && msg.contains("The manifest was not modified."), - "the error must name the purl and promise the manifest is intact; got: {msg}" + "the error must name the purl, the cause, the remedy and promise the manifest is \ + intact; got: {msg}" ); assert_eq!(v["summary"]["removed"], 0); - // Fail-closed: manifest, ledger, and (corrupt) lock all byte-identical. + // Fail-closed: manifest and lock byte-identical. assert_eq!(read_bytes(&socket.join("manifest.json")), manifest_before); - assert_eq!(read_bytes(&ledger_path), ledger_before); assert_eq!(read_bytes(&lock_path), lock_before); } -/// Hosted-only twin: the same corrupt-lock failure surfaces through -/// `remove_hosted_only`'s failed-revert branch. +/// Hosted-only twin: an `--offline` run cannot re-resolve the upstream +/// entry, so the pin is refused through `remove_hosted_only`'s +/// failed-restore branch. #[test] fn remove_hosted_only_revert_failure_fails_closed() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); - std::fs::write(&lock_path, "{corrupt lock").unwrap(); + std::fs::write(&lock_path, redirected_lock_text()).unwrap(); let lock_before = read_bytes(&lock_path); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); let (code, stdout, stderr) = run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); @@ -968,45 +1016,42 @@ fn remove_hosted_only_revert_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("could not unwind hosted redirect for pkg:npm/left-pad@1.3.0"), - "the error must name the purl; got: {msg}" - ); - assert_eq!(read_bytes(&ledger_path), ledger_before, "ledger untouched"); - assert_eq!( - read_bytes(&lock_path), - lock_before, - "corrupt lock untouched" + msg.contains("cannot restore pkg:npm/left-pad@1.3.0 to its upstream registry entry") + && msg.contains("this run is offline"), + "the error must name the purl and the offline cause; got: {msg}" ); + assert_eq!(read_bytes(&lock_path), lock_before, "lock untouched"); } // --------------------------------------------------------------------------- -// 10. Hosted ledger persist failure after successful lockfile reverts -// (main flow + hosted-only). Unix-only. +// 10. Restored-lockfile write failure (main flow + hosted-only). Unix-only; +// skipped where directory modes are not enforced (root). // --------------------------------------------------------------------------- -/// The per-purl reverts flush lockfile writes as they go; when the ledger -/// persist then fails, the remove must abort with `hosted_revert_failed` -/// naming the persist — the manifest untouched, the (stale) ledger still -/// on disk, and the lock already restored (the documented desync posture). +/// Every pin resolved, but the restored lockfile cannot be written (a +/// read-only project root; the write is a temp-file + rename beside it): +/// the remove aborts with `hosted_revert_failed` naming the write — the +/// manifest untouched and the lock byte-identical. #[cfg(unix)] #[test] -fn remove_hosted_ledger_persist_failure_fails_closed() { +fn remove_hosted_lockfile_write_failure_fails_closed() { let tmp = tempfile::tempdir().expect("tempdir"); - let lock_path = tmp.path().join("package-lock.json"); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let lock_path = root.join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); - let socket = write_manifest_files_empty(tmp.path(), NPM_PURL, NPM_UUID); + let lock_before = read_bytes(&lock_path); + let socket = write_manifest_files_empty(&root, NPM_PURL, NPM_UUID); let manifest_before = read_bytes(&socket.join("manifest.json")); - let vendor = tmp.path().join(".socket/vendor"); + let registry = NpmRegistry::start(true); - chmod(&vendor, 0o555); - let _mode = ModeGuard(vendor.clone()); - if !readonly_dir_enforced(&vendor) { + chmod(&root, 0o555); + let _mode = ModeGuard(root.clone()); + if !readonly_dir_enforced(&root) { return; } let (code, stdout, stderr) = - run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + run_remove_online(&root, &[NPM_PURL, "--json", "--yes"], ®istry); assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout); @@ -1014,8 +1059,8 @@ fn remove_hosted_ledger_persist_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("failed to persist the hosted redirect ledger"), - "the error must name the persist failure; got: {msg}" + msg.contains("writing the restored lockfiles failed"), + "the error must name the write failure; got: {msg}" ); assert_eq!( read_bytes(&socket.join("manifest.json")), @@ -1023,37 +1068,33 @@ fn remove_hosted_ledger_persist_failure_fails_closed() { "the manifest mutation must not have happened" ); assert_eq!( - read_bytes(&ledger_path), - ledger_before, - "the unwritable ledger keeps its old bytes" - ); - // The lockfile edits flushed BEFORE the persist (the documented - // desync-avoidance ordering): the lock is already restored. - assert_eq!( - std::fs::read_to_string(&lock_path).unwrap(), - expected_reverted_lock_text(), - "the per-purl revert flushed the lock before the persist failed" + read_bytes(&lock_path), + lock_before, + "the lock was never written" ); } -/// Hosted-only twin of the persist failure. +/// Hosted-only twin of the write failure. #[cfg(unix)] #[test] -fn remove_hosted_only_ledger_persist_failure_fails_closed() { +fn remove_hosted_only_lockfile_write_failure_fails_closed() { let tmp = tempfile::tempdir().expect("tempdir"); - let lock_path = tmp.path().join("package-lock.json"); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let lock_path = root.join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); - let vendor = tmp.path().join(".socket/vendor"); + let lock_before = read_bytes(&lock_path); + // `.socket/` exists and stays writable so the apply lock is taken. + std::fs::create_dir(root.join(".socket")).unwrap(); + let registry = NpmRegistry::start(true); - chmod(&vendor, 0o555); - let _mode = ModeGuard(vendor.clone()); - if !readonly_dir_enforced(&vendor) { + chmod(&root, 0o555); + let _mode = ModeGuard(root.clone()); + if !readonly_dir_enforced(&root) { return; } let (code, stdout, stderr) = - run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + run_remove_online(&root, &[NPM_PURL, "--json", "--yes"], ®istry); assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout); @@ -1061,18 +1102,13 @@ fn remove_hosted_only_ledger_persist_failure_fails_closed() { assert_eq!(v["error"]["code"], "hosted_revert_failed", "envelope={v}"); let msg = v["error"]["message"].as_str().expect("message string"); assert!( - msg.contains("failed to persist the hosted redirect ledger"), - "the error must name the persist failure; got: {msg}" - ); - assert_eq!( - read_bytes(&ledger_path), - ledger_before, - "ledger keeps its old bytes" + msg.contains("writing the restored lockfiles failed"), + "the error must name the write failure; got: {msg}" ); assert_eq!( - std::fs::read_to_string(&lock_path).unwrap(), - expected_reverted_lock_text(), - "the per-purl revert flushed the lock before the persist failed" + read_bytes(&lock_path), + lock_before, + "the lock was never written" ); } @@ -1799,14 +1835,13 @@ mod pty { } /// Declining the hosted-only confirm prompt must cancel cleanly (exit - /// 0, "Removal cancelled.") with the lock and ledger byte-identical. + /// 0, "Removal cancelled.") with the lock byte-identical and no + /// `.socket/` state written. #[test] fn remove_hosted_only_interactive_n_cancels() { let tmp = tempfile::tempdir().expect("tempdir"); let lock_path = tmp.path().join("package-lock.json"); std::fs::write(&lock_path, redirected_lock_text()).unwrap(); - let ledger_path = write_redirect_ledger_text(tmp.path(), &npm_redirect_ledger_text()); - let ledger_before = read_bytes(&ledger_path); let lock_before = read_bytes(&lock_path); let (code, output) = run_in_pty( @@ -1833,8 +1868,11 @@ mod pty { "'n' must report cancellation; got: {output}" ); // Declined: nothing moved. - assert_eq!(read_bytes(&ledger_path), ledger_before, "ledger untouched"); assert_eq!(read_bytes(&lock_path), lock_before, "lock untouched"); + assert!( + !tmp.path().join(".socket").exists(), + "a declined remove leaves no .socket/ state" + ); } } diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index bfa158519..ded85f9b3 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -4,8 +4,8 @@ //! per-file details, preserve-state closing message, and the //! error-class stderr notices (other suites run `--json`/`--silent`); //! 2. failure legs of the vendored and hosted rollback (unknown-backend -//! revert failure, ledger save/persist failure, replay refusal, -//! per-purl revert I/O failure, corrupt ledgers); +//! revert failure, ledger save failure, per-pin upstream-restore +//! refusals, lockfile write failure, pre-v5 hosted ledger retirement); //! 3. GC-failure warnings (unix permissions); //! 4. manifest-write failure (macOS immutable flag); //! 5. the interactive confirm DECLINE (PTY-driven, like @@ -19,18 +19,16 @@ //! //! Binary-driven throughout (the `rollback_duality_invariants.rs` shape): //! `SOCKET_*`-scrubbed child processes via `common::run`, hand-written -//! camelCase manifests, git-sha256 oracle, `--offline` everywhere. Hosted -//! ledgers are serialized through the real `RedirectState`/`FileEdit` -//! types so fixtures can never drift from the on-disk schema (the -//! `in_process_rollback_hosted.rs` convention). +//! camelCase manifests, git-sha256 oracle, `--offline` everywhere except the +//! hosted leg: v5 hosted state is the lockfile pins themselves, and their +//! rollback restores the upstream registry entry from a mock npm registry +//! (`SOCKET_NPM_REGISTRY`, see `NpmRegistry`). #[path = "common/pty_io.rs"] mod pty_io; use std::path::{Path, PathBuf}; use serde_json::{json, Value}; -use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; -use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; #[path = "common/mod.rs"] mod common; @@ -149,8 +147,6 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], - before_hash: String, - after_hash: String, } fn patched_fixture() -> PatchedFixture { @@ -179,8 +175,6 @@ fn patched_fixture() -> PatchedFixture { purl, before, after, - before_hash, - after_hash, } } @@ -1350,73 +1344,134 @@ fn qualified_manifest_purl_removed_after_vendored_revert() { } // ═════════════════════════ 3. hosted-leg gaps ══════════════════════════════ +// +// v5 hosted mode keeps no ledger: the hosted pins are discovered from the +// lockfiles (on `--patch-server-url`'s origin for these mock-host URLs) and +// rollback restores each pin's DEFAULT UPSTREAM registry entry, re-resolved +// from the (mocked) npm registry. A pre-v5 ledger is never replayed; it is +// retired once no hosted pin remains. 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/"; - -/// A full camelCase patch record for hand-written ledgers. -fn hosted_record(uuid: &str) -> PatchRecord { - let mut files = std::collections::HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "a".repeat(64), - after_hash: "b".repeat(64), - }, - ); - PatchRecord { - uuid: uuid.to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files, - vulnerabilities: std::collections::HashMap::new(), - description: "x".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), +const IO_PURL: &str = "pkg:npm/is-odd@3.0.1"; +const IO_HOSTED_URL: &str = "http://patch.test/patch/npm/is-odd/3.0.1/66666666-6666-4666-8666-666666666666/99999999-9999-4999-8999-999999999999/is-odd-3.0.1.tgz"; +const PATCH_SERVER: &str = "http://patch.test"; +const UPSTREAM_INTEGRITY: &str = "sha512-UPSTREAMupstream=="; + +/// A mock npm registry (the `SOCKET_NPM_REGISTRY` override the upstream +/// restore reads) serving the version document of each `(name, version)` +/// it was started with; everything else is a 404. The server runs on +/// wiremock's own thread; the runtime is kept only to own it. +struct NpmRegistry { + server: wiremock::MockServer, + _rt: tokio::runtime::Runtime, +} + +impl NpmRegistry { + fn start(served: &[(&str, &str)]) -> Self { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let server = rt.block_on(async { + let server = MockServer::start().await; + for (name, version) in served { + Mock::given(method("GET")) + .and(path(format!("/npm/{name}/{version}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": name, + "version": version, + "dist": { + "tarball": format!( + "https://registry.yarnpkg.com/{name}/-/{name}-{version}.tgz" + ), + "shasum": "aaaa", + "integrity": UPSTREAM_INTEGRITY, + } + }))) + .mount(&server) + .await; + } + server + }); + Self { server, _rt: rt } + } + + fn base(&self) -> String { + format!("{}/npm", self.server.uri()) + } + + fn request_count(&self) -> usize { + self._rt + .block_on(self.server.received_requests()) + .map(|r| r.len()) + .unwrap_or(0) } } -/// Serialize a hand-written ledger through the real core types (real -/// schema: version, mode "hosted", edits[FileEdit], records{purl:record}). -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); +/// `common::run` with the mock patch host recognized as hosted +/// (`--patch-server-url`) and, when given, the npm registry pointed at the +/// mock. +fn run_hosted(cwd: &Path, args: &[&str], registry: Option<&NpmRegistry>) -> (i32, String, String) { + let mut full: Vec<&str> = args.to_vec(); + full.extend(["--patch-server-url", PATCH_SERVER]); + match registry { + Some(r) => { + let base = r.base(); + common::run_with_env(cwd, &full, &[("SOCKET_NPM_REGISTRY", base.as_str())]) + } + None => run(cwd, &full), } - let vendor_dir = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).expect("create .socket/vendor"); - let mut bytes = serde_json::to_vec_pretty(&state).expect("serialize ledger"); - bytes.push(b'\n'); - std::fs::write(vendor_dir.join("redirect-state.json"), bytes).expect("write ledger"); } fn ledger_path(root: &Path) -> PathBuf { root.join(".socket/vendor/redirect-state.json") } -// yarn-classic fragments — `redirect_yarn_classic_entry` is a text kind the -// per-purl npm revert claims by `@` key. +/// Write a pre-v5 hosted ledger (v5 never writes one) with the given raw +/// `edits`, exactly as an old release serialized it. +fn write_legacy_ledger(root: &Path, edits: Value) { + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).expect("create .socket/vendor"); + let ledger = json!({ + "version": 1, + "mode": "hosted", + "edits": edits, + "records": {}, + }); + let mut bytes = serde_json::to_vec_pretty(&ledger).expect("serialize ledger"); + bytes.push(b'\n'); + std::fs::write(vendor_dir.join("redirect-state.json"), bytes).expect("write ledger"); +} + +// yarn-classic fragments: the upstream block is exactly what the restore +// re-derives from the mock registry's version document. -fn yarn_block(resolved: &str, integrity: &str) -> String { +fn yarn_block_for(name: &str, version: &str, resolved: &str, integrity: &str) -> String { format!( - "left-pad@1.2.3:\n version \"1.2.3\"\n resolved \"{resolved}\"\n integrity {integrity}" + "{name}@{version}:\n version \"{version}\"\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_upstream_block_for(name: &str, version: &str) -> String { + yarn_block_for( + name, + version, + &format!("https://registry.yarnpkg.com/{name}/-/{name}-{version}.tgz#aaaa"), + UPSTREAM_INTEGRITY, ) } +fn yarn_original_block() -> String { + yarn_upstream_block_for("left-pad", "1.2.3") +} + fn yarn_redirected_block() -> String { - yarn_block(LP_HOSTED_URL, "sha512-PATCHEDpatched==") + yarn_block_for( + "left-pad", + "1.2.3", + LP_HOSTED_URL, + "sha512-PATCHEDpatched==", + ) } fn yarn_lock_content(block: &str) -> String { @@ -1426,99 +1481,42 @@ fn yarn_lock_content(block: &str) -> String { ) } -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 — `redirect_gemfile_lock_source_url` has NO per-purl revert. - -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())), - } -} - -/// Single-record npm fixture (yarn-classic wiring, redirected on disk). +/// Single-pin npm fixture: a yarn.lock hosted-wired to the mock patch host. 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, hosted_record(LP_UUID))], - vec![yarn_classic_edit()], - ); } -/// Two-record fixture: npm (per-purl revertable) + gem (replay-only). -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, hosted_record(LP_UUID)), - (GEM_PURL, hosted_record(GEM_UUID)), - ], - vec![yarn_classic_edit(), gem_source_edit()], - ); -} - -/// Human wet run over a hosted-only (manifest-less) project: the wet -/// "Unwound hosted redirect for {purl}" line and the reinstall note — with -/// the wiring actually unwound, the emptied ledger deleted and no +/// Human wet run over a hosted-only (manifest-less, ledger-less) project: +/// the "Restored {purl} to its upstream registry entry" line and the +/// reinstall note — with the lock entry actually restored and no /// `.socket/` residue. The unscoped "No patches found in manifest" line is /// reserved for a run with no work in ANY leg: a project whose patches are -/// all hosted has work, so the line must NOT print alongside the unwind. +/// all hosted has work, so the line must NOT print alongside the restore. #[test] fn hosted_human_wet_announces_and_unwinds() { let tmp = tempfile::tempdir().expect("tempdir"); write_single_npm_fixture(tmp.path()); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes"]); + let (code, stdout, stderr) = run_hosted(tmp.path(), &["rollback", "--yes"], Some(®istry)); assert_eq!( code, 0, "the hosted-only rollback succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); - // No manifest at all: "No patches found in manifest" would be a - // misleading line right above the hosted unwind. assert!( !stdout.contains("No patches found in manifest"), "the empty-manifest announce must not print when the hosted leg has work; \ stdout=\n{stdout}" ); assert!( - stdout.contains(&format!("Unwound hosted redirect for {LP_PURL}")), - "the wet unwind line must print; stdout=\n{stdout}" + stdout.contains(&format!( + "Restored {LP_PURL} to its upstream registry entry" + )), + "the wet restore line must print; stdout=\n{stdout}" ); assert!( stdout.contains("1 unwired package keeps its patched bytes"), @@ -1527,238 +1525,312 @@ fn hosted_human_wet_announces_and_unwinds() { assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_original_block()), - "the wiring must be unwound on disk" + "the lock entry must be restored to the upstream registry entry" ); assert!( !ledger_path(tmp.path()).exists(), - "the emptied ledger must be deleted" + "no ledger is ever written" ); assert!( !tmp.path().join(".socket").exists(), - "a fully unwound hosted project keeps no .socket/ residue (vendor/ pruned \ - with the ledger, apply.lock removed by the lock guard)" + "a fully restored hosted project keeps no .socket/ residue (apply.lock \ + removed by the lock guard)" ); } -/// Human dry-run twin: "Would unwind hosted redirect for {purl}", nothing -/// mutated. +/// Human dry-run twin: "Would restore {purl} …", nothing mutated. A dry +/// run resolves exactly like a wet run (the registry IS asked) and skips +/// only the write. #[test] fn hosted_human_dry_run_previews() { let tmp = tempfile::tempdir().expect("tempdir"); write_single_npm_fixture(tmp.path()); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--dry-run"]); + let (code, stdout, stderr) = + run_hosted(tmp.path(), &["rollback", "--dry-run"], Some(®istry)); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains(&format!("Would unwind hosted redirect for {LP_PURL}")), - "the dry-run unwind preview must print; stdout=\n{stdout}" + stdout.contains(&format!( + "Would restore {LP_PURL} to its upstream registry entry" + )), + "the dry-run restore preview must print; stdout=\n{stdout}" + ); + assert!( + registry.request_count() >= 1, + "a dry run resolves the upstream entry like a wet run" ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_redirected_block()), "dry run must not touch the wired lock" ); - assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "dry run must not touch the ledger" - ); + assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } -/// Human notice for a SCOPED hosted target with no per-purl revert (gem, -/// with an out-of-scope npm record blocking the replay): the "Cannot -/// unwind hosted redirect for …" stderr guidance, exit 1, nothing touched. +/// `--offline` cannot re-resolve the upstream entry: the pin is REFUSED +/// with the `git checkout` remedy on stderr (human), exit 1, nothing +/// written — the registry is never asked. #[test] -fn scoped_unsupported_ecosystem_prints_human_notice() { +fn offline_refusal_prints_human_notice() { let tmp = tempfile::tempdir().expect("tempdir"); - write_two_record_fixture(tmp.path()); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + write_single_npm_fixture(tmp.path()); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes", GEM_PURL]); + let (code, stdout, stderr) = run_hosted( + tmp.path(), + &["rollback", "--offline", "--yes", LP_PURL], + Some(®istry), + ); assert_eq!( code, 1, - "a scoped unsupported hosted purl fails closed; stdout=\n{stdout}\nstderr=\n{stderr}" + "an offline hosted restore is refused; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains(&format!("Cannot unwind hosted redirect for {GEM_PURL}")) - && stderr.contains("no per-purl revert exists"), - "the human guidance must print on stderr; stderr=\n{stderr}" - ); - assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "the ledger must be untouched" + stderr.contains(&format!( + "Error: Cannot restore {LP_PURL} to its upstream registry entry" + )) && stderr.contains("this run is offline") + && stderr.contains("`git checkout -- yarn.lock`"), + "the human refusal must print on stderr with the remedy; stderr=\n{stderr}" ); + assert_eq!(registry.request_count(), 0, "offline asks no registry"); assert_eq!( - std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(), - gemfile_lock_content(GEM_PATCH_REMOTE), - "the refused gem wiring must be untouched" + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_redirected_block()), + "the refused pin's wiring must be untouched" ); } -/// Per-purl hosted revert FAILURE: the wired lockfile is unreadable -/// (yarn.lock is a directory), so the scoped npm revert errors — the purl -/// + error land in `hosted.failed`, exit 1, everything else untouched. +/// Two pins in one yarn.lock, one of which the registry does not answer +/// for: each pin restores or refuses on its own. The refused purl + error +/// land in `hosted.failed` (exit 1, `partial_failure`), the other pin is +/// restored, and the refused block stays hosted byte-for-byte. #[test] fn per_purl_revert_failure_lands_in_hosted_failed() { let tmp = tempfile::tempdir().expect("tempdir"); - // Two records so the scoped npm run is NOT replay-eligible: the - // failure under test is the per-purl revert alone. - std::fs::create_dir(tmp.path().join("yarn.lock")).unwrap(); // a DIRECTORY + let io_redirected = yarn_block_for("is-odd", "3.0.1", IO_HOSTED_URL, "sha512-PATCHEDio=="); std::fs::write( - tmp.path().join("Gemfile.lock"), - gemfile_lock_content(GEM_PATCH_REMOTE), + tmp.path().join("yarn.lock"), + yarn_lock_content(&format!("{io_redirected}\n\n{}", yarn_redirected_block())), ) .unwrap(); - write_hosted_ledger( - tmp.path(), - vec![ - (LP_PURL, hosted_record(LP_UUID)), - (GEM_PURL, hosted_record(GEM_UUID)), - ], - vec![yarn_classic_edit(), gem_source_edit()], - ); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + // left-pad is NOT served: its lookup 404s. + let registry = NpmRegistry::start(&[("is-odd", "3.0.1")]); - let (code, stdout, stderr) = run( + let (code, stdout, stderr) = run_hosted( tmp.path(), - &["rollback", "--json", "--offline", "--yes", LP_PURL], + &["rollback", "--json", "--yes"], + Some(®istry), ); assert_eq!( code, 1, - "a failed per-purl revert must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + "a refused pin must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); let failed = v["hosted"]["failed"].as_array().expect("failed array"); assert_eq!(failed.len(), 1, "stdout=\n{stdout}"); assert_eq!(failed[0]["purl"], LP_PURL, "stdout=\n{stdout}"); + let error = failed[0]["error"].as_str().unwrap_or_default(); assert!( - failed[0]["error"] - .as_str() - .is_some_and(|e| e.contains("read yarn.lock")), - "the error must name the unreadable lockfile; stdout=\n{stdout}" + error.contains(&format!( + "cannot restore {LP_PURL} to its upstream registry entry" + )) && error.contains("404") + && error.contains("git checkout -- yarn.lock"), + "the error must name the purl, the registry failure and the remedy; \ + stdout=\n{stdout}" ); assert_eq!( v["hosted"]["reverted"], - json!([]), - "nothing may be reported reverted; stdout=\n{stdout}" + json!([IO_PURL]), + "the other pin restores on its own; stdout=\n{stdout}" ); assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "a failed revert must leave the ledger byte-identical" + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&format!( + "{}\n\n{}", + yarn_upstream_block_for("is-odd", "3.0.1"), + yarn_redirected_block() + )), + "only the resolvable pin is restored; the refused one stays hosted" ); +} + +/// Human twin of `per_purl_revert_failure_lands_in_hosted_failed`: the +/// refused pin prints the "Error: Cannot restore {purl} …" stderr line +/// (errors print even without `--json`), exit 1, the lock untouched. +#[test] +fn per_purl_revert_failure_prints_human_stderr_line() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_single_npm_fixture(tmp.path()); + let registry = NpmRegistry::start(&[]); + + let (code, stdout, stderr) = + run_hosted(tmp.path(), &["rollback", "--yes", LP_PURL], Some(®istry)); assert_eq!( - std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(), - gemfile_lock_content(GEM_PATCH_REMOTE), - "the out-of-scope gem wiring must be untouched" + code, 1, + "a refused pin must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stderr.contains(&format!( + "Error: Cannot restore {LP_PURL} to its upstream registry entry:" + )), + "the human failure line must print on stderr; stderr=\n{stderr}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_redirected_block()), + "a refused pin leaves the lock byte-identical" ); } -/// The whole-ledger replay REFUSAL loop: a leftover edit with an unsafe -/// path refuses its group — `group:` failure entries in the JSON, -/// the "Cannot unwind hosted redirect edits" stderr line in human mode, -/// exit 1, ledger intact. +/// A pre-v5 ledger's edits are NEVER replayed: a ledger whose only content +/// is an edit naming an unsafe path (the old replay refused such a group) +/// is simply retired — no lockfile pins a hosted patch, so rollback removes +/// the stale file and exits 0 (`legacyRedirectLedgerRemoved`), and the +/// edit's target is never touched. Human mode says so on stdout. #[test] -fn replay_refusal_reports_group_failures_in_both_modes() { - let evil_edit = || FileEdit { - path: "../evil.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())), +fn legacy_ledger_edits_are_never_replayed_in_both_modes() { + let edits = || { + json!([{ + "path": "../evil.lock", + "kind": "redirect_yarn_classic_entry", + "action": "rewritten", + "key": "left-pad@1.2.3", + "original": yarn_original_block(), + "new": yarn_redirected_block(), + }]) }; // ── --json ── let tmp = tempfile::tempdir().expect("tempdir"); - write_hosted_ledger(tmp.path(), vec![], vec![evil_edit()]); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let evil = tmp.path().join("evil.lock"); + std::fs::write(&evil, yarn_lock_content(&yarn_redirected_block())).unwrap(); + write_legacy_ledger(&project, edits()); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--json", "--offline", "--yes"]); + let (code, stdout, stderr) = run_hosted(&project, &["rollback", "--json", "--yes"], None); assert_eq!( - code, 1, - "a replay refusal must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + code, 0, + "a stale pre-v5 ledger is retired, not replayed; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); - let failed = v["hosted"]["failed"].as_array().expect("failed array"); - assert_eq!(failed.len(), 1, "stdout=\n{stdout}"); - assert_eq!( - failed[0]["purl"], "group:yarn", - "the refusal is reported per group; stdout=\n{stdout}" - ); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!(v["legacyRedirectLedgerRemoved"], true, "stdout=\n{stdout}"); assert!( - failed[0]["error"] - .as_str() - .is_some_and(|e| e.contains("unsafe path") && e.contains("../evil.lock")), - "the refusal must name the reason and the file; stdout=\n{stdout}" + !ledger_path(&project).exists(), + "the stale ledger is deleted" ); assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "a refused replay must leave the ledger byte-identical" + std::fs::read_to_string(&evil).unwrap(), + yarn_lock_content(&yarn_redirected_block()), + "a legacy ledger's edit is never replayed" ); // ── human ── let tmp = tempfile::tempdir().expect("tempdir"); - write_hosted_ledger(tmp.path(), vec![], vec![evil_edit()]); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes"]); - assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); + write_legacy_ledger(tmp.path(), edits()); + let (code, stdout, stderr) = run_hosted(tmp.path(), &["rollback", "--yes"], None); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("Cannot unwind hosted redirect edits (yarn)"), - "the human refusal line must print on stderr; stderr=\n{stderr}" + stdout.contains( + "Removed the pre-v5 hosted ledger .socket/vendor/redirect-state.json: no \ + lockfile pins a hosted patch." + ), + "the human retire line must print; stdout=\n{stdout}" ); + assert!(!ledger_path(tmp.path()).exists()); } -/// A records-EMPTY ledger with leftover edits (the degraded -/// record-fetch-failed shape): an unscoped wet run replays the edits — -/// the confirm clause for leftover edits composes on the way — restoring -/// the wired file and deleting the emptied ledger. +/// A pre-v5 ledger beside a live hosted pin: the pin is restored from the +/// registry (never from the ledger's recorded original), and the ledger is +/// retired once no pin remains. Offline, the ledger's recorded original is +/// NOT a fallback: the pin is refused, and the ledger stays while the pin +/// is still wired. #[test] -fn leftover_edits_only_ledger_replays_unscoped() { +fn legacy_ledger_beside_a_live_pin_is_never_the_revert_source() { + let legacy_edits = || { + json!([{ + "path": "yarn.lock", + "kind": "redirect_yarn_classic_entry", + "action": "rewritten", + "key": "left-pad@1.2.3", + // A recorded "original" the registry disagrees with: a replay + // would write it; the restore must not. + "original": yarn_block_for( + "left-pad", "1.2.3", + "https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.3.tgz#bbbb", + "sha512-LEDGERledger==", + ), + "new": yarn_redirected_block(), + }]) + }; + + // Offline: refused, nothing written, ledger kept. let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join("yarn.lock"), + write_single_npm_fixture(tmp.path()); + write_legacy_ledger(tmp.path(), legacy_edits()); + let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + let (code, stdout, stderr) = run_hosted( + tmp.path(), + &["rollback", "--json", "--offline", "--yes"], + None, + ); + assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v = parse_envelope(&stdout, &stderr); + assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); + assert_eq!( + v["hosted"]["failed"][0]["purl"], LP_PURL, + "stdout=\n{stdout}" + ); + assert_eq!(v["hosted"]["reverted"], json!([]), "stdout=\n{stdout}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_redirected_block()), - ) - .unwrap(); - write_hosted_ledger(tmp.path(), vec![], vec![yarn_classic_edit()]); - - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--json", "--offline", "--yes"]); + "the ledger's recorded original is never replayed" + ); assert_eq!( - code, 0, - "the leftover-edits replay succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" + std::fs::read(ledger_path(tmp.path())).unwrap(), + ledger_before, + "the ledger stays while a pin is still wired" ); + + // Online: restored from the registry, ledger retired. + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); + let (code, stdout, stderr) = run_hosted( + tmp.path(), + &["rollback", "--json", "--yes"], + Some(®istry), + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!( + v["hosted"]["reverted"], + json!([LP_PURL]), + "stdout=\n{stdout}" + ); assert!( v["hosted"]["editedFiles"].as_u64().unwrap_or(0) >= 1, - "the replay rewrote the lock; stdout=\n{stdout}" + "the restore rewrote the lock; stdout=\n{stdout}" ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_original_block()), - "the leftover edit must be replayed" + "the entry comes back from the registry, not the ledger" ); assert!( !ledger_path(tmp.path()).exists(), - "the emptied ledger must be deleted" - ); - assert!( - !tmp.path().join(".socket").exists(), - "the replayed-out project keeps no .socket/ residue" + "with no hosted pin left the pre-v5 ledger is retired" ); } -/// A corrupt redirect ledger skips ONLY the hosted leg: exit 1 with the -/// `redirect_state_unreadable` warning (JSON) / `Error -/// (redirect_state_unreadable):` notice (human), and the garbage file is -/// left in place for quarantine. +/// A corrupt pre-v5 ledger is inert: a project whose only state is that +/// garbage file retires it and exits 0 in both modes (it is never parsed, +/// so it can never fail the run). #[test] -fn corrupt_redirect_ledger_warns_and_fails_in_both_modes() { +fn corrupt_legacy_ledger_is_retired_in_both_modes() { let corrupt = || { let tmp = tempfile::tempdir().expect("tempdir"); let vendor_dir = tmp.path().join(".socket/vendor"); @@ -1771,103 +1843,77 @@ fn corrupt_redirect_ledger_warns_and_fails_in_both_modes() { let tmp = corrupt(); let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--json", "--offline", "--yes"]); assert_eq!( - code, 1, - "a corrupt redirect ledger must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + code, 0, + "a corrupt pre-v5 ledger never fails the run; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); - assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); + assert_eq!(v["status"], "success", "stdout=\n{stdout}"); + assert_eq!(v["legacyRedirectLedgerRemoved"], true, "stdout=\n{stdout}"); + assert!(!ledger_path(tmp.path()).exists()); + + // ── human dry run: previewed, kept ── + let tmp = corrupt(); + let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--dry-run"]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - warning_codes(&v).contains(&"redirect_state_unreadable".to_string()), - "the warning must be surfaced; stdout=\n{stdout}" - ); - assert_eq!( - v["hosted"]["reverted"], - json!([]), - "the hosted leg must be skipped; stdout=\n{stdout}" + stdout.contains("Would remove the pre-v5 hosted ledger"), + "stdout=\n{stdout}" ); assert_eq!( std::fs::read(ledger_path(tmp.path())).unwrap(), b"garbage not json", - "the corrupt ledger must be left in place for quarantine" - ); - - // ── human ── - let tmp = corrupt(); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes"]); - assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); - assert!( - stderr.contains("Error (redirect_state_unreadable):"), - "the error-class notice must print on stderr; stderr=\n{stderr}" + "a dry run deletes nothing" ); } /// `--ecosystems` narrows the hosted leg: a pypi-scoped run leaves the npm -/// record (and, being a scope, blocks the whole-ledger replay); an -/// npm-scoped run unwinds it. +/// pin; an npm-scoped run restores it. #[test] fn ecosystems_filter_narrows_hosted_scope() { let tmp = tempfile::tempdir().expect("tempdir"); write_single_npm_fixture(tmp.path()); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); - let (code, stdout, stderr) = run( + let (code, stdout, stderr) = run_hosted( tmp.path(), - &[ - "rollback", - "--json", - "--offline", - "--yes", - "--ecosystems", - "pypi", - ], + &["rollback", "--json", "--yes", "--ecosystems", "pypi"], + Some(®istry), ); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( v["hosted"]["reverted"], json!([]), - "a pypi-scoped run must not unwind the npm record; stdout=\n{stdout}" - ); - assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "the record must survive the eco-narrowed run" + "a pypi-scoped run must not restore the npm pin; stdout=\n{stdout}" ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_redirected_block()), - "the wiring must survive too" + "the wiring must survive the eco-narrowed run" ); - let (code, stdout, stderr) = run( + let (code, stdout, stderr) = run_hosted( tmp.path(), - &[ - "rollback", - "--json", - "--offline", - "--yes", - "--ecosystems", - "npm", - ], + &["rollback", "--json", "--yes", "--ecosystems", "npm"], + Some(®istry), ); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); let v = parse_envelope(&stdout, &stderr); assert_eq!( v["hosted"]["reverted"], json!([LP_PURL]), - "the npm-scoped run must unwind it; stdout=\n{stdout}" + "the npm-scoped run must restore it; stdout=\n{stdout}" ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_original_block()), - "the wiring must be unwound" + "the entry must be restored" ); - assert!(!ledger_path(tmp.path()).exists(), "ledger deleted"); assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } -/// A path-shaped target selects a HOSTED record through its installed -/// copy: `rollback node_modules/left-pad` unwinds the redirect. +/// A path-shaped target selects a HOSTED pin through its installed copy: +/// `rollback node_modules/left-pad` restores the upstream entry. #[test] fn path_glob_selects_hosted_record() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -1881,16 +1927,12 @@ fn path_glob_selects_hosted_record() { "1.2.3", b"installed bytes\n", ); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); - let (code, stdout, stderr) = run( + let (code, stdout, stderr) = run_hosted( tmp.path(), - &[ - "rollback", - "--json", - "--offline", - "--yes", - "node_modules/left-pad", - ], + &["rollback", "--json", "--yes", "node_modules/left-pad"], + Some(®istry), ); assert_eq!( code, 0, @@ -1900,117 +1942,165 @@ fn path_glob_selects_hosted_record() { assert_eq!( v["hosted"]["reverted"], json!([LP_PURL]), - "the path target must select the hosted record; stdout=\n{stdout}" + "the path target must select the hosted pin; stdout=\n{stdout}" ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), yarn_lock_content(&yarn_original_block()), - "the wiring must be unwound" + "the entry must be restored" ); - assert!(!ledger_path(tmp.path()).exists(), "ledger deleted"); assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } -/// `persist_redirect_state` FAILURE after the hosted leg mutated the -/// ledger in memory: the replay rewrote the wired file, but the emptied -/// ledger cannot be removed (read-only `.socket/vendor`) — the failure -/// lands in `hosted.failed` as the `ledger` entry and the run exits 1. +/// The restored lockfile cannot be written (a read-only project root; the +/// write is an atomic temp-file + rename beside it): the failure lands in +/// `hosted.failed` under the `files` key and the run exits 1, the lock +/// untouched. (Skipped where the sandbox ignores directory modes, e.g. as +/// root.) #[cfg(unix)] #[test] -fn hosted_persist_failure_lands_in_hosted_failed() { +fn hosted_restore_write_failure_lands_in_hosted_failed() { let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join("yarn.lock"), - yarn_lock_content(&yarn_redirected_block()), - ) - .unwrap(); - write_hosted_ledger(tmp.path(), vec![], vec![yarn_classic_edit()]); - - let vendor_dir = tmp.path().join(".socket/vendor"); - let guard = DirModeGuard::chmod(&vendor_dir, 0o555, 0o755); - if !readonly_dir_enforced(&vendor_dir) { + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + write_single_npm_fixture(&project); + // `.socket/` exists and stays writable so the apply lock is taken. + std::fs::create_dir(project.join(".socket")).unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); + + let guard = DirModeGuard::chmod(&project, 0o555, 0o755); + if !readonly_dir_enforced(&project) { return; } - - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--json", "--offline", "--yes"]); + let (code, stdout, stderr) = + run_hosted(&project, &["rollback", "--json", "--yes"], Some(®istry)); guard.restore(); assert_eq!( code, 1, - "a ledger persist failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" + "a lockfile write failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); let v = parse_envelope(&stdout, &stderr); assert_eq!(v["status"], "partial_failure", "stdout=\n{stdout}"); let failed = v["hosted"]["failed"].as_array().expect("failed array"); assert!( - failed.iter().any(|f| f["purl"] == "ledger" + failed.iter().any(|f| f["purl"] == "files" && f["error"] .as_str() - .is_some_and(|e| e.contains("failed to persist the hosted redirect ledger"))), - "the persist failure must be reported under the 'ledger' key; stdout=\n{stdout}" + .is_some_and(|e| e.contains("writing the restored lockfiles failed"))), + "the write failure must be reported under the 'files' key; stdout=\n{stdout}" ); - // The replay itself ran before the persist: the wired file is restored. assert_eq!( - std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), - yarn_lock_content(&yarn_original_block()), - "the replay's file writes land before the persist failure" + std::fs::read_to_string(project.join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_redirected_block()), + "the lock was never written" + ); +} + +/// Human twin of `hosted_restore_write_failure_lands_in_hosted_failed`: +/// the "Error: Writing the restored lockfiles failed" stderr line, exit 1. +#[cfg(unix)] +#[test] +fn hosted_restore_write_failure_prints_human_error_line() { + let tmp = tempfile::tempdir().expect("tempdir"); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + write_single_npm_fixture(&project); + std::fs::create_dir(project.join(".socket")).unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); + + let guard = DirModeGuard::chmod(&project, 0o555, 0o755); + if !readonly_dir_enforced(&project) { + return; + } + let (code, stdout, stderr) = run_hosted(&project, &["rollback", "--yes"], Some(®istry)); + guard.restore(); + + assert_eq!( + code, 1, + "a lockfile write failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - ledger_path(tmp.path()).exists(), - "the un-removable ledger file must still be on disk" + stderr.contains("Error: Writing the restored lockfiles failed"), + "the human write-failure line must print on stderr; stderr=\n{stderr}" ); } -/// An unscoped Bun rollback stages its package edits together through -/// whole-ledger replay. Its human confirmation prints from that deferred -/// path after the original lockfile is restored. -#[test] -fn bun_deferred_purl_unwinds_via_replay() { - let bun_original = - r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; - // The engine's real redirected shape: registry 4-tuple → URL 3-tuple - // `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). - let bun_redirected = format!( - r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"# - ); - let bun_lock = |block: &str| { - format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{block}\n }}\n}}\n") - }; +fn bun_original_line() -> String { + format!(r#" "left-pad": ["left-pad@1.2.3", "", {{}}, "{UPSTREAM_INTEGRITY}"],"#) +} +/// The engine's real redirected shape: registry 4-tuple → URL 3-tuple +/// `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). +fn bun_redirected_line() -> String { + format!(r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"#) +} + +fn bun_lock(line: &str) -> String { + format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{line}\n }}\n}}\n") +} + +/// A hosted bun.lock pin restores to the registry 4-tuple +/// `["name@version", "", {deps}, ""]`, with the human +/// restore line. +#[test] +fn bun_lock_pin_restores_to_the_registry_tuple() { let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write(tmp.path().join("bun.lock"), bun_lock(&bun_redirected)).unwrap(); - write_hosted_ledger( - tmp.path(), - vec![(LP_PURL, hosted_record(LP_UUID))], - vec![FileEdit { - path: "bun.lock".to_string(), - kind: "redirect_bun_lock_package".to_string(), - action: "rewritten".to_string(), - key: Some("left-pad".to_string()), - original: Some(Value::String(bun_original.to_string())), - new: Some(Value::String(bun_redirected.clone())), - }], - ); + std::fs::write( + tmp.path().join("bun.lock"), + bun_lock(&bun_redirected_line()), + ) + .unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes"]); + let (code, stdout, stderr) = run_hosted(tmp.path(), &["rollback", "--yes"], Some(®istry)); assert_eq!( code, 0, - "the bun-deferred unwind succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" + "the bun.lock restore succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains(&format!("Unwound hosted redirect for {LP_PURL}")), - "the deferred purl's wet unwind line must print; stdout=\n{stdout}" + stdout.contains(&format!( + "Restored {LP_PURL} to its upstream registry entry" + )), + "the wet restore line must print; stdout=\n{stdout}" ); assert_eq!( std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), - bun_lock(bun_original), - "the bun.lock fragment must be replayed back to the original" + bun_lock(&bun_original_line()), + "the bun.lock entry must be the registry tuple again" + ); + assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); +} + +/// Dry-run twin of `bun_lock_pin_restores_to_the_registry_tuple`: "Would +/// restore …", bun.lock byte-identical afterwards. +#[test] +fn bun_lock_pin_dry_run_previews() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("bun.lock"), + bun_lock(&bun_redirected_line()), + ) + .unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.2.3")]); + + let (code, stdout, stderr) = + run_hosted(tmp.path(), &["rollback", "--dry-run"], Some(®istry)); + assert_eq!( + code, 0, + "the bun.lock dry run succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - !ledger_path(tmp.path()).exists(), - "record and edit both unwound: the ledger must be deleted" + stdout.contains(&format!( + "Would restore {LP_PURL} to its upstream registry entry" + )), + "the dry-run preview line must print; stdout=\n{stdout}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), + bun_lock(&bun_redirected_line()), + "dry run must not touch the wired bun.lock" ); - assert!(!tmp.path().join(".socket").exists(), "no .socket/ residue"); } // ═══════════════ 4. GC-failure warnings (unix permissions) ═════════════════ @@ -2158,8 +2248,8 @@ fn manifest_write_failure_warns_and_exits_one() { // And the entry's blobs must survive for a retry (the failed-write // fallback restores the in-memory reference before the GC). assert!( - fx.socket.join("blobs").join(&fx.before_hash).exists() - && fx.socket.join("blobs").join(&fx.after_hash).exists(), + fx.socket.join("blobs").join(git_sha256(fx.before)).exists() + && fx.socket.join("blobs").join(git_sha256(fx.after)).exists(), "the failed-cleanup entry's blobs must be pinned" ); } @@ -2330,101 +2420,6 @@ fn vendored_dry_run_json_previews_without_human_print() { assert!(fx.tgz_path().is_file(), "dry run must keep the artifact"); } -/// Human twin of `per_purl_revert_failure_lands_in_hosted_failed`: the -/// failed per-purl npm revert prints the "Failed to unwind hosted -/// redirect for {purl}: {e}" stderr line (errors print even without -/// `--json`), exit 1, ledger untouched. -#[test] -fn per_purl_revert_failure_prints_human_stderr_line() { - let tmp = tempfile::tempdir().expect("tempdir"); - // yarn.lock is a DIRECTORY so the scoped npm revert fails on read; - // the second (gem) record keeps the scoped run replay-ineligible. - std::fs::create_dir(tmp.path().join("yarn.lock")).unwrap(); - std::fs::write( - tmp.path().join("Gemfile.lock"), - gemfile_lock_content(GEM_PATCH_REMOTE), - ) - .unwrap(); - write_hosted_ledger( - tmp.path(), - vec![ - (LP_PURL, hosted_record(LP_UUID)), - (GEM_PURL, hosted_record(GEM_UUID)), - ], - vec![yarn_classic_edit(), gem_source_edit()], - ); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); - - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes", LP_PURL]); - assert_eq!( - code, 1, - "a failed per-purl revert must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" - ); - assert!( - stderr.contains(&format!("Failed to unwind hosted redirect for {LP_PURL}:")), - "the human failure line must print on stderr; stderr=\n{stderr}" - ); - assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "a failed revert must leave the ledger byte-identical" - ); -} - -/// Dry-run twin of `bun_deferred_purl_unwinds_via_replay`: the deferred -/// preview routes through the replay's dropped-records probe and prints -/// "Would unwind hosted redirect for {purl}" — with bun.lock and the -/// ledger byte-identical afterwards. -#[test] -fn bun_deferred_purl_dry_run_previews_via_replay() { - let bun_original = - r#" "left-pad": ["left-pad@1.2.3", "", {}, "sha512-UPSTREAMupstream=="],"#; - // The engine's real redirected shape: registry 4-tuple → URL 3-tuple - // `["name@", {deps}, "sha512-…"]` (the registry slot is dropped). - let bun_redirected = format!( - r#" "left-pad": ["left-pad@{LP_HOSTED_URL}", {{}}, "sha512-PATCHEDpatched=="],"# - ); - let bun_lock = |block: &str| { - format!("{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n{block}\n }}\n}}\n") - }; - - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write(tmp.path().join("bun.lock"), bun_lock(&bun_redirected)).unwrap(); - write_hosted_ledger( - tmp.path(), - vec![(LP_PURL, hosted_record(LP_UUID))], - vec![FileEdit { - path: "bun.lock".to_string(), - kind: "redirect_bun_lock_package".to_string(), - action: "rewritten".to_string(), - key: Some("left-pad".to_string()), - original: Some(Value::String(bun_original.to_string())), - new: Some(Value::String(bun_redirected.clone())), - }], - ); - let ledger_before = std::fs::read(ledger_path(tmp.path())).unwrap(); - - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--dry-run"]); - assert_eq!( - code, 0, - "the bun-deferred dry run succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" - ); - assert!( - stdout.contains(&format!("Would unwind hosted redirect for {LP_PURL}")), - "the deferred purl's dry-run preview line must print; stdout=\n{stdout}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(), - bun_lock(&bun_redirected), - "dry run must not touch the wired bun.lock" - ); - assert_eq!( - std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before, - "dry run must not touch the ledger" - ); -} - /// The manifest vanishing while another process holds the apply lock: the /// pre-lock existence probe saw the file, but the under-lock read finds /// it gone — rollback fails closed with the "Invalid manifest" error @@ -2499,45 +2494,6 @@ fn manifest_deleted_under_held_lock_fails_with_invalid_manifest() { panic!("the probe-then-delete interleaving never landed in 8 attempts"); } -/// Human twin of `hosted_persist_failure_lands_in_hosted_failed`: the -/// wet-run ledger persist failure prints the "Error: Failed to persist -/// the hosted redirect ledger" stderr line, exit 1 — after the replay -/// already restored the wired file. -#[cfg(unix)] -#[test] -fn hosted_persist_failure_prints_human_error_line() { - let tmp = tempfile::tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join("yarn.lock"), - yarn_lock_content(&yarn_redirected_block()), - ) - .unwrap(); - write_hosted_ledger(tmp.path(), vec![], vec![yarn_classic_edit()]); - - let vendor_dir = tmp.path().join(".socket/vendor"); - let guard = DirModeGuard::chmod(&vendor_dir, 0o555, 0o755); - if !readonly_dir_enforced(&vendor_dir) { - return; - } - - let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "--yes"]); - guard.restore(); - - assert_eq!( - code, 1, - "a ledger persist failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" - ); - assert!( - stderr.contains("Error: Failed to persist the hosted redirect ledger"), - "the human persist-failure line must print on stderr; stderr=\n{stderr}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), - yarn_lock_content(&yarn_original_block()), - "the replay's file writes land before the persist failure" - ); -} - /// Human twin of `manifest_write_failure_warns_and_exits_one`: the failed /// manifest update prints the "Error: Failed to update the manifest:" /// stderr line, exit 1, manifest byte-identical — after the file restore @@ -3347,7 +3303,6 @@ fn empty_manifest_announces_no_patches() { const VLT_UUID: &str = "88888888-8888-4888-8888-888888888888"; const VLT_ID: &str = "~npm~left-pad@1.3.0"; -const VLT_REGISTRY_SHA: &str = "sha512-REGISTRY=="; const VLT_PATCHED_SHA: &str = "sha512-PATCHED=="; fn vlt_entry(sha: &str, url: &str) -> String { @@ -3361,12 +3316,10 @@ fn vlt_lock_text(entry: &str) -> String { } /// A redirected vlt project whose store holds the patched copy `vlt -/// install` extracted, with the hidden lock recording the hosted pin. -fn write_vlt_hosted_fixture(root: &Path) -> (String, PathBuf) { - let registry = vlt_entry( - VLT_REGISTRY_SHA, - "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - ); +/// install` extracted, with the hidden lock recording the hosted pin (on +/// Socket's own patch host, so discovery needs no `--patch-server-url`). +/// Returns the store path. +fn write_vlt_hosted_fixture(root: &Path) -> PathBuf { let hosted = vlt_entry( VLT_PATCHED_SHA, &format!("https://patch.socket.dev/patch/npm/t/{VLT_UUID}/left-pad-1.3.0.tgz"), @@ -3383,42 +3336,29 @@ fn write_vlt_hosted_fixture(root: &Path) -> (String, PathBuf) { vlt_lock_text(&hosted), ) .unwrap(); - let mut record = hosted_record(VLT_UUID); - record.files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: git_sha256(b"pristine"), - after_hash: git_sha256(b"patched"), - }, - ); - write_hosted_ledger( - root, - vec![("pkg:npm/left-pad@1.3.0", record)], - vec![FileEdit { - path: "vlt-lock.json".to_string(), - kind: "redirect_vlt_lock_node".to_string(), - action: "rewritten".to_string(), - key: Some("left-pad@1.3.0".to_string()), - original: Some(json!(registry)), - new: Some(json!(hosted)), - }], - ); - (vlt_lock_text(®istry), store) + store } -/// A dry-run rollback previews the vlt unwind but deletes nothing and +/// A dry-run rollback previews the vlt restore but deletes nothing and /// says nothing about installed copies; the wet human run restores the -/// registry pin, invalidates the patched store entry and prints the -/// advisory as a warning line. +/// registry pin (integrity from the registry's version document), +/// invalidates the patched store entry and prints the advisory as a +/// warning line. #[test] fn vlt_hosted_rollback_dry_run_keeps_the_store_and_wet_human_run_heals() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let (registry_lock, store) = write_vlt_hosted_fixture(root); + let store = write_vlt_hosted_fixture(root); let hosted_lock = std::fs::read_to_string(root.join("vlt-lock.json")).unwrap(); + let registry = NpmRegistry::start(&[("left-pad", "1.3.0")]); - let (code, stdout, stderr) = run(root, &["rollback", "--dry-run", "--yes", "--offline"]); + let (code, stdout, stderr) = + run_hosted(root, &["rollback", "--dry-run", "--yes"], Some(®istry)); assert_eq!(code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("Would restore pkg:npm/left-pad@1.3.0 to its upstream registry entry"), + "{stdout}" + ); assert!( !stderr.contains("redirect_vlt_reinstall_required"), "{stderr}" @@ -3429,11 +3369,14 @@ fn vlt_hosted_rollback_dry_run_keeps_the_store_and_wet_human_run_heals() { ); assert!(store.join("index.js").exists()); - let (code, stdout, stderr) = run(root, &["rollback", "--yes", "--offline"]); + let (code, stdout, stderr) = run_hosted(root, &["rollback", "--yes"], Some(®istry)); assert_eq!(code, 0, "{stdout}\n{stderr}"); - assert_eq!( - std::fs::read_to_string(root.join("vlt-lock.json")).unwrap(), - registry_lock + let restored = std::fs::read_to_string(root.join("vlt-lock.json")).unwrap(); + assert!( + restored.contains(&format!( + "\"{VLT_ID}\": [0,\"left-pad\",\"{UPSTREAM_INTEGRITY}\"" + )) && !restored.contains("patch.socket.dev"), + "the node must carry the registry integrity again, no hosted URL:\n{restored}" ); assert!( stderr.contains( diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 41433f640..cea44d55e 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -791,16 +791,13 @@ async fn hosted_lock_held_refuses_before_any_write() { ); } -/// A WET zero-grant run (every reference skipped) holds no apply lock, so -/// it must not perform the one write the strict ledger load can make: the -/// `redirect-state.json` → `redirect-state.json.corrupt` quarantine. Under a -/// held lock AND with no holder, a malformed ledger is reported as the hard -/// error it is (exit 1, the repair-or-move-aside remedy) and left exactly -/// where it was — no `.corrupt` file, no `.socket/vendor/` mutation -/// lock-free. A granted wet run (the lock holder) still quarantines -/// (pinned in in_process_redirect.rs). +/// A zero-grant wet run over a project holding a MALFORMED pre-v5 redirect +/// ledger: v5 scan never reads that ledger, so it is neither an error nor +/// quarantined — the run succeeds (nothing granted, nothing written) under a +/// lock held by another process and with no holder alike, and the torn file +/// stays byte-identical in place. The human arm never mentions it either. #[tokio::test] -async fn zero_grant_wet_run_reports_a_malformed_ledger_without_moving_it() { +async fn zero_grant_wet_run_ignores_a_malformed_pre_v5_ledger() { use std::time::Duration; let no_grant = MockServer::start().await; @@ -816,21 +813,12 @@ async fn zero_grant_wet_run_reports_a_malformed_ledger_without_moving_it() { std::fs::write(&ledger, TORN).unwrap(); let lock_before = std::fs::read(root.join("package-lock.json")).unwrap(); - let assert_left_in_place = |code: i32, doc: &Value, label: &str| { - assert_eq!( - code, 1, - "{label}: a malformed ledger is a hard error: {doc:#}" - ); - assert_eq!(doc["status"], "error", "{label}: {doc:#}"); - let error = doc["error"].as_str().unwrap_or_default(); + let assert_ignored = |code: i32, doc: &Value, label: &str| { + assert_eq!(code, 0, "{label}: a pre-v5 ledger is never an error: {doc:#}"); + assert_eq!(doc["status"], "success", "{label}: {doc:#}"); assert!( - error.contains("redirect-state.json") && error.contains("is malformed"), - "{label}: the error names the ledger: {error}" - ); - assert!( - !error.contains(".corrupt") && error.contains("move it aside"), - "{label}: no lock, so nothing was moved — the remedy is the repair-or-move-aside \ - variant: {error}" + !doc.to_string().contains("redirect-state.json"), + "{label}: the envelope never mentions the legacy ledger: {doc:#}" ); assert_eq!( std::fs::read(&ledger).unwrap(), @@ -841,7 +829,7 @@ async fn zero_grant_wet_run_reports_a_malformed_ledger_without_moving_it() { !root .join(".socket/vendor/redirect-state.json.corrupt") .exists(), - "{label}: a zero-grant run never quarantines" + "{label}: never quarantined" ); assert_eq!( std::fs::read(root.join("package-lock.json")).unwrap(), @@ -850,8 +838,8 @@ async fn zero_grant_wet_run_reports_a_malformed_ledger_without_moving_it() { ); }; - // Under a lock held by another process: the run does not contend (it - // would write nothing) and must not rename under the holder either. + // Under a lock held by another process: a zero-grant run writes nothing, + // so it never contends. let holder = socket_patch_core::patch::apply_lock::acquire(&root.join(".socket"), Duration::ZERO) .unwrap(); @@ -860,25 +848,21 @@ async fn zero_grant_wet_run_reports_a_malformed_ledger_without_moving_it() { doc["errorCode"], "lock_held", "a zero-grant run never contends: {doc:#}" ); - assert_left_in_place(code, &doc, "held lock"); + assert_ignored(code, &doc, "held lock"); drop(holder); - // No holder: same outcome — the gate is "this run holds the lock", not - // "nobody else does". let (code, doc) = scan_hosted_json(root, &no_grant.uri(), &[], &[]); - assert_left_in_place(code, &doc, "no holder"); + assert_ignored(code, &doc, "no holder"); assert!( !root.join(".socket/apply.lock").exists(), "no lock was taken, none is left behind" ); - // Human arm: the same hard error on stderr, once. let (code, _stdout, stderr) = scan_hosted(root, &no_grant.uri(), &[], &[]); - assert_eq!(code, 1, "stderr=\n{stderr}"); - assert_eq!( - stderr.matches("is malformed").count(), - 1, - "reported exactly once; stderr=\n{stderr}" + assert_eq!(code, 0, "stderr=\n{stderr}"); + assert!( + !stderr.contains("malformed") && !stderr.contains("redirect ledger"), + "stderr=\n{stderr}" ); assert_eq!(std::fs::read(&ledger).unwrap(), TORN); } @@ -945,20 +929,22 @@ async fn hosted_lock_io_when_a_file_squats_on_socket_dir() { /// The footprint of a SUCCESSFUL wet hosted run (G6 / lock lifecycle): after /// `scan --mode hosted --yes` (human) and `scan --mode hosted --json`, each -/// on a fresh project, `.socket/` holds exactly `vendor/` (the redirect -/// ledger's home) — no `apply.lock` outlives the run, nothing else is -/// created. The human run also never prints the `Non-interactive mode +/// on a fresh project, `.socket/` holds NOTHING (v5 hosted mode writes no +/// ledger; the lockfile is the whole record) — no `apply.lock` outlives the +/// run, and nothing else is created. The human run also never prints the `Non-interactive mode /// detected` auto-accept line: scan never prompts. #[tokio::test] -async fn successful_wet_hosted_run_leaves_only_vendor_under_socket() { +async fn successful_wet_hosted_run_leaves_nothing_under_socket() { let server = MockServer::start().await; mock_discovery(&server, PURL, UUID).await; mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; mock_view(&server, UUID, PURL).await; let socket_listing = |root: &Path| -> Vec { - let mut names: Vec = std::fs::read_dir(root.join(".socket")) - .unwrap() + let Ok(dir) = std::fs::read_dir(root.join(".socket")) else { + return Vec::new(); + }; + let mut names: Vec = dir .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) .collect(); names.sort(); @@ -979,15 +965,15 @@ async fn successful_wet_hosted_run_leaves_only_vendor_under_socket() { !stderr.contains("Non-interactive mode detected"), "--yes skips the prompt outright; stderr=\n{stderr}" ); - assert!(root.join(".socket/vendor/redirect-state.json").is_file()); + assert!(!root.join(".socket/vendor/redirect-state.json").exists()); assert!( !root.join(".socket/apply.lock").exists(), "apply.lock never outlives the run" ); assert_eq!( socket_listing(root), - vec!["vendor".to_string()], - "a hosted run writes ONLY .socket/vendor/**" + Vec::::new(), + "a v5 hosted run writes nothing under .socket/" ); // `--json` arm on a fresh project (a second run over the redirected @@ -1002,17 +988,15 @@ async fn successful_wet_hosted_run_leaves_only_vendor_under_socket() { !root.join(".socket/apply.lock").exists(), "apply.lock never outlives the run" ); - assert_eq!(socket_listing(root), vec!["vendor".to_string()]); + assert_eq!(socket_listing(root), Vec::::new()); } /// Human `scan --mode hosted` on a project whose discovery is EMPTY returns -/// before the engine (exit 0, `No patches available…`) — the only place a -/// malformed redirect ledger would have been reported. It is reported there -/// as an advisory instead, exactly once, and the file is never moved (a -/// read-only consult; quarantine is the engine's job under the lock). -/// `--silent` mutes it like every advisory. +/// before the engine (exit 0, `No patches available…`). A malformed pre-v5 +/// redirect ledger is ignored there like everywhere else in v5 scan: no +/// advisory (with or without `--silent`), never moved, no lock taken. #[tokio::test] -async fn hosted_human_empty_discovery_still_reports_a_malformed_ledger() { +async fn hosted_human_empty_discovery_ignores_a_malformed_pre_v5_ledger() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) @@ -1031,33 +1015,25 @@ async fn hosted_human_empty_discovery_still_reports_a_malformed_ledger() { const TORN: &[u8] = b"{ torn"; std::fs::write(&ledger, TORN).unwrap(); - let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); - assert_eq!(code, 0, "an empty discovery exits 0; stderr=\n{stderr}"); - assert!( - stdout.contains("No patches available for installed packages."), - "{stdout}" - ); - assert_eq!( - stderr.matches("is malformed").count(), - 1, - "the corruption is reported exactly once; stderr=\n{stderr}" - ); - assert!( - stderr.contains("Warning: the redirect ledger"), - "advisory form; stderr=\n{stderr}" - ); - assert_eq!(std::fs::read(&ledger).unwrap(), TORN, "left in place"); - assert!(!root - .join(".socket/vendor/redirect-state.json.corrupt") - .exists()); - assert!(!root.join(".socket/apply.lock").exists()); - - let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &["--silent"], &[]); - assert_eq!(code, 0, "stderr=\n{stderr}"); - assert!( - !stderr.contains("is malformed"), - "--silent mutes the advisory; stderr=\n{stderr}" - ); + for extra in [&[][..], &["--silent"][..]] { + let (code, stdout, stderr) = scan_hosted(root, &server.uri(), extra, &[]); + assert_eq!(code, 0, "{extra:?}: an empty discovery exits 0; stderr=\n{stderr}"); + if extra.is_empty() { + assert!( + stdout.contains("No patches available for installed packages."), + "{stdout}" + ); + } + assert!( + !stderr.contains("malformed") && !stderr.contains("redirect ledger"), + "{extra:?}: a pre-v5 ledger is never read; stderr=\n{stderr}" + ); + assert_eq!(std::fs::read(&ledger).unwrap(), TORN, "left in place"); + assert!(!root + .join(".socket/vendor/redirect-state.json.corrupt") + .exists()); + assert!(!root.join(".socket/apply.lock").exists()); + } } /// A free-tier org whose every discovered hosted offer is paid-tier: the @@ -1341,10 +1317,18 @@ async fn ledgerless_cargo_wiring_refuses(manifest_wiring: bool) { /// Dry-run and apply use the same native binary rewrite, with no Bun /// executable or installed dependencies. A fresh clone works immediately. +/// No run writes a redirect ledger (v5), and `rollback` cannot restore a +/// binary `bun.lockb` pin to its upstream entry: it refuses the pin +/// (`partial_failure`, exit 1) naming the `git checkout` remedy and leaves +/// the lock byte-identical. #[tokio::test] async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { let purl = "pkg:npm/minimist@1.2.2"; - let url = "https://patch.test/minimist-1.2.2.tgz"; + // A standard hosted URL shape (grant token, then the patch uuid), so + // lockfile discovery recognizes the pin under `--patch-server-url`. + let url = &format!( + "https://patch.test/patch/npm/minimist/1.2.2/22222222-2222-4222-8222-222222222222/{UUID}/minimist-1.2.2.tgz" + ); let sri = format!("sha512-{}", "A".repeat(86) + "=="); let server = MockServer::start().await; mock_discovery(&server, purl, UUID).await; @@ -1395,15 +1379,10 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { .any(|bytes| bytes == url.as_bytes())); assert!(!tmp.path().join("bun.lock").exists()); assert!(!tmp.path().join("node_modules").exists()); - let ledger: Value = serde_json::from_slice( - &std::fs::read(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert!(ledger["edits"] - .as_array() - .unwrap() - .iter() - .any(|edit| edit["kind"] == "redirect_bun_lockb_package")); + assert!(!tmp + .path() + .join(".socket/vendor/redirect-state.json") + .exists()); let (code, rerun) = scan_hosted_json(tmp.path(), &server.uri(), &[], &env); assert_eq!(code, 0, "{rerun:#}"); @@ -1418,17 +1397,30 @@ async fn native_bun_lockb_hosting_dry_run_rerun_and_rollback_without_bun() { "rollback", "--json", "--yes", - "--offline", + "--patch-server-url", + "https://patch.test", "--cwd", tmp.path().to_str().unwrap(), ], &env, ); - assert_eq!(code, 0, "{stdout}\n{stderr}"); - let entries = socket_patch_core::vendor::lock_inventory::inventory_project(tmp.path()).await; + assert_eq!(code, 1, "a binary bun.lockb pin is refused: {stdout}\n{stderr}"); + let doc: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{e}: {stdout}")); + assert_eq!(doc["status"], "partial_failure", "{doc:#}"); + let failed = doc["hosted"]["failed"].as_array().unwrap_or_else(|| panic!("{doc:#}")); + assert_eq!(failed.len(), 1, "{doc:#}"); + assert_eq!(failed[0]["purl"], purl, "{doc:#}"); + let error = failed[0]["error"].as_str().unwrap_or_default(); assert!( - entries.iter().any(|entry| entry.purl == purl), - "{entries:?}" + error.starts_with(&format!("cannot restore {purl} to its upstream registry entry: ")) + && error.contains("bun.lockb") + && error.contains("git checkout"), + "{error}" + ); + assert_eq!( + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + patched, + "nothing is written for a refused pin" ); assert!(!tmp.path().join("bun.lock").exists()); assert!(!tmp @@ -1826,19 +1818,17 @@ async fn unreadable_pnpm_workspace_gets_warning_only_guidance_in_a_live_run() { user_bytes, "the unreadable workspace file must be left byte-identical" ); - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); assert!( - !ledger.contains("redirect_pnpm_workspace_trust"), - "no workspace-trust edit may be recorded when the file was unreadable: {ledger}" + !tmp.path().join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger" ); } // ──────────── redirect_supersedes_vendored (+ human) ──────────── /// The hosted-direction takeover warning: a LIVE lock routing package X to -/// its hosted artifact while BOTH ledgers still claim X (redirect records + -/// vendored state) must fire `redirect_supersedes_vendored` naming X — the +/// its hosted artifact (a lockfile hosted pin — v5 hosted state) while the +/// vendored ledger still claims X must fire `redirect_supersedes_vendored` naming X — the /// only signal that X's vendored ledger entry and committed tarball are now /// orphaned. A DIFFERENT package Y is granted this run so the takeover /// pre-revert never consumes X's vendored entry. The human re-run prints @@ -1900,31 +1890,9 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { ), ) .unwrap(); - // Redirect ledger: records X (the record uuid the live-lock proof keys on). - let socket_vendor = root.join(".socket/vendor"); - std::fs::create_dir_all(&socket_vendor).unwrap(); - let redirect_ledger = json!({ - "version": 1, - "mode": "hosted", - "records": { - XPURL: { - "uuid": XUUID, - "exportedAt": "2026-01-01T00:00:00Z", - "files": { - "package/index.js": { "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) } - }, - "vulnerabilities": {}, - "description": "x", - "license": "MIT", - "tier": "free" - } - } - }); - std::fs::write( - socket_vendor.join("redirect-state.json"), - serde_json::to_vec_pretty(&redirect_ledger).unwrap(), - ) - .unwrap(); + // No redirect ledger (v5): X's hosted pin in the lock is the whole + // hosted state. The mock host is recognized as a patch server only via + // `--patch-server-url` below. // Vendored ledger ALSO claims X (stale — the lock routes X hosted). write_vendor_state( root, @@ -1933,7 +1901,8 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { "package-lock", ); - let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); + let psu = ["--patch-server-url", "http://patch.test"]; + let (code, doc) = scan_hosted_json(root, &server.uri(), &psu, &[]); assert_eq!( code, 0, "the overlap warning never flips the exit code: {doc:#}" @@ -1959,7 +1928,7 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { ); // Human re-run (idempotent): the takeover-warning loop prints the detail. - let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); + let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &psu, &[]); assert_eq!(code, 0, "human overlap run exits 0; stderr=\n{stderr}"); assert!( stderr.contains( @@ -2067,7 +2036,7 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { "the VEX summary must name the count and the path; stderr=\n{stderr}" ); assert!( - stderr.contains("attested from the ledger"), + stderr.contains("attested from their patch records"), "the no-verify caveat is load-bearing; stderr=\n{stderr}" ); let doc: Value = @@ -2287,10 +2256,10 @@ async fn human_reference_failure_prints_an_error_line_and_exits_1() { ); } -/// A malformed redirect ledger aborts with an `Error: The redirect ledger -/// ...` line. +/// A malformed pre-v5 redirect ledger no longer aborts anything: the human +/// dry run proceeds (exit 0, the preview summary) with no `Error:` line. #[tokio::test] -async fn human_malformed_ledger_prints_an_error_prefix() { +async fn human_malformed_pre_v5_ledger_does_not_abort_the_run() { let server = MockServer::start().await; mock_discovery(&server, PURL, UUID).await; mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; @@ -2304,14 +2273,20 @@ async fn human_malformed_ledger_prints_an_error_prefix() { ) .unwrap(); - let (code, _stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); - assert_eq!(code, 1, "stderr=\n{stderr}"); + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr - .lines() - .any(|l| l.starts_with("Error: The redirect ledger ") && l.contains("malformed")), + stdout.contains("Would redirect 1 package"), + "stdout=\n{stdout}" + ); + assert!( + !stderr.lines().any(|l| l.starts_with("Error:")) && !stderr.contains("malformed"), "stderr=\n{stderr}" ); + assert_eq!( + std::fs::read(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + b"{bad" + ); } /// Stdout below the discovery report: human hosted `scan` prints the @@ -2364,8 +2339,7 @@ async fn human_rerun_says_already_redirected_and_first_run_prints_next_steps() { assert_eq!( engine_stdout(&stdout), "Redirected 1 package; rewrote 2 files.\n\ - Commit .socket/vendor/redirect-state.json, .npmrc, and package-lock.json to keep \ - the redirect.\n\ + Commit .npmrc and package-lock.json to keep the redirect.\n\ Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages pick \ up the patched artifacts, then run `socket-patch vex` to verify them.\n", "stderr=\n{stderr}" diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index e34a34b12..722197318 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1062,29 +1062,7 @@ async fn scan_human_apply_over_live_hosted_wiring_warns_retained() { ) .unwrap(); - // The redirect ledger recording that hosted redirect. - use socket_patch_core::manifest::schema::PatchRecord; - use socket_patch_core::patch::redirect::RedirectState; - let mut state = RedirectState::new(); - state.records.insert( - purl.to_string(), - PatchRecord { - uuid: UUID.to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: std::collections::HashMap::new(), - vulnerabilities: std::collections::HashMap::new(), - description: String::new(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - let vendor_dir = tmp.path().join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - std::fs::write( - vendor_dir.join("redirect-state.json"), - serde_json::to_string_pretty(&state).unwrap(), - ) - .unwrap(); + // v5: the lock pin above is the whole hosted state (no ledger). let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); @@ -1450,31 +1428,38 @@ fn reference_posts(reqs: &[wiremock::Request]) -> usize { .count() } -/// Seed a redirect ledger recording `uuid` for `purl` (hosted mode's only -/// patch store), so update detection has an "old" side to compare. Written -/// through the real ledger type so the hosted engine's strict loader -/// accepts it. -fn seed_redirect_ledger(root: &Path, purl: &str, uuid: &str) { - use socket_patch_core::manifest::schema::PatchRecord; - use socket_patch_core::patch::redirect::RedirectState; - let mut state = RedirectState::new(); - state.records.insert( - purl.to_string(), - PatchRecord { - uuid: uuid.to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: std::collections::HashMap::new(), - vulnerabilities: std::collections::HashMap::new(), - description: "seed".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - let vendor_dir = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); +/// Seed a hosted pin for the npm `purl` in `package-lock.json` — the +/// lockfile resolving it to the Socket patch server under `uuid`. v5 hosted +/// mode keeps its state only in lockfile pins, so this is update +/// detection's "old" side. +fn seed_hosted_pin(root: &Path, purl: &str, uuid: &str) { + let (name, version) = purl + .strip_prefix("pkg:npm/") + .and_then(|rest| rest.rsplit_once('@')) + .expect("an npm purl"); + let lock = serde_json::json!({ + "name": "covgap-scan-root", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "covgap-scan-root", + "version": "0.0.0", + "dependencies": { name: version } + }, + format!("node_modules/{name}"): { + "version": version, + "resolved": format!( + "https://patch.socket.dev/patch/npm/{name}/{version}/tok/{uuid}/{name}-{version}.tgz" + ), + "integrity": "sha512-orig==", + } + } + }); std::fs::write( - vendor_dir.join("redirect-state.json"), - serde_json::to_string_pretty(&state).unwrap(), + root.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap(), ) .unwrap(); } @@ -1519,9 +1504,9 @@ async fn scan_hosted_human_prints_table_updates_and_redirects() { let tmp = tempfile::tempdir().unwrap(); write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); - // The ledger records an OLDER patch: the shared update detection must + // The lock pins an OLDER hosted patch: the shared update detection must // flag the newer offer in hosted mode too. - seed_redirect_ledger(tmp.path(), purl, OLD_UUID); + seed_hosted_pin(tmp.path(), purl, OLD_UUID); // v5: a bare scan is hosted. let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); @@ -2449,18 +2434,17 @@ async fn scan_prune_keeps_a_wired_vlt_uuid_and_sweeps_an_unwired_one() { ); } -/// The degraded-ledger overlap through the CLI: a redirect ledger holding -/// only a vlt node edit (no records) keyed at a peer variant of the -/// vendored package's DepID is superseded by the vendored wiring (warned -/// and reconciled), at the `~` boundary only. +/// A pre-v5 redirect ledger holding only a vlt node edit keyed at a peer +/// variant of the vendored package's DepID is IGNORED by a vendored scan: +/// v5 has no `vendor_supersedes_redirect` reconciliation (once the lock +/// routes a package to `.socket/vendor/`, no hosted state is left), so no +/// warning fires at either side of the `~` boundary and the legacy file is +/// left byte-identical. #[tokio::test] -async fn scan_vendored_warns_on_a_degraded_vlt_edit_at_the_tilde_boundary() { +async fn scan_vendored_ignores_a_degraded_pre_v5_vlt_ledger_edit() { use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; use vlt_hosted_common as hosted; - for (key, overlaps) in [ - ("left-pad@1.3.0~peer.2", true), - ("left-pad@1.3.00~peer.2", false), - ] { + for key in ["left-pad@1.3.0~peer.2", "left-pad@1.3.00~peer.2"] { let server = MockServer::start().await; hosted::mock_all(&server).await; let tmp = tempfile::tempdir().unwrap(); @@ -2475,14 +2459,11 @@ async fn scan_vendored_warns_on_a_degraded_vlt_edit_at_the_tilde_boundary() { original: None, new: None, }]; - std::fs::write( - hosted::ledger_path(root), - serde_json::to_string_pretty(&ledger).unwrap(), - ) - .unwrap(); + let bytes = serde_json::to_string_pretty(&ledger).unwrap(); + std::fs::write(hosted::ledger_path(root), &bytes).unwrap(); let cwd = root.to_str().unwrap().to_string(); let uri = server.uri(); - let (_, env, stderr) = hosted::run_json( + let (code, env, stderr) = hosted::run_json( root, &[ "scan", @@ -2500,22 +2481,16 @@ async fn scan_vendored_warns_on_a_degraded_vlt_edit_at_the_tilde_boundary() { ], &[], ); + assert_eq!(code, 0, "{key}: {env:#}\n{stderr}"); let text = env.to_string(); - let warned = text.contains("vendor_supersedes_redirect"); - assert_eq!(warned, overlaps, "{key}: {env:#}\n{stderr}"); - let edits = std::fs::read(hosted::ledger_path(root)) - .ok() - .map(|b| { - serde_json::from_slice::(&b) - .unwrap() - .edits - .len() - }) - .unwrap_or(0); + assert!( + !text.contains("vendor_supersedes_redirect"), + "{key}: the v5 vendored scan never reconciles a hosted ledger: {env:#}" + ); assert_eq!( - edits, - usize::from(!overlaps), - "{key}: the reconciliation drops exactly the claimed edit: {env:#}" + std::fs::read_to_string(hosted::ledger_path(root)).unwrap(), + bytes, + "{key}: the pre-v5 ledger is left byte-identical: {env:#}" ); } } diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index 0c6399bdd..ca875494d 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -1,8 +1,9 @@ //! Coverage-gap tests for `commands/vendor.rs`: //! the fail-closed ledger/manifest exit contracts, the fresh-clone -//! committed-artifact staging error ladder, the redirect-ledger takeover -//! guard, the human-mode (no `--json`) output surface, and the unix -//! fault-injection paths for the two state-write failure events. +//! committed-artifact staging error ladder, the hosted-pin takeover guard +//! (v5: hosted state is the lockfile pins), the human-mode (no `--json`) +//! output surface, and the unix fault-injection paths for the state-write +//! failure events. //! //! Fixture + runner shapes mirror `in_process_vendor.rs` (which this suite //! deliberately does not touch): an offline, self-contained npm project with @@ -427,58 +428,122 @@ fn missing_package_with_no_lock_and_no_ledger_is_calm_skip() { } // ───────────────────────────────────────────────────────────────────── -// 3. redirect-ledger takeover guard +// 3. hosted-pin takeover guard (v5: lockfile pins, no redirect ledger) // ───────────────────────────────────────────────────────────────────── -/// A malformed redirect ledger makes a claimed purl indistinguishable from -/// an unclaimed one, so every purl of a takeover-capable ecosystem (npm, -/// cargo) fails CLOSED with the corruption surfaced — and nothing is -/// vendored over the possibly-live hosted redirect. +/// The mock patch-server origin the hosted pins below live on. Only +/// `https://patch.socket.dev` and the `--patch-server-url` origin count as +/// hosted, so every run over these pins passes that flag. +const HOSTED_ORIGIN: &str = "http://patch.test"; +const HOSTED_INTEGRITY: &str = "sha512-HOSTEDpatchedHOSTEDpatched=="; +/// What the mock npm registry's version document hands back for the +/// upstream restore. +const UPSTREAM_INTEGRITY: &str = "sha512-UPSTREAMupstreamUPSTREAM=="; + +fn hosted_url() -> String { + format!( + "{HOSTED_ORIGIN}/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/{UUID}/left-pad-1.3.0.tgz" + ) +} + +/// Pin the fixture's left-pad entry to the hosted tarball (what `scan +/// --mode hosted` leaves in package-lock.json) and return the lock bytes. +fn pin_hosted(fx: &NpmFixture) -> Vec { + let mut lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + lock["packages"]["node_modules/left-pad"]["resolved"] = Value::String(hosted_url()); + lock["packages"]["node_modules/left-pad"]["integrity"] = + Value::String(HOSTED_INTEGRITY.to_string()); + let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); + bytes.push(b'\n'); + std::fs::write(fx.lock_path(), &bytes).unwrap(); + bytes +} + +/// A wiremock npm registry answering left-pad@1.3.0's version document. +async fn mock_npm_registry() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": { + "tarball": format!("{}/left-pad/-/left-pad-1.3.0.tgz", server.uri()), + "integrity": UPSTREAM_INTEGRITY, + "shasum": "0000000000000000000000000000000000000000" + } + }))) + .mount(&server) + .await; + server +} + +/// `vendor --json --patch-server-url ` through the binary, +/// ONLINE against the mock registry (the upstream restore needs it) but +/// anonymous, so no other network path opens. +fn vendor_online(fx: &NpmFixture, registry: &str, extra: &[&str]) -> (i32, Value) { + let root = fx.root().to_str().unwrap(); + let mut args = vec![ + "vendor", + "--json", + "--patch-server-url", + HOSTED_ORIGIN, + "--cwd", + root, + ]; + args.extend_from_slice(extra); + let (code, stdout, stderr) = run_cli( + fx.root(), + &args, + &[ + ("SOCKET_NO_API_TOKEN", "1"), + ("SOCKET_NPM_REGISTRY", registry), + ], + ); + let env: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); + (code, env) +} + +/// A pre-v5 redirect ledger on disk (even a malformed one) is IGNORED: +/// the vendor run neither reads nor rewrites it, vendors the purl as +/// usual, and leaves the stale file byte-identical. #[test] -fn corrupt_redirect_ledger_fails_takeover_capable_purl_closed() { +fn corrupt_pre_v5_redirect_ledger_is_ignored_by_vendor() { let fx = npm_fixture(); std::fs::create_dir_all(fx.vendor_dir()).unwrap(); std::fs::write(fx.redirect_state_path(), b"garbage").unwrap(); let (code, env) = vendor_cli(fx.root(), &[]); - assert_eq!(code, 1, "{env:#}"); - let failed = find_event(&env, "failed", Some("redirect_ledger_corrupt")); - assert_eq!(failed["purl"], PURL); + assert_eq!(code, 0, "a stale ledger must not block vendoring: {env:#}"); + let applied = find_event(&env, "applied", None); + assert_eq!(applied["purl"], PURL); assert!( - failed["error"] - .as_str() - .is_some_and(|d| d.contains("cannot vendor over a possibly-live hosted redirect")), - "{env:#}" + events(&env) + .iter() + .all(|e| e["errorCode"] != "redirect_ledger_corrupt"), + "the pre-v5 ledger is never read: {env:#}" ); + assert!(fx.tgz_path().is_file(), "the purl is vendored"); + assert_ne!(fx.lock_bytes(), fx.original_lock, "the lock is rewired"); assert_eq!( - fx.lock_bytes(), - fx.original_lock, - "the lock must not be rewired while the redirect ledger is unreadable" - ); - assert!( - !fx.tgz_path().exists(), - "no artifact may be produced for the refused purl" + std::fs::read(fx.redirect_state_path()).unwrap(), + b"garbage", + "the pre-v5 ledger is left untouched" ); } -/// Dry-run over a purl the redirect ledger still claims: the run must warn +/// Dry run over a purl the lockfile pins hosted: the run warns /// `vendor_would_revert_redirect` (an UNCOUNTED advisory — dry/wet takeover -/// parity) and leave both the redirect ledger and the lockfile untouched. -#[test] -fn dry_run_over_claimed_redirect_warns_and_writes_nothing() { +/// parity) after resolving the upstream entry exactly like a wet run +/// would, and writes nothing — no lock edit, no artifact, no ledger. +#[tokio::test] +async fn dry_run_over_hosted_pin_warns_and_writes_nothing() { let fx = npm_fixture(); - std::fs::create_dir_all(fx.vendor_dir()).unwrap(); - let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); - let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - let ledger = json!({ - "version": 1, - "mode": "hosted", - "records": { PURL: patch_record(&before_hash, &after_hash) } - }); - let ledger_bytes = serde_json::to_vec_pretty(&ledger).unwrap(); - std::fs::write(fx.redirect_state_path(), &ledger_bytes).unwrap(); + let hosted_lock = pin_hosted(&fx); + let registry = mock_npm_registry().await; - let (code, env) = vendor_cli(fx.root(), &["--dry-run"]); + let (code, env) = vendor_online(&fx, ®istry.uri(), &["--dry-run"]); assert_eq!(code, 0, "the dry run itself succeeds: {env:#}"); let warned = find_event(&env, "skipped", Some("vendor_would_revert_redirect")); assert_eq!(warned["purl"], PURL); @@ -486,63 +551,115 @@ fn dry_run_over_claimed_redirect_warns_and_writes_nothing() { env["summary"]["skipped"], 0, "the takeover advisory is uncounted: {env:#}" ); - assert_eq!( - std::fs::read(fx.redirect_state_path()).unwrap(), - ledger_bytes, - "a dry run must not touch the redirect ledger" - ); assert_eq!( fx.lock_bytes(), - fx.original_lock, + hosted_lock, "a dry run must not touch the lock" ); + assert!(!fx.tgz_path().exists(), "a dry run vendors nothing"); + assert!( + !fx.state_path().exists(), + "a dry run writes no vendor ledger" + ); + assert!( + !fx.redirect_state_path().exists(), + "no hosted ledger is ever written" + ); +} + +/// The wet twin: vendoring over the hosted pin first restores the upstream +/// registry entry (`vendor_takeover_reverted_redirect`), then vendors; the +/// vendor ledger records the UPSTREAM entry as the original, so `vendor +/// --revert` lands on the registry entry — never back on the hosted URL. +#[tokio::test] +async fn vendor_over_hosted_pin_restores_upstream_then_revert_returns_to_registry() { + let fx = npm_fixture(); + pin_hosted(&fx); + let registry = mock_npm_registry().await; + + let (code, env) = vendor_online(&fx, ®istry.uri(), &[]); + assert_eq!(code, 0, "{env:#}"); + find_event(&env, "applied", None); + let warned = find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); + assert!( + warned + .to_string() + .contains("restored its upstream registry entry (package-lock.json)"), + "the advisory names the restored lockfile: {warned:#}" + ); + let lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + let entry = &lock["packages"]["node_modules/left-pad"]; + assert!( + entry["resolved"] + .as_str() + .unwrap_or_default() + .contains(".socket/vendor/"), + "the lock is vendored: {lock:#}" + ); + assert!(!fx + .lock_bytes() + .windows(HOSTED_ORIGIN.len()) + .any(|w| w == HOSTED_ORIGIN.as_bytes())); + assert!(!fx.redirect_state_path().exists(), "no hosted ledger"); + + let (code, env) = vendor_cli(fx.root(), &["--revert"]); + assert_eq!(code, 0, "{env:#}"); + let lock: Value = serde_json::from_slice(&fx.lock_bytes()).unwrap(); + let entry = &lock["packages"]["node_modules/left-pad"]; + assert_eq!( + entry["resolved"], + format!("{}/left-pad/-/left-pad-1.3.0.tgz", registry.uri()), + "revert lands on the upstream registry tarball: {lock:#}" + ); + assert_eq!(entry["integrity"], UPSTREAM_INTEGRITY, "{lock:#}"); } -/// A claimed redirect whose recorded edit cannot be reverted (no recorded -/// original fragment) fails the purl CLOSED with `redirect_revert_failed` -/// — vendoring over an unrevertable live redirect would strand the hosted -/// edits forever. +/// A hosted pin whose upstream entry cannot be restored (here: `--offline`, +/// so the registry lookup is impossible) fails the purl CLOSED with +/// `redirect_revert_failed` naming the checkout remedy — vendoring over a +/// live hosted pin would record the hosted fragment as the "original". #[test] -fn unrevertable_redirect_claim_fails_closed() { +fn unrestorable_hosted_pin_fails_closed() { let fx = npm_fixture(); - std::fs::create_dir_all(fx.vendor_dir()).unwrap(); - let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); - let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - // A yarn-classic hosted edit claiming this purl, with NO original - // fragment recorded: the revert must refuse rather than guess. - let ledger = json!({ - "version": 1, - "mode": "hosted", - "edits": [{ - "path": "yarn.lock", - "kind": "redirect_yarn_classic_entry", - "action": "rewritten", - "key": "left-pad@1.3.0" - }], - "records": { PURL: patch_record(&before_hash, &after_hash) } - }); - let ledger_bytes = serde_json::to_vec_pretty(&ledger).unwrap(); - std::fs::write(fx.redirect_state_path(), &ledger_bytes).unwrap(); + let hosted_lock = pin_hosted(&fx); - let (code, env) = vendor_cli(fx.root(), &[]); + let (code, env) = vendor_cli(fx.root(), &["--patch-server-url", HOSTED_ORIGIN]); assert_eq!(code, 1, "{env:#}"); let failed = find_event(&env, "failed", Some("redirect_revert_failed")); assert_eq!(failed["purl"], PURL); + let detail = failed["error"].as_str().unwrap_or_default(); assert!( - failed["error"] - .as_str() - .is_some_and(|d| d.contains("cannot vendor over the live hosted redirect")), + detail.contains("cannot vendor over the live hosted pin") + && detail.contains("git checkout -- package-lock.json"), "{env:#}" ); assert_eq!( - std::fs::read(fx.redirect_state_path()).unwrap(), - ledger_bytes, - "a refused takeover must leave the redirect ledger as it was" + fx.lock_bytes(), + hosted_lock, + "the hosted pin stays as found" ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); assert!(!fx.tgz_path().exists(), "no artifact for the refused purl"); + assert!(!fx.state_path().exists(), "no vendor ledger entry"); + assert!(!fx.redirect_state_path().exists()); } +/// Without `--patch-server-url` a URL on a non-Socket origin is NOT a +/// hosted pin, so no takeover is attempted and no `redirect_revert_failed` +/// can fire. +#[test] +fn hosted_url_on_unconfigured_origin_is_not_a_takeover() { + let fx = npm_fixture(); + pin_hosted(&fx); + + let (_code, env) = vendor_cli(fx.root(), &[]); + assert!( + events(&env).iter().all(|e| { + e["errorCode"] != "redirect_revert_failed" + && e["errorCode"] != "vendor_takeover_reverted_redirect" + }), + "an unrecognized origin is not hosted state: {env:#}" + ); +} // ───────────────────────────────────────────────────────────────────── // 4. revert-failure accounting on tampered ledger entries // ───────────────────────────────────────────────────────────────────── @@ -993,61 +1110,6 @@ async fn vendor_state_write_failure_reports_failed_event() { ); } -/// A hosted redirect record whose revert succeeds but whose ledger update -/// cannot be persisted (`.socket/vendor` read-only). The takeover's revert, -/// its redirect-ledger drop, the vendor rewire and the vendor ledger are -/// committed together, so the failed commit leaves ALL of them as found: -/// the lock untouched, the redirect ledger byte-identical (still claiming -/// only wiring that is still there), no vendor ledger — never a redirect -/// ledger claiming reverted wiring. The run exits 1 with -/// `vendor_commit_failed`. (Before the group commit the takeover persisted -/// the redirect ledger on its own and failed the purl closed with -/// `redirect_ledger_write_failed` before vendoring it.) -#[cfg(unix)] -#[test] -fn redirect_ledger_write_failure_commits_nothing() { - let fx = npm_fixture(); - std::fs::create_dir_all(fx.vendor_dir()).unwrap(); - let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); - let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - // A record claiming the purl with no edits left to unwind: the revert - // trivially succeeds, so the ledger persist is the step that fails. - let ledger = json!({ - "version": 1, - "mode": "hosted", - "records": { PURL: patch_record(&before_hash, &after_hash) } - }); - let ledger_bytes = serde_json::to_vec_pretty(&ledger).unwrap(); - std::fs::write(fx.redirect_state_path(), &ledger_bytes).unwrap(); - chmod(&fx.vendor_dir(), 0o555); - let _restore = RestorePerms(fx.vendor_dir()); - - let (code, env) = vendor_cli(fx.root(), &[]); - assert_eq!(code, 1, "{env:#}"); - assert_eq!(env["error"]["code"], "vendor_commit_failed", "{env:#}"); - assert!( - env["error"]["message"] - .as_str() - .is_some_and(|d| d.contains("could not commit")), - "{env:#}" - ); - assert_eq!( - fx.lock_bytes(), - fx.original_lock, - "no vendor rewire is committed" - ); - assert!(!fx.state_path().exists(), "no vendor ledger is committed"); - assert_eq!( - std::fs::read(fx.redirect_state_path()).unwrap(), - ledger_bytes, - "the unpersistable ledger is left exactly as found" - ); - assert!( - !fx.vendor_dir().join(".commit-journal.json").exists(), - "the failed commit leaves no journal behind" - ); -} - // ───────────────────────────────────────────────────────────────────── // 8. human-mode error/refusal stderr surfaces (no --json, no --silent) // @@ -1157,65 +1219,52 @@ async fn human_fetch_failure_prints_fetch_failed() { "nothing may be vendored from a failed fetch" ); } - -/// Human corrupt-redirect-ledger surface: the takeover-capable purl's -/// fail-closed refusal prints `Cannot vendor …` with the corruption. +/// Human stale-ledger surface: a malformed pre-v5 redirect ledger is +/// ignored — the run vendors normally and prints no refusal. #[test] -fn human_corrupt_redirect_ledger_prints_cannot_vendor() { +fn human_corrupt_pre_v5_redirect_ledger_vendors_normally() { let fx = npm_fixture(); std::fs::create_dir_all(fx.vendor_dir()).unwrap(); std::fs::write(fx.redirect_state_path(), b"garbage").unwrap(); let (code, stdout, stderr) = human_vendor(&fx, &[]); - assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stderr.contains("Cannot vendor pkg:npm/left-pad@1.3.0:"), - "stderr names the refused purl: {stderr}" + stdout.contains("Vendored 1 package."), + "the stale ledger does not block the run: {stdout}" ); assert!( - stdout.contains("Vendored 0 packages; 1 failed."), - "the fail-closed refusal is counted: {stdout}" + !stderr.contains("Cannot vendor"), + "no refusal is printed: {stderr}" + ); + assert_eq!( + std::fs::read(fx.redirect_state_path()).unwrap(), + b"garbage", + "the pre-v5 ledger is left untouched" ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); } -/// Human unrevertable-redirect surface: a claimed purl whose hosted edits -/// cannot be reverted prints the `cannot revert the hosted redirect` line. +/// Human unrestorable-hosted-pin surface: an offline run over a hosted pin +/// prints the `Cannot vendor …: cannot restore the upstream entry` line and +/// counts the refusal. #[test] -fn human_unrevertable_redirect_prints_cannot_revert() { +fn human_unrestorable_hosted_pin_prints_cannot_restore() { let fx = npm_fixture(); - std::fs::create_dir_all(fx.vendor_dir()).unwrap(); - let before_hash = compute_git_sha256_from_bytes(ORIG_INDEX); - let after_hash = compute_git_sha256_from_bytes(PATCHED_INDEX); - // Same unrevertable shape as section 3: a rewritten hosted edit with - // NO recorded original fragment. - let ledger = json!({ - "version": 1, - "mode": "hosted", - "edits": [{ - "path": "yarn.lock", - "kind": "redirect_yarn_classic_entry", - "action": "rewritten", - "key": "left-pad@1.3.0" - }], - "records": { PURL: patch_record(&before_hash, &after_hash) } - }); - std::fs::write( - fx.redirect_state_path(), - serde_json::to_vec_pretty(&ledger).unwrap(), - ) - .unwrap(); + let hosted_lock = pin_hosted(&fx); - let (code, stdout, stderr) = human_vendor(&fx, &[]); + let (code, stdout, stderr) = human_vendor(&fx, &["--patch-server-url", HOSTED_ORIGIN]); assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( stderr.contains("Cannot vendor pkg:npm/left-pad@1.3.0:") - && stderr.contains("cannot revert the hosted redirect"), + && stderr.contains("cannot restore the upstream entry"), "the human takeover-refusal line: {stderr}" ); - assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); + assert!( + stdout.contains("Vendored 0 packages; 1 failed."), + "the fail-closed refusal is counted: {stdout}" + ); + assert_eq!(fx.lock_bytes(), hosted_lock, "lock untouched"); } - /// Human backend-refusal surface: an installed package with NO lockfile of /// any flavor is a non-benign `vendor_lockfile_missing` refusal — the /// `Cannot vendor …` stderr line carries the backend's remedy. diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index a1bed29c6..6de4d9603 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -1,5 +1,6 @@ //! Coverage-gap tests for `commands/vex.rs`: the -//! corrupt-ledger/corrupt-manifest hard-error family, the multi-manifest +//! corrupt-ledger/corrupt-manifest family (hard errors, plus the advisory +//! malformed pre-v5 redirect ledger), the multi-manifest //! product auto-detect warning echo, and the four skip guards in the //! go-patches `replace` synthesis (including the SECURITY fail-closed //! coordinate guard on tamper-able `go.mod` lines). @@ -233,26 +234,46 @@ fn corrupt_manifest_json_envelope_carries_code_and_removes_stale_doc() { } // ────────────────────────────────────────────────────────────────────── -// corrupt redirect ledger → `redirect_ledger_corrupt`, exit 2 (redirect ledger load) +// corrupt pre-v5 redirect ledger → `redirect_ledger_corrupt` WARNING // -// The module doc promises a HARD error for a present-but-malformed -// `.socket/vendor/redirect-state.json`: attesting with its records -// silently dropped would produce a false document. No manifest is laid -// down — the ledger load must error BEFORE the empty-manifest / -// manifest_not_found check, which is itself an ordering assertion. +// v5 hosted mode keeps no ledger: hosted references come from the +// lockfiles and their records from the API. A pre-v5 +// `.socket/vendor/redirect-state.json` is only an extra local record +// source, so a malformed one is an advisory — its records are simply not +// consulted, the run proceeds to its normal outcome, and (vex being a +// read-only consumer) the file is left byte-identical, never quarantined. // ────────────────────────────────────────────────────────────────────── -/// Plant a malformed redirect ledger at its canonical path. +const CORRUPT_REDIRECT_LEDGER: &str = "{{{"; + +/// Plant a malformed pre-v5 redirect ledger at its canonical path. fn write_corrupt_redirect_ledger(cwd: &Path) { let dir = cwd.join(".socket/vendor"); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("redirect-state.json"), "{{{").unwrap(); + std::fs::write(dir.join("redirect-state.json"), CORRUPT_REDIRECT_LEDGER).unwrap(); +} + +fn assert_redirect_ledger_untouched(cwd: &Path) { + assert_eq!( + std::fs::read_to_string(cwd.join(".socket/vendor/redirect-state.json")).unwrap(), + CORRUPT_REDIRECT_LEDGER, + "vex must leave the malformed pre-v5 ledger byte-identical" + ); + assert!( + !cwd.join(".socket/vendor/redirect-state.json.corrupt") + .exists(), + "vex must not quarantine the pre-v5 ledger" + ); } #[test] -fn corrupt_redirect_ledger_hard_errors_in_human_mode() { +fn corrupt_redirect_ledger_is_a_warning_in_human_mode_and_the_run_proceeds() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); + // A manifest patch whose package is nowhere on disk: the run's own + // outcome is `no_applicable_patches` (exit 1) — reaching it proves the + // malformed ledger did not abort the run. + write_ghost_npm_manifest(cwd, "pkg:npm/leftpad@1.0.0"); write_corrupt_redirect_ledger(cwd); let out = cli() @@ -265,32 +286,33 @@ fn corrupt_redirect_ledger_hard_errors_in_human_mode() { ]) .output() .expect("invoke vex"); + let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!( out.status.code(), - Some(2), - "a malformed redirect ledger is a hard error, never a degrade. stderr:\n{}", - String::from_utf8_lossy(&out.stderr) + Some(1), + "a malformed pre-v5 redirect ledger is advisory: the run reaches its own \ + no_applicable_patches outcome. stderr:\n{stderr}" ); - assert!(out.stdout.is_empty(), "no document on a hard error"); - let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("redirect ledger") && stderr.contains("malformed"), - "the CorruptRedirectState message must reach stderr. got: {stderr}" + stderr.contains("Warning:") + && stderr.contains("pre-v5 redirect ledger") + && stderr.contains("malformed") + && stderr.contains("not consulted"), + "the redirect_ledger_corrupt warning must reach stderr. got: {stderr}" ); - // Ordering: the ledger error fires before the missing-manifest check — - // the (absent) manifest must not be what gets reported. assert!( !stderr.contains("Manifest not found"), - "the redirect-ledger error must win over manifest_not_found. got: {stderr}" + "the manifest was read and planned from. got: {stderr}" ); + assert_redirect_ledger_untouched(cwd); } #[test] -fn corrupt_redirect_ledger_json_envelope_carries_code_and_preserves_ledger() { +fn corrupt_redirect_ledger_json_envelope_carries_the_warning_and_preserves_ledger() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); + write_ghost_npm_manifest(cwd, "pkg:npm/leftpad@1.0.0"); write_corrupt_redirect_ledger(cwd); - let ledger_path = cwd.join(".socket/vendor/redirect-state.json"); let vex_path = cwd.join("out.vex.json"); let out = cli() @@ -306,43 +328,78 @@ fn corrupt_redirect_ledger_json_envelope_carries_code_and_preserves_ledger() { ]) .output() .expect("invoke vex"); - assert_eq!( - out.status.code(), - Some(2), - "redirect_ledger_corrupt is a hard error in --json mode too. stdout:\n{}", - String::from_utf8_lossy(&out.stdout) - ); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["status"], "error", "{env}"); - assert_eq!(env["error"]["code"], "redirect_ledger_corrupt", "{env}"); + assert_eq!(out.status.code(), Some(1), "{env}"); + // The run's own outcome, not a ledger error. + assert_eq!(env["error"]["code"], "no_applicable_patches", "{env}"); + let skipped = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["action"] == "skipped" && e["purl"] == "pkg:npm/leftpad@1.0.0") + .unwrap_or_else(|| panic!("the manifest patch was evaluated: {env}")); + assert_eq!(skipped["errorCode"], "package_not_found", "{env}"); + let warning = env["warnings"] + .as_array() + .unwrap_or_else(|| panic!("warnings[] expected: {env}")) + .iter() + .find(|w| w["code"] == "redirect_ledger_corrupt") + .unwrap_or_else(|| panic!("redirect_ledger_corrupt must be in warnings[]: {env}")); assert!( - env["error"]["message"] + warning["detail"] .as_str() - .unwrap() - .contains("malformed"), - "the envelope must carry the CorruptRedirectState detail: {env}" + .is_some_and(|m| m.contains("malformed") && m.contains("redirect-state.json")), + "{env}" ); - // vex is a READ-ONLY ledger consumer: the malformed file may still hold - // the only pre-redirect revert data, so it must be left exactly where it - // was — neither deleted nor quarantined by this run. - assert_eq!( - std::fs::read_to_string(&ledger_path).unwrap(), - "{{{", - "vex must not touch the malformed redirect ledger" + // Under --json the warning travels in the envelope only. + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("pre-v5 redirect ledger"), + "--json must not echo the warning on stderr: {stderr}" ); + assert_redirect_ledger_untouched(cwd); + assert!(!vex_path.exists(), "no document when nothing was attested"); +} + +#[test] +fn corrupt_redirect_ledger_alone_warns_and_is_still_manifest_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_corrupt_redirect_ledger(cwd); + + let vex_path = cwd.join("out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); + // A malformed ledger is no patch source: with nothing else on disk the + // run is the ordinary nothing-to-attest error. + assert_eq!(out.status.code(), Some(2), "{env}"); + assert_eq!(env["error"]["code"], "manifest_not_found", "{env}"); assert!( - !cwd.join(".socket/vendor/redirect-state.json.corrupt") - .exists(), - "vex must not quarantine the ledger (that is the writer's recovery flow)" + env["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "redirect_ledger_corrupt")), + "{env}" ); - assert!(!vex_path.exists(), "no document on a hard error"); + assert_redirect_ledger_untouched(cwd); } // ────────────────────────────────────────────────────────────────────── // corrupt vendor ledger → `vendor_ledger_corrupt`, exit 2 // // A present-but-corrupt `.socket/vendor/state.json` is a hard error -// mirroring `redirect_ledger_corrupt`: the vendor ledger is an +// (unlike the advisory pre-v5 redirect ledger above): the vendor ledger is an // attestation input in its own right — it carries embedded records and // the entries whose wiring liveness gates them — so a run that cannot read // it cannot tell which vendored patches it is dropping, and attesting from diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index 959f506f2..d51b6c778 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -1,6 +1,9 @@ //! Real Bun binary-lock acceptance tests. The CLI must never invoke a Bun //! conversion or replace bun.lockb with text. Every terminal mode is checked -//! with an empty-cache frozen install, and rollback restores the exact input. +//! with an empty-cache frozen install, and rollback restores the exact input +//! (v5: a hosted pin in a binary lock is refused by rollback — restored from +//! version control instead — while a vendor takeover rebuilds its registry +//! record from the npm registry, refusing only offline). //! //! Run scripts/backtest-bun-lockb.py for the writer/reader release matrix. //! SOCKET_PATCH_BUN_LOCKB_REQUIRED=1 makes missing tools a hard error; @@ -62,8 +65,14 @@ fn require_success(output: Output, label: &str) -> Output { } fn cli(project: &Path, args: &[&str]) -> Value { + cli_env(project, args, &[]) +} + +/// [`cli`] with extra environment variables. +fn cli_env(project: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value { let output = require_success( command(env!("CARGO_BIN_EXE_socket-patch"), project) + .envs(envs.iter().copied()) .args(args) .args([ "--cwd", @@ -84,6 +93,69 @@ fn cli(project: &Path, args: &[&str]) -> Value { }) } +/// [`cli`] for a run expected to fail: `(exit code, envelope)`. +fn cli_code(project: &Path, args: &[&str]) -> (i32, Value) { + let output = command(env!("CARGO_BIN_EXE_socket-patch"), project) + .args(args) + .args([ + "--cwd", + project.to_str().unwrap(), + "--json", + "--no-telemetry", + ]) + .output() + .unwrap(); + let envelope = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "{error}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + (output.status.code().unwrap_or(-1), envelope) +} + +/// v5 keeps no hosted ledger, so undoing a hosted pin means restoring the +/// entry's upstream registry form. For a binary `bun.lockb` only a vendor +/// takeover rebuilds that record (it refuses a workspace-normalized lock), so +/// `rollback` REFUSES the pin, naming the checkout remedy, and leaves the +/// lock exactly as found; the test then applies that remedy (`git checkout -- +/// bun.lockb`, here: the original bytes written back). +fn rollback_refuses_binary_hosted_pin_then_checkout(fixture: &Fixture, server: &MockServer) { + let hosted_lock = fixture.lock(); + let uri = server.uri(); + let (code, env) = cli_code( + &fixture.project, + &["rollback", "--yes", "--patch-server-url", &uri], + ); + assert_eq!(code, 1, "a binary hosted pin cannot be restored: {env}"); + assert_eq!(env["status"], "partial_failure", "{env}"); + let failed = env["hosted"]["failed"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + failed.iter().any(|f| f["purl"] == PURL + && f["error"] + .as_str() + .is_some_and(|e| e.contains("git checkout -- bun.lockb"))), + "the refusal names the checkout remedy: {env}" + ); + assert_eq!( + fixture.lock(), + hosted_lock, + "a refused restore writes nothing" + ); + assert!( + !fixture + .project + .join(".socket/vendor/redirect-state.json") + .exists(), + "no hosted ledger exists" + ); + std::fs::write(fixture.project.join("bun.lockb"), &fixture.original_lock).unwrap(); +} + fn scan(project: &Path, server: &MockServer, mode: &str, extra: &[&str]) -> Value { let uri = server.uri(); let mut args = vec![ @@ -670,10 +742,116 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { "hosted rerun: {repeat}" ); assert_eq!(fixture.lock(), hosted_lock); + assert!( + !project.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" + ); std::fs::rename(&modules, project.join("node_modules")).unwrap(); - // Hosted -> vendored, including truthful dry run and exact rerun state. + // Hosted -> vendored: v5 restores a hosted pin's upstream entry before + // vendoring over it, re-resolving the registry record from the npm + // registry — which an OFFLINE takeover cannot do, so it is REFUSED (dry + // and wet alike, nothing written) with the checkout remedy. Online, the + // takeover rebuilds the binary registry record exactly and vendors over + // it; `vendor --revert` then gives back the original bytes. After that + // (equivalently, after `git checkout -- bun.lockb`) the offline vendor + // proceeds, with a truthful dry run and exact rerun state. fixture.stage(); + let uri = server.uri(); + let before = snapshot(project); + for extra in [&["--dry-run"][..], &[][..]] { + let mut args = vec!["vendor", "--offline", "--patch-server-url", &uri]; + args.extend_from_slice(extra); + let (code, refused) = cli_code(project, &args); + assert_eq!( + code, 1, + "vendor {extra:?} over a binary hosted pin: {refused}" + ); + let failed = refused["events"] + .as_array() + .and_then(|events| { + events + .iter() + .find(|e| e["errorCode"] == "redirect_revert_failed") + }) + .unwrap_or_else(|| panic!("expected redirect_revert_failed: {refused}")); + assert_eq!(failed["purl"], PURL, "{refused}"); + assert!( + failed["error"].as_str().is_some_and(|e| { + e.contains("cannot vendor over the live hosted pin") + && e.contains("git checkout -- bun.lockb") + }), + "{refused}" + ); + assert_eq!( + snapshot(project), + before, + "refused vendor {extra:?} wrote nothing" + ); + } + // The npm registry's version document for minimist@1.2.2 (the public + // registry's values, which the original lock pins), served locally. + let integrity = "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="; + let digest = base64::engine::general_purpose::STANDARD + .decode(integrity.trim_start_matches("sha512-")) + .unwrap(); + assert!( + fixture.original_lock.windows(64).any(|w| w == digest.as_slice()), + "the original lock pins the registry digest" + ); + Mock::given(method("GET")) + .and(path("/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"dist": { + "tarball": "https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz", + "integrity": integrity}}))) + .mount(&server) + .await; + let taken_over = cli_env( + project, + &["vendor", "--patch-server-url", &uri, "--vendor-source", "build"], + &[("SOCKET_NPM_REGISTRY", &uri)], + ); + assert_eq!( + taken_over["summary"]["applied"], 1, + "online vendor over the binary hosted pin: {taken_over}" + ); + assert!( + taken_over["events"].as_array().is_some_and(|events| events + .iter() + .any(|e| e["errorCode"] == "vendor_takeover_reverted_redirect")), + "the takeover is reported: {taken_over}" + ); + let vendor_lock = fixture.lock(); + assert!( + !vendor_lock + .windows(uri.len()) + .any(|w| w == uri.as_bytes()), + "no hosted URL is left in bun.lockb" + ); + let state: Value = serde_json::from_slice( + &std::fs::read(project.join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + let original = state["entries"][PURL]["wiring"] + .as_array() + .and_then(|w| w.iter().find(|r| r["kind"] == "bun_lockb_package")) + .map(|r| r["original"].clone()) + .unwrap_or_else(|| panic!("bun_lockb_package wiring: {state}")); + assert_eq!(original["name"], "minimist", "{original}"); + assert_eq!(original["version"], "1.2.2", "{original}"); + assert_eq!( + original["resolution"], + "https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz", + "the vendor ledger records the registry record: {original}" + ); + fixture.frozen("taken-over", &fixture.patched, "minimist"); + let reverted = cli(project, &["vendor", "--revert", "--offline"]); + assert_eq!( + fixture.lock(), + fixture.original_lock, + "the revert restores the pre-hosted bytes exactly: {reverted}" + ); + assert!(!project.join(".socket/vendor").exists(), "{reverted}"); let before = snapshot(project); let preview = cli(project, &["vendor", "--offline", "--dry-run"]); assert_eq!(snapshot(project), before, "vendor dry run: {preview}"); @@ -767,8 +945,7 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { ); fixture.frozen("hosted-again", &fixture.patched, "minimist"); fixture.manifestless_vex("hosted-again", bun_vex::BunMode::Hosted, &server.uri()); - let reverted = cli(project, &["rollback", "--yes"]); - assert_eq!(reverted["status"], "success", "rollback: {reverted}"); + rollback_refuses_binary_hosted_pin_then_checkout(&fixture, &server); fixture.pristine(); fixture.frozen("rolled-back", &fixture.original, "minimist"); } @@ -847,7 +1024,7 @@ async fn native_binary_alias_and_transitive() { bun_vex::BunMode::Hosted, &server.uri(), ); - cli(&fixture.project, &["rollback", "--yes"]); + rollback_refuses_binary_hosted_pin_then_checkout(&fixture, &server); fixture.pristine(); fixture.stage(); let result = cli(&fixture.project, &["vendor", "--offline"]); diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs index e046c046e..225bba3ce 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -34,8 +34,8 @@ //! Both end in the manifest-less VEX tail //! ([`golang_e2e_matrix::manifestless_vex`]): a fresh checkout on a fresh //! machine (real `go run`, patched module in its cache) attests the patch -//! `(redirected)` from go.mod/go.sum + the patch API alone — with and -//! without the redirect ledger, never offline without a record, never once +//! `(redirected)` from go.mod/go.sum + the patch API alone (v5 hosted mode +//! writes no redirect ledger), never offline without a record, never once //! the replace is reverted. The Go release is whatever `go` is on `PATH` //! (see `golang_e2e_matrix` for the version matrix knobs). @@ -547,8 +547,8 @@ fn day2_machine_builds_patched_module_from_committed_files_alone() { /// onto the gopatch module path, plus BOTH go.sum `h1:` lines (the goproxy /// integrity pair is all-or-nothing; the grant carries it on the override's /// identifiers) — and a fresh day-2 machine must build the PATCHED module -/// from the committed files alone. Hosted persistence is the redirect ledger -/// ONLY: no manifest, no blobs. +/// from the committed files alone. Hosted persistence is the go.mod/go.sum +/// rewrite ONLY: no manifest, no blobs, no ledger (v5). // multi_thread: the CLI subprocess blocks a worker thread while wiremock // keeps serving the view + reference routes on the others. // #[serial]: see the sibling test's note — env mutation vs env iteration. @@ -564,7 +564,7 @@ async fn golang_get_uuid_hosted_day2_machine_builds() { let purl = format!("pkg:golang/{UMOD}@{UVER}"); let artifact_url = format!("{}/{}/@v/{SVER}.zip", fx.proxy_url, fx.smod); - // API mocks: `view/{uuid}` (the record the redirect ledger embeds for + // API mocks: `view/{uuid}` (the record the hosted run fetches for its // VEX) + the reference grant whose goproxy override carries the module // path/version and the gopatch-flavor hash pair the rewriter pins. let server = MockServer::start().await; @@ -694,17 +694,13 @@ async fn golang_get_uuid_hosted_day2_machine_builds() { "go.sum must pin the served-.mod hash.\nwant: {want_mod}\ngot:\n{gosum}" ); - // Hosted persistence contract: the ledger IS the persistence. - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(fx.consumer.join(".socket/vendor/redirect-state.json")) - .expect("redirect ledger must be written"), - ) - .unwrap(); - assert_eq!(ledger["mode"], "hosted", "ledger: {ledger}"); - assert_eq!( - ledger["records"][purl.as_str()]["uuid"], - UUID, - "the ledger must embed the patch record for VEX: {ledger}" + // Hosted persistence contract (v5): the go.mod/go.sum rewrite IS the + // persistence — no ledger. + assert!( + !fx.consumer + .join(".socket/vendor/redirect-state.json") + .exists(), + "hosted mode must NOT write a redirect ledger ({purl})" ); assert!( !fx.consumer.join(".socket/manifest.json").exists(), @@ -753,7 +749,7 @@ async fn golang_get_uuid_hosted_day2_machine_builds() { String::from_utf8_lossy(&patched.stdout) ); - // ── manifest-less VEX over the committed state (ledger included) ───── + // ── manifest-less VEX over the committed state (no ledger) ─────────── // The API stand-in owns its own runtime, so the tail runs off this // test's async runtime. std::thread::scope(|scope| { diff --git a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs index 81157c771..865bd6bcb 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs @@ -83,6 +83,20 @@ async fn mount_hosted_grant(server: &MockServer) { .await; } +/// Serve Go's checksum database lookup for the upstream module (the +/// `SOCKET_GOSUMDB_URL` override the hosted -> upstream restore reads): the +/// two go.sum lines of `UPSTREAM_SUM`, so the restore re-derives exactly +/// the pair the hosted rewrite pruned. +async fn mount_sumdb(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!("/lookup/{UMOD}@{UVER}"))) + .respond_with(ResponseTemplate::new(200).set_body_string(format!( + "12345\n{UPSTREAM_SUM}\ngo.sum database tree\n12345\nAAAA\n" + ))) + .mount(server) + .await; +} + fn get_hosted(consumer: &Path, server: &MockServer, modcache: &Path) -> serde_json::Value { let (code, stdout, stderr) = common::run_with_env( consumer, @@ -311,8 +325,10 @@ fn pristine_module(modcache: &Path) { std::fs::write(module_dir.join("lib.go"), PRISTINE_LIB).unwrap(); } -/// Hosted `rollback` puts the pruned upstream go.sum pair back where go -/// sorts it, so go.mod and go.sum return byte for byte. +/// Hosted `rollback` drops the hosted `replace` and puts the pruned upstream +/// go.sum pair back where go sorts it -- re-derived from the (mocked) +/// checksum database, no ledger involved -- so go.mod and go.sum return +/// byte for byte. Offline, the pin is refused and nothing is written. #[tokio::test(flavor = "multi_thread")] async fn hosted_rollback_restores_go_sum_byte_for_byte() { let tmp = tempfile::tempdir().unwrap(); @@ -325,23 +341,63 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() { mount_hosted_grant(&server).await; let env = get_hosted(&consumer, &server, &modcache); assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert!( + !consumer.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode keeps no ledger: go.mod/go.sum are the record" + ); + let wired_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap(); + let wired_sum = std::fs::read_to_string(consumer.join("go.sum")).unwrap(); + assert_ne!(wired_mod, go_mod, "precondition: go.mod is hosted-wired"); - let (code, stdout, stderr) = common::run_with_env( - &consumer, - &[ + let rollback = |extra: &[&str], env: &[(&str, &str)]| { + let mut args = vec![ "rollback", "--json", "--yes", - "--offline", "--cwd", consumer.to_str().unwrap(), - ], - &[("GOMODCACHE", modcache.to_str().unwrap())], + ]; + args.extend_from_slice(extra); + let mut env_full = vec![("GOMODCACHE", modcache.to_str().unwrap())]; + env_full.extend_from_slice(env); + common::run_with_env(&consumer, &args, &env_full) + }; + + let (code, stdout, stderr) = rollback(&["--offline"], &[]); + assert_eq!( + code, 1, + "offline must refuse\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let doc: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(doc["hosted"]["failed"][0]["purl"], UPURL, "{doc}"); + assert!( + doc["hosted"]["failed"][0]["error"] + .as_str() + .is_some_and(|e| e.contains("this run is offline") && e.contains("go.mod")), + "{doc}" + ); + assert_eq!( + std::fs::read_to_string(consumer.join("go.mod")).unwrap(), + wired_mod ); + assert_eq!( + std::fs::read_to_string(consumer.join("go.sum")).unwrap(), + wired_sum + ); + + mount_sumdb(&server).await; + let sumdb = server.uri(); + let (code, stdout, stderr) = rollback(&[], &[("SOCKET_GOSUMDB_URL", sumdb.as_str())]); assert_eq!( code, 0, "rollback failed\nstdout:\n{stdout}\nstderr:\n{stderr}" ); + let doc: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!( + doc["hosted"]["reverted"], + serde_json::json!([UPURL]), + "{doc}" + ); assert_eq!( std::fs::read_to_string(consumer.join("go.mod")).unwrap(), go_mod @@ -352,10 +408,11 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() { ); } -/// hosted → vendored takeover: vendoring must first unwind the hosted -/// redirect (replace, socket go.sum lines, pruned upstream pair, ledger -/// record), so the project is fully vendored — never a vendored go.mod -/// beside a redirect ledger that still claims the module. +/// hosted → vendored takeover: vendoring must first restore the hosted pin +/// to its upstream entry (drop the hosted replace and the socket go.sum +/// lines, re-derive the pruned upstream pair from the checksum database), +/// so the project is fully vendored — never a vendored go.mod beside a +/// hosted pin. No ledger is involved at any point. #[tokio::test(flavor = "multi_thread")] async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() { let tmp = tempfile::tempdir().unwrap(); @@ -370,11 +427,14 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() { assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); let ledger_path = consumer.join(".socket/vendor/redirect-state.json"); assert!( - std::fs::read_to_string(&ledger_path) + std::fs::read_to_string(consumer.join("go.mod")) .unwrap() - .contains(UPURL), + .contains("gopatch"), "precondition: the module is hosted-redirected" ); + assert!(!ledger_path.exists(), "hosted mode keeps no ledger"); + mount_sumdb(&server).await; + let sumdb = server.uri(); let socket = consumer.join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); @@ -400,16 +460,15 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() { .unwrap(); std::fs::write(socket.join("blobs").join(&after), PATCHED_LIB).unwrap(); + // Online: the takeover's upstream restore consults the (mocked) + // checksum database; the patch itself comes from the local manifest. let (code, stdout, stderr) = common::run_with_env( &consumer, + &["vendor", "--json", "--cwd", consumer.to_str().unwrap()], &[ - "vendor", - "--json", - "--offline", - "--cwd", - consumer.to_str().unwrap(), + ("GOMODCACHE", modcache.to_str().unwrap()), + ("SOCKET_GOSUMDB_URL", sumdb.as_str()), ], - &[("GOMODCACHE", modcache.to_str().unwrap())], ); assert_eq!( code, 0, @@ -432,9 +491,5 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() { go_sum, "go.sum is back to its pre-redirect bytes" ); - let ledger = std::fs::read_to_string(&ledger_path).unwrap_or_default(); - assert!( - !ledger.contains(UPURL), - "the redirect ledger no longer claims the module: {ledger}" - ); + assert!(!ledger_path.exists(), "no hosted ledger is ever written"); } diff --git a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs index 02201cdc3..6fd68191d 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_workspace_build.rs @@ -18,7 +18,8 @@ //! //! Each state then runs the manifest-less VEX tail //! ([`golang_e2e_matrix::manifestless_vex`]): fresh checkout, real install -//! on a fresh cache, attested with and without ledgers, `record_unavailable` +//! on a fresh cache, attested with and without ledgers (hosted mode writes +//! none since v5), `record_unavailable` //! offline, omitted when tampered or reverted. Hermetic + offline (file //! GOPROXY, per-"machine" caches, wiremock API). Needs Go 1.18+ (`go.work`); //! the release is whatever `go` is on `PATH` (see `golang_e2e_matrix`). @@ -604,8 +605,8 @@ fn go_work_hosted_members_build_patched_and_attest_without_manifest() { "only the ROOT module's files: {env}" ); assert!( - root.join(".socket/vendor/redirect-state.json").is_file(), - "hosted persistence is the redirect ledger" + !root.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted persistence is the go.mod/go.sum rewrite alone — no ledger" ); run_members( root, diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 5c17b75ac..ddab397ea 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -358,9 +358,37 @@ fn scan_hosted(cwd: &Path, extra: &[&str]) -> serde_json::Value { // Exit 0 alone is not enough: the envelope carries the real verdict. "scan --mode hosted did not report success.\nenvelope:\n{env:#}\nstderr:\n{stderr}" ); + // v5 hosted mode keeps no ledger: the lockfile pins are the whole state. + assert!( + !cwd.join(".socket/vendor/redirect-state.json").exists(), + "scan --mode hosted wrote the pre-v5 redirect ledger" + ); env } +/// The production record `GET /patch/view/` serves (the public +/// proxy), fetched on a private runtime so the sync legs can call it. +fn published_view_record_blocking(uuid: &str) -> serde_json::Value { + let url = format!("{PROXY}/patch/view/{uuid}"); + tokio::runtime::Runtime::new() + .expect("tokio runtime") + .block_on(async { + let resp = reqwest::Client::new() + .get(&url) + .header("Accept", "application/json") + .send() + .await + .unwrap_or_else(|e| panic!("GET {url}: {e}")); + let status = resp.status(); + let body = resp + .text() + .await + .unwrap_or_else(|e| panic!("GET {url}: reading body: {e}")); + assert!(status.is_success(), "GET {url}: HTTP {status}\n{body}"); + serde_json::from_str(&body).unwrap_or_else(|e| panic!("GET {url}: bad JSON ({e})")) + }) +} + /// Assert the hosted redirect actually rewrote something, and return the list /// of rewritten files. /// @@ -1034,10 +1062,39 @@ fn npm_package_lock_hosted_install_proof() { ); assert_patched(&minimist_entry(&fx.proj), PATCH_MARKER, LEG); + // ROLLBACK on a copy of the committed state: v5 restores the upstream + // registry entry re-resolved from the REAL npm registry — the lock + // comes back equal to what npm wrote — and removes the `.npmrc` the + // hosted run created. + let copy = fx.proj.parent().unwrap().join("rollback-copy"); + std::fs::create_dir_all(©).unwrap(); + for f in ["package.json", "package-lock.json", ".npmrc"] { + std::fs::copy(fx.proj.join(f), copy.join(f)).unwrap(); + } + let (code, stdout, stderr) = run(©, &["rollback", "--json", "--yes"]); + assert_eq!(code, 0, "{LEG}: rollback failed:\n{stdout}\n{stderr}"); + let rb: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("{LEG}: rollback --json is not JSON ({e}):\n{stdout}")); + assert_eq!( + rb["hosted"]["reverted"], + serde_json::json!([NPM_PURL]), + "{LEG}: rollback restores the hosted pin: {rb:#}" + ); + let json = |b: &[u8]| serde_json::from_slice::(b).unwrap(); + assert_eq!( + json(&std::fs::read(copy.join("package-lock.json")).unwrap()), + json(&lock_before), + "{LEG}: rollback restores the registry lock" + ); + assert!( + !copy.join(".npmrc").exists(), + "{LEG}: rollback removes the .npmrc the hosted run created" + ); + // MANIFEST-LESS VEX against production: the installed patched tree is - // hash-verified against the real patch record — with the ledger, from - // lockfile discovery + the public proxy without it, never offline, and - // not once the lock is reverted. + // hash-verified against the real patch record — from lockfile discovery + // + the public proxy (hosted keeps no ledger), never offline, and not + // once the lock is reverted. npm_e2e_common::production_manifestless_vex( LEG, &fx.proj, @@ -1199,15 +1256,14 @@ fn pnpm_hosted_install_proof() { } /// Manifest-less VEX over the reinstalled pnpm project, against the REAL -/// public patch proxy: the ledger record attests `(redirected)`; with both -/// ledgers deleted the production `view/` record does; `--offline` -/// has no record; and with the lock back on the registry (ledger kept) -/// nothing attests, `--no-verify` included. Only the pinned advisory is -/// asserted — production may add more to the patch later. +/// public patch proxy: hosted keeps no ledger, so the production +/// `view/` record attests `(redirected)` from the lock alone; +/// `--offline` has no record; and with the lock back on the registry +/// nothing names the patch, `--no-verify` included. Only the pinned +/// advisory is asserted — production may add more to the patch later. fn pnpm_hosted_manifestless_vex(proj: &Path, lock_pristine: &[u8], leg: &str) { use vex_e2e_common::{ - assert_absent, assert_not_attested, run_vex, statements_for, strip_ledgers, strip_manifest, - VexRun, + assert_absent, assert_not_attested, run_vex, statements_for, strip_manifest, VexRun, }; let bin = binary(); let attested = |run: &VexRun, cell: &str| { @@ -1226,15 +1282,14 @@ fn pnpm_hosted_manifestless_vex(proj: &Path, lock_pristine: &[u8], leg: &str) { ); }; strip_manifest(proj); - let ledger = proj.join(".socket/vendor/redirect-state.json"); - let ledger_bytes = std::fs::read(&ledger).expect("redirect ledger"); - attested(&VexRun::default(), "manifest deleted"); - strip_ledgers(proj); - attested(&VexRun::default(), "ledgers deleted"); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "{leg}: v5 hosted mode writes no ledger" + ); + attested(&VexRun::default(), "lock + public proxy"); let out = run_vex(&bin, proj, &VexRun::offline()); assert_eq!(out.code, Some(1), "{leg} [offline]: {out}"); assert_not_attested(&out.envelope, NPM_PURL, "record_unavailable"); - std::fs::write(&ledger, &ledger_bytes).unwrap(); std::fs::write(proj.join("pnpm-lock.yaml"), lock_pristine).unwrap(); for no_verify in [false, true] { let out = run_vex( @@ -1245,13 +1300,16 @@ fn pnpm_hosted_manifestless_vex(proj: &Path, lock_pristine: &[u8], leg: &str) { ..VexRun::default() }, ); - assert_ne!( + assert_eq!( out.code, - Some(0), - "{leg} [reverted, no_verify={no_verify}]: {out}" + Some(2), + "{leg} [reverted, no_verify={no_verify}]: nothing names the patch: {out}" + ); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{leg}: {out}" ); assert_absent(out.doc.as_ref(), NPM_PURL); - assert_not_attested(&out.envelope, NPM_PURL, "redirect_unwired"); } } @@ -1328,9 +1386,9 @@ fn yarn_classic_hosted_install_proof() { assert_patched(&minimist_entry(&fx.proj), PATCH_MARKER, LEG); // Manifest-less VEX against the REAL public proxy: the record for the - // production patch comes from `patches-api.socket.dev` once the redirect - // ledger is gone; `--offline` is pointed at an empty local stand-in so - // its zero-request claim is observable. + // production patch comes from `patches-api.socket.dev` (hosted keeps no + // ledger); `--offline` is pointed at an empty local stand-in so its + // zero-request claim is observable. let empty = vex_e2e_common::PatchApi::empty(); let reinstall_env = env; yarn_classic_vex::ManifestlessVex { @@ -1533,11 +1591,11 @@ fn berry_skip_code(env: &serde_json::Value) -> String { /// Manifest-less VEX against PRODUCTION for the berry hosted leg (a hosted /// checkout carries no `.socket/manifest.json` by design): after the /// `--immutable` reinstall served the patched bytes, standalone `vex` -/// attests the production patch — with the redirect ledger (online and -/// `--offline`), with the lockfile alone (the record fetched from the -/// public proxy), not at all `--offline` without a ledger -/// (`record_unavailable`), and not once the lock is reverted to the -/// registry and reinstalled (`redirect_unwired`, even with `--no-verify`). +/// attests the production patch from the lockfile alone (the record fetched +/// from the public proxy — v5 hosted keeps no ledger), not at all +/// `--offline` (`record_unavailable`), and not once the lock is reverted to +/// the registry and reinstalled (nothing names the patch, even with +/// `--no-verify`). fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env: &[(&str, &str)]) { const LEG: &str = "yarn_berry_hosted_install_proof (manifest-less vex)"; let proj = &fx.proj; @@ -1545,15 +1603,10 @@ fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env !proj.join(".socket/manifest.json").exists(), "{LEG}: hosted mode writes no manifest" ); - let ledger_path = proj.join(".socket/vendor/redirect-state.json"); - let ledger = std::fs::read(&ledger_path).expect("redirect ledger"); - - let (code, env_json, doc) = yarn_berry_vex(proj, &[]); - assert_berry_redirected_attestation(code, &env_json, doc, &format!("{LEG}: ledger, online")); - let (code, env_json, doc) = yarn_berry_vex(proj, &["--offline"]); - assert_berry_redirected_attestation(code, &env_json, doc, &format!("{LEG}: ledger, offline")); - - std::fs::remove_file(&ledger_path).unwrap(); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "{LEG}: v5 hosted mode writes no ledger" + ); let (code, env_json, doc) = yarn_berry_vex(proj, &[]); assert_berry_redirected_attestation(code, &env_json, doc, &format!("{LEG}: lockfile only")); let (code, env_json, doc) = yarn_berry_vex(proj, &["--offline"]); @@ -1561,8 +1614,7 @@ fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env assert_eq!(berry_skip_code(&env_json), "record_unavailable", "{LEG}"); assert!(doc.is_none(), "{LEG}: no document"); - // Revert the lock to the registry (ledger kept) and reinstall. - std::fs::write(&ledger_path, &ledger).unwrap(); + // Revert the lock to the registry and reinstall. std::fs::write(proj.join("yarn.lock"), registry_lock).unwrap(); std::fs::remove_dir_all(proj.join("node_modules")).ok(); let reinstall = tool(proj, "yarn", &["install", "--immutable"], env); @@ -1578,15 +1630,19 @@ fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env &[][..], ] { let (code, env_json, doc) = yarn_berry_vex(proj, extra); - assert_eq!(code, 1, "{LEG}: reverted {extra:?}: {env_json:#}"); assert_eq!( - berry_skip_code(&env_json), - "redirect_unwired", - "{LEG}: reverted {extra:?}" + code, 2, + "{LEG}: reverted {extra:?}: nothing names the patch: {env_json:#}" + ); + assert_eq!( + env_json["error"]["code"], "manifest_not_found", + "{LEG}: reverted {extra:?}: {env_json:#}" ); assert!(doc.is_none(), "{LEG}: reverted {extra:?}: no document"); } - println!("VEX-MATRIX|yarn@4.6.0|production|hosted|manifest-less+ledgers-deleted+offline+reverted|PASS"); + println!( + "VEX-MATRIX|yarn@4.6.0|production|hosted|manifest-less+lockfile-only+offline+reverted|PASS" + ); } #[test] @@ -1639,22 +1695,22 @@ fn bun_hosted_install_proof() { dump(&reinstall) ); assert_patched(&minimist_entry(&fx.proj), PATCH_MARKER, LEG); - let ledger: serde_json::Value = - serde_json::from_str(&read(&fx.proj.join(".socket/vendor/redirect-state.json"))).unwrap(); - let record = ledger["records"][NPM_PURL].clone(); + // v5 hosted keeps no ledger: the record the attestation must match is + // production's own (`/patch/view/` on the public proxy). + let record = published_view_record_blocking(NPM_UUID); bun_manifestless_vex_production(&fx.proj, &record, "redirected", &lock_before, LEG); } /// Manifest-less VEX over a PRODUCTION bun checkout (`checkout` holds the /// committed state plus a real frozen install of the patched bytes): /// with `.socket/manifest.json` deleted, standalone `vex` against the public -/// patch API attests minimist under the ledger record's uuid with `marker` -/// (`redirected` / `vendored`) and exactly the record's vulnerability ids; -/// with both ledgers deleted as well it still attests (lockfile discovery + -/// the API); `--offline` then omits it as `record_unavailable`; and with -/// the ledgers back but `bun.lock` reverted to `registry_lock` it is NOT -/// attested, verified or not. The hermetic twins (mock API, zero-request -/// oracle) run in `e2e_redirect_bun_build` / `e2e_vendor_bun_build`. +/// patch API attests minimist under the production record's uuid with +/// `marker` and exactly the record's vulnerability ids (lockfile discovery +/// plus the API: v5 hosted keeps no ledger); `--offline` omits it as +/// `record_unavailable`; and with `bun.lock` reverted to `registry_lock` +/// nothing names the patch any more, verified or not. The hermetic twins +/// (mock API, zero-request oracle) run in `e2e_redirect_bun_build` / +/// `e2e_vendor_bun_build`. fn bun_manifestless_vex_production( checkout: &Path, record: &serde_json::Value, @@ -1730,24 +1786,13 @@ fn bun_manifestless_vex_production( }) }; let _ = std::fs::remove_file(checkout.join(".socket/manifest.json")); + assert!( + !checkout.join(".socket/vendor/redirect-state.json").exists(), + "{leg}: v5 hosted mode writes no ledger" + ); let (code, env, doc) = vex(&[]); assert_eq!(code, Some(0), "{leg}: manifest-less vex: {env:#}"); assert_eq!(attested(&doc), want, "{leg}: manifest-less vex: {env:#}"); - let ledgers: Vec<(PathBuf, Vec)> = [ - ".socket/vendor/state.json", - ".socket/vendor/redirect-state.json", - ] - .iter() - .map(|rel| checkout.join(rel)) - .filter_map(|p| std::fs::read(&p).ok().map(|b| (p, b))) - .collect(); - assert!(!ledgers.is_empty(), "{leg}: the flow left no ledger"); - for (p, _) in &ledgers { - std::fs::remove_file(p).unwrap(); - } - let (code, env, doc) = vex(&[]); - assert_eq!(code, Some(0), "{leg}: ledger-less vex: {env:#}"); - assert_eq!(attested(&doc), want, "{leg}: ledger-less vex: {env:#}"); let (code, env, doc) = vex(&["--offline"]); assert_eq!(code, Some(1), "{leg}: offline vex: {env:#}"); assert_eq!( @@ -1756,21 +1801,16 @@ fn bun_manifestless_vex_production( "{leg}: {env:#}" ); assert!(attested(&doc).is_empty(), "{leg}: offline vex: {env:#}"); - for (p, b) in &ledgers { - std::fs::write(p, b).unwrap(); - } std::fs::write(checkout.join("bun.lock"), registry_lock).unwrap(); - let unwired = if marker == "redirected" { - "redirect_unwired" - } else { - "vendor_unwired" - }; for extra in [&[][..], &["--no-verify"][..]] { let (code, env, doc) = vex(extra); - assert_eq!(code, Some(1), "{leg}: reverted vex {extra:?}: {env:#}"); assert_eq!( - skip(&env).as_deref(), - Some(unwired), + code, + Some(2), + "{leg}: reverted vex {extra:?}: nothing names the patch: {env:#}" + ); + assert_eq!( + env["error"]["code"], "manifest_not_found", "{leg}: reverted {extra:?}: {env:#}" ); assert!( @@ -1778,7 +1818,7 @@ fn bun_manifestless_vex_production( "{leg}: reverted vex {extra:?}: {env:#}" ); } - eprintln!("BUN-VEX production {marker} manifest-deleted/ledgers-deleted/offline/reverted ok"); + eprintln!("BUN-VEX production {marker} manifest-deleted/offline/reverted ok"); } // =========================================================================== @@ -1935,7 +1975,7 @@ fn pypi_requirements_txt_hosted_install_proof() { ); // Manifest-less VEX over the installed, redirected checkout (hosted - // writes no manifest; the steps also drop the redirect ledger). + // writes no manifest, and v5 no redirect ledger either). let original = format!("{PYPI_NAME}=={PYPI_VERSION}\n"); pypi_manifestless_vex( &proj, @@ -2004,6 +2044,17 @@ fn pypi_uv_lock_hosted_install_proof() { assert_redirected(&env_json, "uv.lock"); let lock = read(&proj.join("uv.lock")); assert_hosted_pin(&lock, PYPI_UUIDS, LEG); + // The ONE uuid the resolver granted (and the lock now pins). The VEX + // stand-in below serves exactly that record: v5 hosted keeps no local + // record, so `production_record` resolves it from the public proxy, and + // handing it all of PYPI_UUIDS would let it serve the first one the + // proxy answers for — a different patch than the lock wires, which the + // manifest-less `vex` then (rightly) reports `record_unavailable` for. + let wired_uuid: &str = PYPI_UUIDS + .iter() + .copied() + .find(|u| lock.contains(u)) + .unwrap_or_else(|| panic!("{LEG}: the uv.lock pins none of PYPI_UUIDS")); std::fs::remove_dir_all(&venv).expect("rm venv"); let resync = tool(&proj, &uv, &["sync", "--frozen", "--quiet"], &env); @@ -2020,19 +2071,19 @@ fn pypi_uv_lock_hosted_install_proof() { ); // Manifest-less VEX over a fresh checkout (pyproject + uv.lock + .socket, - // reinstalled by uv from an empty cache through patch.socket.dev): the - // record comes from the redirect ledger, then — ledger deleted — from a - // local stand-in serving the SAME production record (hermetic, so a - // proxy 503 cannot flake the matrix); `--offline` makes zero requests; - // `apply --vex` and the leg's own `scan --mode hosted --vex` (live) attest; - // the reverted pair, ledger kept, reinstalled pristine, does not. + // reinstalled by uv from an empty cache through patch.socket.dev): v5 + // hosted keeps no ledger, so the record comes from a local stand-in + // serving the SAME production record (hermetic, so a proxy 503 cannot + // flake the matrix); `--offline` makes zero requests; `apply --vex` and + // the leg's own `scan --mode hosted --vex` (live) attest; the reverted + // pair, reinstalled pristine, does not. uv_vex::production_manifestless(&uv_vex::Production { leg: LEG, proj: &proj, tmp: tmp.path(), mode: uv_vex::Mode::Hosted, purl: PYPI_PURL, - uuids: PYPI_UUIDS, + uuids: &[wired_uuid], registry: ®istry, uv: &uv, patched: &|dir: &Path| { @@ -2293,7 +2344,6 @@ async fn gem_bundler_hosted_install_proof() { &proj, &wired_uuid, "redirected", - "redirect_unwired", (&pristine_gemfile, pristine_lock.as_bytes()), &[ ("BUNDLE_PATH", bundle_path.as_str()), @@ -2305,10 +2355,9 @@ async fn gem_bundler_hosted_install_proof() { /// Manifest-less VEX over a gem production leg's installed checkout `dir` /// (the real public patch proxy supplies records; `envs` point the crawler /// at the leg's BUNDLE_PATH so the installed tree is hash-verified): -/// attested with the ledger kept → attested from the lockfile wiring alone -/// once both ledgers are deleted → `record_unavailable` offline (no -/// ledger) → `unwired` once the Gemfile + lock are reverted to `pristine` -/// (ledger restored), with and without `--no-verify`. Production's +/// attested from the lockfile wiring alone (v5 hosted keeps no ledger) → +/// `record_unavailable` offline → nothing names the patch once the Gemfile +/// and lock are reverted to `pristine`, with and without `--no-verify`. Production's /// vulnerability set is not pinned here: every statement for the purl must /// be `not_affected` via `uuid` with `marker`. fn gem_manifestless_vex( @@ -2316,13 +2365,10 @@ fn gem_manifestless_vex( dir: &Path, uuid: &str, marker: &str, - unwired: &str, pristine: (&[u8], &[u8]), envs: &[(&str, &str)], ) { - use vex_e2e_common::{ - assert_not_attested, run_vex, statements_for, strip_ledgers, strip_manifest, VexRun, - }; + use vex_e2e_common::{assert_not_attested, run_vex, statements_for, strip_manifest, VexRun}; let purl = format!("pkg:gem/{GEM_NAME}@{GEM_VERSION}"); let mut base = VexRun { product: Some("pkg:gem/app@1.0.0".into()), @@ -2351,27 +2397,17 @@ fn gem_manifestless_vex( ); } }; - let ledgers = dir.join(".socket/vendor"); - let saved: Vec<(std::ffi::OsString, Vec)> = std::fs::read_dir(&ledgers) - .map(|rd| { - rd.flatten() - .filter(|e| e.path().is_file()) - .map(|e| (e.file_name(), std::fs::read(e.path()).unwrap())) - .collect() - }) - .unwrap_or_default(); strip_manifest(dir); - attested(&base, "ledger kept"); - strip_ledgers(dir); - attested(&base, "ledgers deleted"); + assert!( + !dir.join(".socket/vendor/redirect-state.json").exists(), + "{leg}: v5 hosted mode writes no ledger" + ); + attested(&base, "lockfile wiring + public proxy"); let mut offline = base.clone(); offline.offline = true; let out = run_vex(&vex_e2e_common::binary(), dir, &offline); - assert_eq!(out.code, Some(1), "{leg} offline, no ledger: {out}"); + assert_eq!(out.code, Some(1), "{leg} offline: {out}"); assert_not_attested(&out.envelope, &purl, "record_unavailable"); - for (name, bytes) in &saved { - std::fs::write(ledgers.join(name), bytes).unwrap(); - } std::fs::write(dir.join("Gemfile"), pristine.0).unwrap(); std::fs::write(dir.join("Gemfile.lock"), pristine.1).unwrap(); for no_verify in [false, true] { @@ -2380,13 +2416,25 @@ fn gem_manifestless_vex( let out = run_vex(&vex_e2e_common::binary(), dir, &run); assert_eq!( out.code, - Some(1), - "{leg} reverted no_verify={no_verify}: {out}" + Some(2), + "{leg} reverted no_verify={no_verify}: nothing names the patch: {out}" + ); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{leg}: {out}" + ); + assert!( + statements_for_opt(out.doc.as_ref(), &purl) == 0, + "{leg} reverted: {out}" ); - assert_not_attested(&out.envelope, &purl, unwired); } } +/// How many statements `doc` (if any) carries for `purl`. +fn statements_for_opt(doc: Option<&serde_json::Value>, purl: &str) -> usize { + doc.map_or(0, |d| vex_e2e_common::statements_for(d, purl).len()) +} + // =========================================================================== // Documented negative cases // =========================================================================== diff --git a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs index a86b4ddd9..9b4ccd72f 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs @@ -26,10 +26,10 @@ //! //! | step | shape | expectation | //! |---|---|---| -//! | 1 | no manifest, ledger present | attested `(redirected)` / `(vendored)` online and `--offline` (ledger record) | +//! | 1 | no manifest, vendor ledger present (vendored only — v5 hosted writes no ledger) | attested `(vendored)` online and `--offline` (ledger record) | //! | 2 | no manifest, no ledgers | attested from the lockfile wiring + the patch API record; embedded `apply --vex` (+ `vendor --vex`) too | //! | 3 | `--offline`, no ledgers | omitted `record_unavailable`, ZERO requests to the API | -//! | 4 | lock + config reverted to the registry, ledger + artifacts kept | omitted `redirect_unwired` / `vendor_unwired`, with and without `--no-verify`; a real `dotnet restore --locked-mode` of the reverted files installs the PRISTINE bytes | +//! | 4 | lock + config reverted to the registry (vendor ledger + artifacts kept) | vendored: omitted `vendor_unwired`; hosted: nothing names the patch any more (`manifest_not_found`) — with and without `--no-verify`; a real `dotnet restore --locked-mode` of the reverted files installs the PRISTINE bytes | //! //! Gates (the `e2e` CI matrix runs this suite once per SDK major with //! `--ignored`): `#[ignore]` keeps it out of the unpinned `test` job (it @@ -593,7 +593,10 @@ fn allow_http_source(config_path: &Path, uuid: &str) { // ── the shared manifest-less VEX matrix ─────────────────────────────── /// Steps 1–4 on a fresh, really-restored checkout. `ledger` is the one the -/// flow persisted; `store` holds the checkout's (patched) restore. +/// flow persisted and `dead_reason` the skip a reverted checkout reports +/// while it is kept — `None` for hosted (v5 writes no redirect ledger, so a +/// reverted checkout names the patch nowhere); `store` holds the +/// checkout's (patched) restore. #[allow(clippy::too_many_arguments)] fn manifestless_vex_matrix( dn: &Dotnet, @@ -602,8 +605,7 @@ fn manifestless_vex_matrix( store: &Path, uuid: &str, marker: Marker, - ledger: &str, - dead_reason: &str, + ledger: Option<(&str, &str)>, pristine: &[u8], patched: &[u8], registry: &(String, String), @@ -618,22 +620,29 @@ fn manifestless_vex_matrix( }; assert!(!checkout.join(".socket/manifest.json").exists()); assert!( - checkout.join(ledger).is_file(), - "the flow committed {ledger}" + !checkout + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL) + .exists(), + "v5 hosted mode writes no redirect ledger" ); + let saved_ledger = ledger.map(|(rel, _)| { + assert!(checkout.join(rel).is_file(), "the flow committed {rel}"); + std::fs::read(checkout.join(rel)).unwrap() + }); // (1) manifest gone, ledger present: online and offline (ledger record). - for (label, run) in [ - ("online+ledger", VexRun::online(&api)), - ("offline+ledger", VexRun::offline()), - ] { - let out = vex_in(checkout, store, hosted_origin(run)); - assert_eq!(out.code, Some(0), "SDK {sdk} {label}: {out}"); - assert_attested(out.doc(), PURL, uuid, marker, &vulns()); + if saved_ledger.is_some() { + for (label, run) in [ + ("online+ledger", VexRun::online(&api)), + ("offline+ledger", VexRun::offline()), + ] { + let out = vex_in(checkout, store, hosted_origin(run)); + assert_eq!(out.code, Some(0), "SDK {sdk} {label}: {out}"); + assert_attested(out.doc(), PURL, uuid, marker, &vulns()); + } } - // (2) ledgers gone too: the lockfile/config wiring + the API record. - let saved_ledger = std::fs::read(checkout.join(ledger)).unwrap(); + // (2) no ledgers: the lockfile/config wiring + the API record. strip_ledgers(checkout); let before = api.view_requests(uuid); let out = vex_in(checkout, store, hosted_origin(VexRun::online(&api))); @@ -685,10 +694,12 @@ fn manifestless_vex_matrix( ); quiet.assert_no_requests(); - // (4) the lock + config reverted to the registry, ledger + artifacts - // kept: the claim is dead even though the patched bytes are still in - // the checkout's store, with and without --no-verify. - std::fs::write(checkout.join(ledger), &saved_ledger).unwrap(); + // (4) the lock + config reverted to the registry (a vendor ledger + + // artifacts kept): the claim is dead even though the patched bytes are + // still in the checkout's store, with and without --no-verify. + if let (Some((rel, _)), Some(bytes)) = (ledger, &saved_ledger) { + std::fs::write(checkout.join(rel), bytes).unwrap(); + } std::fs::write(checkout.join("nuget.config"), ®istry.0).unwrap(); std::fs::write(checkout.join("packages.lock.json"), ®istry.1).unwrap(); let reverted_store = sb.dir(&format!("store-reverted-{uuid}")); @@ -709,11 +720,17 @@ fn manifestless_vex_matrix( let mut run = hosted_origin(VexRun::offline()); run.no_verify = no_verify; let out = vex_in(checkout, s, run); - assert_omitted( - &out, - dead_reason, - &format!("SDK {sdk} reverted nv={no_verify}"), - ); + let cell = format!("SDK {sdk} reverted nv={no_verify}"); + match ledger { + Some((_, dead_reason)) => assert_omitted(&out, dead_reason, &cell), + None => { + assert_eq!(out.code, Some(2), "{cell}: nothing names the patch: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{cell}: {out}" + ); + } + } } } } @@ -818,8 +835,7 @@ fn nuget_hosted_dotnet_restore_then_manifestless_vex() { &store_co, HOSTED_UUID, Marker::Redirected, - socket_patch_core::patch::redirect::REDIRECT_STATE_REL, - "redirect_unwired", + None, &pristine, &patched, ®istry, @@ -919,8 +935,10 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() { &store_co, VENDORED_UUID, Marker::Vendored, - socket_patch_core::vendor::VENDOR_STATE_REL, - "vendor_unwired", + Some(( + socket_patch_core::vendor::VENDOR_STATE_REL, + "vendor_unwired", + )), &pristine, &patched, ®istry, diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index 7915abe68..62758751d 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -3,9 +3,9 @@ //! //! `scan --mode hosted` rewrites `bun.lock` so the patched dependency's //! `packages` entry moves from the registry 4-tuple to the URL 3-tuple -//! `["@", {deps}, "sha512-"]`, and records the -//! patch in the redirect ledger. This test proves every link against REAL -//! `bun`: +//! `["@", {deps}, "sha512-"]` — and writes +//! nothing else (v5: no redirect ledger; the lock IS the hosted state). +//! This test proves every link against REAL `bun`: //! //! 1. `bun install` of left-pad@1.3.0 (network for fixture setup only, //! private `BUN_INSTALL_CACHE_DIR`). The text `bun.lock` is the default @@ -24,8 +24,8 @@ //! bootstrap is needed). //! 3. `scan --mode hosted --json --vex` (the real binary): bun.lock now //! pins the hosted URL + the patched sha512 and keeps its own -//! lockfileVersion line, the ledger embeds the record, the in-run VEX -//! is the `(redirected)` attestation. +//! lockfileVersion line, no ledger is written, the in-run VEX is the +//! `(redirected)` attestation (from this run's fetched record). //! 4. FRESH-CHECKOUT PROOF: only package.json + bun.lock + .socket/ travel; //! `bun install --frozen-lockfile` with a fresh `BUN_INSTALL_CACHE_DIR` //! MUST install the patched bytes from the hosted tarball. Then the @@ -36,8 +36,10 @@ //! is the matrix twin) — and land the marker bytes again. //! //! The rollback leg continues from step 4: `rollback --yes` must restore -//! bun.lock byte-for-byte to the pre-redirect snapshot, delete the redirect -//! ledger, and a fresh frozen install of the restored lock must produce the +//! bun.lock byte-for-byte to the pre-redirect snapshot — v5 re-resolves the +//! upstream registry 4-tuple (a wiremock mirror of the pristine integrity, +//! `SOCKET_NPM_REGISTRY`, with the mock origin named the patch server) — +//! and a fresh frozen install of the restored lock must produce the //! ORIGINAL registry bytes (marker gone). //! //! The negative twin serves TAMPERED tarball bytes (a different, valid @@ -293,9 +295,17 @@ fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { /// The real binary with `--no-telemetry` appended: nothing in this suite /// should ever post a telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + run_socket_env(cwd, args, &[]) +} + +/// [`run_socket`] with extra env applied after the scrub. +fn run_socket_env(cwd: &Path, args: &[&str], env: &[(String, String)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).arg("--no-telemetry").current_dir(cwd); cache_env::scrub_ambient_bun_env(&mut cmd); + for (k, v) in env { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -900,7 +910,7 @@ async fn bun_hosted_project( assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); assert_eq!( env["vex"]["verified"], false, - "in-run redirect VEX is attested from the ledger, not hash-verified: {env}" + "in-run redirect VEX is attested from the fetched record, not hash-verified: {env}" ); let vex_doc: serde_json::Value = serde_json::from_slice(&std::fs::read(proj.join("out.vex.json")).unwrap()).unwrap(); @@ -927,7 +937,7 @@ async fn bun_hosted_project( } HostedDriver::GetUuid => { // get's envelope nests the same redirect block into its own base - // shape; nothing is downloaded into `.socket/` (the ledger IS the + // shape; nothing is downloaded into `.socket/` (the lock IS the // persistence — parity with `scan --mode hosted`). assert_eq!(env["redirect"]["mode"], "hosted", "envelope: {env}"); assert_eq!(env["found"], 1, "get keeps its found count: {env}"); @@ -990,10 +1000,9 @@ async fn bun_hosted_project( ); } - let ledger = std::fs::read_to_string(redirect_ledger(&proj)).unwrap(); assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA), - "redirect ledger must embed the patch record + vulnerability: {ledger}" + !redirect_ledger(&proj).exists(), + "v5 hosted mode writes no redirect ledger — the lock is the hosted state" ); Some(BunRedirectFixture { @@ -1017,6 +1026,42 @@ fn redirect_ledger(proj: &Path) -> PathBuf { .join("redirect-state.json") } +/// The env an unwind of the fixture's hosted pin runs with: the mock origin +/// named the patch server (so its URL counts as a hosted pin) and an npm +/// registry mirror serving `left-pad@1.3.0`'s version document with the +/// integrity the PRISTINE lock recorded — all the v5 upstream restore of a +/// bun.lock entry reads. +async fn unwind_env(fx: &BunRedirectFixture) -> Vec<(String, String)> { + let server = &fx._server; + let lock_before = String::from_utf8(fx.lock_before.clone()).unwrap(); + let line = packages_line(&lock_before, DEP); + let integrity = line + .rsplit('"') + .nth(1) + .filter(|s| s.starts_with("sha512-")) + .unwrap_or_else(|| panic!("no integrity in the pristine line {line}")) + .to_string(); + Mock::given(method("GET")) + .and(path(format!("/registry/{DEP}/{DEP_VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": DEP, + "version": DEP_VERSION, + "dist": { + "tarball": format!("https://registry.npmjs.org/{DEP}/-/{DEP}-{DEP_VERSION}.tgz"), + "integrity": integrity + } + }))) + .mount(server) + .await; + vec![ + ("SOCKET_PATCH_SERVER_URL".to_string(), server.uri()), + ( + "SOCKET_NPM_REGISTRY".to_string(), + format!("{}/registry", server.uri()), + ), + ] +} + /// Fresh dir `/` with only the committable files (package.json, /// bun.lock, bunfig.toml when the project has one, and `.socket/` when it /// exists — rollback removes it). @@ -1380,10 +1425,10 @@ async fn bun_redirect_tampered_hosted_tarball_digest_boundary() { } /// Rollback leg: after the hosted rewrite and the fresh-checkout proof, -/// `rollback --yes` (unscoped — the whole-ledger reverse replay) must -/// restore bun.lock byte-for-byte to the pre-redirect snapshot and delete -/// the redirect ledger, and a fresh frozen install of the restored lock -/// must land the ORIGINAL registry bytes — the marker gone. +/// `rollback --yes` (the v5 upstream restore of the hosted pin) must +/// restore bun.lock byte-for-byte to the pre-redirect snapshot, and a +/// fresh frozen install of the restored lock must land the ORIGINAL +/// registry bytes — the marker gone. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_redirect_rollback_restores_lock_and_original_install() { @@ -1401,7 +1446,8 @@ async fn bun_redirect_rollback_restores_lock_and_original_install() { assert_patched_fresh_install(&fx); let proj = &fx.proj; - let (code, stdout, stderr) = run_socket( + let env = unwind_env(&fx).await; + let (code, stdout, stderr) = run_socket_env( proj, &[ "rollback", @@ -1410,6 +1456,7 @@ async fn bun_redirect_rollback_restores_lock_and_original_install() { "--cwd", proj.to_str().unwrap(), ], + &env, ); assert_eq!( code, 0, @@ -1425,7 +1472,7 @@ async fn bun_redirect_rollback_restores_lock_and_original_install() { ); assert!( !redirect_ledger(proj).exists(), - "rollback must delete the redirect ledger" + "no hosted ledger exists at any point" ); let restored = std::fs::read_to_string(proj.join("bun.lock")).unwrap(); assert!( @@ -1527,11 +1574,11 @@ fn fresh_frozen_install_with_local_dep( /// still our wiring — the spec bun installs from is intact — so after a /// real re-save: `rollback --dry-run` must resolve, the repeat hosted run /// must report `redirected: 1` with no `redirect_bun_entry_not_found` and -/// heal the line back to the 3-tuple (a second ledger edit), a fresh -/// frozen install must land the patched bytes, and `rollback` must put the -/// registry line back inside the GROWN lock and install the original bytes. -/// On ≥ 1.3.10 the same steps prove the no-regression twin: digest kept, -/// repeat run a no-op, one ledger edit. +/// heal the line back to the 3-tuple, a fresh frozen install must land the +/// patched bytes, and `rollback` must put the registry line back inside the +/// GROWN lock and install the original bytes. On ≥ 1.3.10 the same steps +/// prove the no-regression twin: digest kept, repeat run a no-op. No run +/// writes a ledger. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn bun_redirect_survives_a_digest_dropping_lock_resave() { @@ -1588,7 +1635,8 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { manifestless_vex(&fx, "resaved", grown_registry.as_bytes()); // 2. The unwind must already resolve over the re-saved lock (dry run). - let (code, stdout, stderr) = run_socket( + let unwind = unwind_env(&fx).await; + let (code, stdout, stderr) = run_socket_env( proj, &[ "rollback", @@ -1598,6 +1646,7 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { "--cwd", proj.to_str().unwrap(), ], + &unwind, ); assert_eq!( code, 0, @@ -1657,22 +1706,10 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { healed.contains("\"local-dep\": ["), "the grown entry survives" ); - let ledger: serde_json::Value = - serde_json::from_slice(&std::fs::read(redirect_ledger(proj)).unwrap()).unwrap(); - let edits = ledger["edits"].as_array().unwrap(); - assert_eq!( - edits.len(), - if expect_drop { 2 } else { 1 }, - "the heal is recorded as a second edit exactly when the digest was dropped: {ledger:#}" + assert!( + !redirect_ledger(proj).exists(), + "the repeat run (heal or no-op) writes no ledger" ); - if expect_drop { - assert_eq!( - edits[1]["original"], - serde_json::json!(digestless_spelling), - "{ledger:#}" - ); - assert_eq!(edits[1]["new"], serde_json::json!(wired_line), "{ledger:#}"); - } eprintln!("REPEAT HOSTED RUN OK"); // 4. The healed lock installs the patched bytes from an empty cache. @@ -1682,9 +1719,9 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { "the healed lock must install the patched bytes" ); - // 5. Rollback: registry line back inside the grown lock, ledger gone, - // original bytes on a fresh install. - let (code, stdout, stderr) = run_socket( + // 5. Rollback: registry line back inside the grown lock (the upstream + // restore), original bytes on a fresh install. + let (code, stdout, stderr) = run_socket_env( proj, &[ "rollback", @@ -1693,6 +1730,7 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { "--cwd", proj.to_str().unwrap(), ], + &unwind, ); assert_eq!( code, 0, @@ -1711,10 +1749,7 @@ async fn bun_redirect_survives_a_digest_dropping_lock_resave() { restored.contains("\"local-dep\": ["), "rollback must not disturb the grown entry:\n{restored}" ); - assert!( - !redirect_ledger(proj).exists(), - "the emptied ledger is deleted" - ); + assert!(!redirect_ledger(proj).exists(), "no hosted ledger"); let installed = fresh_frozen_install_with_local_dep(&fx, "fresh-rolled-back", &tgz_name); assert_eq!( installed, fx.orig, diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs index e3229b031..3df2d5bb5 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs @@ -20,17 +20,18 @@ //! reference / view API mocks AND a real sparse index //! (`config.json` + per-crate index file + download route). //! 3. `scan --mode hosted --json --vex …` (the real binary): the three-file -//! rewrite lands, the ledger embeds the patch record, and the in-run -//! VEX is the unverified `(redirected)` attestation. +//! rewrite lands, no ledger is written (v5: the three files ARE the +//! hosted state), and the in-run VEX is the unverified `(redirected)` +//! attestation from this run's fetched record. //! 4. FRESH-CHECKOUT PROOF: only Cargo.toml + Cargo.lock + `.cargo/` + -//! `src/` + `.socket/` travel; `cargo fetch --locked` with an EMPTY +//! `src/` (+ `.socket/` when present) travel; `cargo fetch --locked` with an EMPTY //! CARGO_HOME pulls the patched `.crate` from wiremock (byte-asserted //! against the cache), and an offline compile oracle //! (`cfg_if::socket_patched()`) proves the patched bytes are what cargo //! extracts and links. //! 5. POST-INSTALL VERIFIED VEX: `socket-patch vex` hash-verifies the -//! extracted registry sources against the ledger record and emits the -//! `(redirected)` statement. +//! extracted registry sources against the patch record (fetched from +//! the API — no hosted ledger) and emits the `(redirected)` statement. //! //! The negative twin serves TAMPERED `.crate` bytes while the index cksum //! and the lockfile checksum keep the real sha256: the fresh `cargo fetch @@ -41,22 +42,20 @@ //! the SAME fixture through `get --mode hosted` (v4.0) — parity by //! construction, since get hands the (purl, uuid) pair to scan's extracted //! run_redirect_selected engine — and re-proves the fresh-checkout fetch. -//! Hosted get writes NO manifest and NO blobs (the redirect ledger is the -//! persistence) and has no `--vex`. +//! Hosted get writes NO manifest, NO blobs and NO ledger (the lockfile +//! rewrite is the persistence) and has no `--vex`. //! //! MANIFEST-LESS VEX (both drivers): the fresh checkout above is then //! driven through the lockfile-discovery steps with the shared //! `vex_e2e_common` helpers against a separate patch-API stand-in — -//! (1) no manifest, ledger kept → `(redirected)` from the ledger record, -//! zero API calls; embedded `apply --vex` attests too; (2) ledger deleted → -//! the lockfile's hosted `source` + the API's record still attest — with -//! the patched copy extracted, and with nothing installed (the lock's -//! checksum pin) — and embedded `apply --vex` / `scan --vex` too; (3) `--offline` with no ledger → -//! `record_unavailable`, zero API requests; (4) the lockfile reverted to -//! crates.io with the ledger restored — the stale lock alone (the patched -//! copy still extracted), and the full three-file revert re-fetched from -//! crates.io by the real cargo — → NOT attested (`redirect_unwired`), also -//! under `--no-verify`. +//! (1) no manifest (and no ledger — v5 writes none) → the lockfile's hosted +//! `source` + the API's record attest `(redirected)`; embedded +//! `apply --vex` attests too; (2) with the patched copy extracted, and with +//! nothing installed (the lock's checksum pin) — and embedded `scan --vex` +//! too; (3) `--offline` → `record_unavailable`, zero API requests; (4) the +//! lockfile reverted to crates.io — the stale lock alone (the patched copy +//! still extracted), and the full three-file revert re-fetched from +//! crates.io by the real cargo — → NOT attested, also under `--no-verify`. //! //! Toolchain / lock format: `cargo_e2e_matrix` (`SOCKET_PATCH_CARGO_E2E_*`) //! runs every step under a pinned cargo release and re-encodes the @@ -577,7 +576,7 @@ async fn redirect_scanned_project( assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert_eq!( env["vex"]["verified"], false, - "in-run hosted VEX is attested from the ledger, not hash-verified: {env}" + "in-run hosted VEX is attested from the fetched record, not hash-verified: {env}" ); } Driver::GetUuid => { @@ -589,7 +588,7 @@ async fn redirect_scanned_project( assert!( env["downloaded"].is_null() && env["applied"].is_null(), "hosted get must not report downloaded/applied — nothing \ - is persisted under .socket/ but the ledger: {env}" + is persisted under .socket/: {env}" ); } } @@ -631,15 +630,16 @@ async fn redirect_scanned_project( "lock checksum must be the PATCHED .crate's sha256:\n{lock_text}" ); - // Ledger embeds the patch record so a post-install `vex` can verify. - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + // v5 hosted mode keeps no ledger: the three-file rewrite is the whole + // hosted state (a post-install `vex` fetches the record from the API). assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA), - "redirect ledger must embed the patch record + vulnerability: {ledger}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no redirect ledger" ); if driver == Driver::GetUuid { - // Parity with scan --mode hosted: the ledger IS the persistence. + // Parity with scan --mode hosted: the lockfile rewrite IS the + // persistence. assert!( !proj.join(".socket/manifest.json").exists(), "get --mode hosted must NOT write the manifest" @@ -675,7 +675,10 @@ fn fresh_checkout_cargo_fetch(fx: &RedirectFixture) -> (PathBuf, PathBuf, Output std::fs::copy(fx.proj.join("Cargo.lock"), fresh.join("Cargo.lock")).unwrap(); copy_dir_recursive(&fx.proj.join(".cargo"), &fresh.join(".cargo")); copy_dir_recursive(&fx.proj.join("src"), &fresh.join("src")); - copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + // v5 hosted mode writes nothing under `.socket/`; carry it when present. + if fx.proj.join(".socket").is_dir() { + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + } let fresh_home = fx.tmp.path().join("fresh-cargo-home"); std::fs::create_dir_all(&fresh_home).unwrap(); @@ -750,30 +753,15 @@ impl ManifestlessHosted { let ledger_path = fresh.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL); let run = self.vex_run(&api, &self.fresh_home); - // (1) No manifest (hosted never writes one), ledger kept: the - // ledger's record attests, verified against the extracted - // hosted copy — no API call. + // (1) No manifest (hosted never writes one) and no ledger (v5 + // writes none): the lock's hosted `source` names the patch, the + // record comes from the API, verified against the extracted + // hosted copy. strip_manifest(fresh); - assert!(ledger_path.is_file(), "the redirect ledger travels"); - let out = run_vex(&bin, fresh, &run); - assert_eq!(out.code, Some(0), "(1) ledger-backed:\n{out}"); - assert_attested(out.doc(), &self.purl, UUID, Marker::Redirected, vulns); - assert_eq!( - api.view_requests(UUID), - 0, - "(1) the ledger record needs no API" - ); - let out = run_vex(&bin, fresh, &run.clone().via(VexVia::Apply)); - assert_eq!(out.code, Some(0), "(1) apply --vex:\n{out}"); - assert_eq!( - out.envelope["status"], "noManifest", - "(1) apply --vex:\n{out}" + assert!( + !ledger_path.exists(), + "hosted mode writes no redirect ledger" ); - assert_attested(out.doc(), &self.purl, UUID, Marker::Redirected, vulns); - - // (2) Ledgers deleted: the lock's hosted `source` names the patch; - // the record comes from the API. - let ledger = std::fs::read(&ledger_path).unwrap(); strip_ledgers(fresh); let out = run_vex(&bin, fresh, &run); assert_eq!(out.code, Some(0), "(2) lockfile-only:\n{out}"); @@ -831,10 +819,10 @@ impl ManifestlessHosted { assert_absent(out.doc.as_ref(), &self.purl); assert_eq!(api.request_count(), before, "(3) --offline made a request"); - // (4a) The lock reverted to crates.io (ledger restored, the patched - // hosted copy still extracted, the Cargo.toml pin + registry - // definition left over): the stale ledger never attests. - std::fs::write(&ledger_path, &ledger).unwrap(); + // (4a) The lock reverted to crates.io (the patched hosted copy still + // extracted, the Cargo.toml pin + registry definition left + // over): nothing in the lock wires the patch, so nothing + // attests it. std::fs::write(fresh.join("Cargo.lock"), &self.baseline_lock).unwrap(); for no_verify in [false, true] { let out = run_vex( @@ -845,25 +833,19 @@ impl ManifestlessHosted { ..run.clone() }, ); - assert_eq!(out.code, Some(1), "(4a) no_verify={no_verify}:\n{out}"); - assert_not_attested(&out.envelope, &self.purl, "redirect_unwired"); + assert_ne!(out.code, Some(0), "(4a) no_verify={no_verify}:\n{out}"); assert_absent(out.doc.as_ref(), &self.purl); } // (4b) The full three-file revert, installed for real from - // crates.io, with the ledger kept. + // crates.io. let revert = self.scratch.join("reverted"); let revert_home = self.scratch.join("reverted-cargo-home"); - std::fs::create_dir_all(revert.join(".socket/vendor")).unwrap(); + std::fs::create_dir_all(&revert).unwrap(); std::fs::create_dir_all(&revert_home).unwrap(); std::fs::write(revert.join("Cargo.toml"), &self.baseline_toml).unwrap(); std::fs::write(revert.join("Cargo.lock"), &self.baseline_lock).unwrap(); copy_dir_recursive(&fresh.join("src"), &revert.join("src")); - std::fs::write( - revert.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL), - &ledger, - ) - .unwrap(); let fetch = cargo(&revert, &["fetch", "--locked"], &revert_home); assert!( fetch.status.success(), @@ -889,8 +871,7 @@ impl ManifestlessHosted { ..run.clone() }, ); - assert_eq!(out.code, Some(1), "(4b) no_verify={no_verify}:\n{out}"); - assert_not_attested(&out.envelope, &self.purl, "redirect_unwired"); + assert_ne!(out.code, Some(0), "(4b) no_verify={no_verify}:\n{out}"); assert_absent(out.doc.as_ref(), &self.purl); } } @@ -954,7 +935,9 @@ async fn cargo_hosted_fresh_checkout_fetch_pulls_patched_crate_and_vex_verifies( ); // 5. POST-INSTALL VERIFIED VEX: default verify mode hash-verifies the - // extracted registry sources against the ledger's patch record. + // extracted registry sources against the patch record — fetched + // from the API (v5: no hosted ledger), the mock origin named the + // patch server so the lock's hosted source is recognized. let doc_path = fresh.join("doc.json"); let (code, stdout, stderr) = run_socket( &fresh, @@ -964,6 +947,14 @@ async fn cargo_hosted_fresh_checkout_fetch_pulls_patched_crate_and_vex_verifies( doc_path.to_str().unwrap(), "--product", PRODUCT, + "--patch-server-url", + &fx.server_uri, + "--api-url", + &fx.server_uri, + "--org", + ORG, + "--api-token", + "fake", "--cwd", fresh.to_str().unwrap(), ], @@ -1000,7 +991,7 @@ async fn cargo_hosted_fresh_checkout_fetch_pulls_patched_crate_and_vex_verifies( } /// get-driven twin (v4.0): `get --mode hosted --json --yes` must land -/// the SAME three-file rewrite + ledger as the scan capstone (asserted +/// the SAME three-file rewrite (and no ledger) as the scan capstone (asserted /// inside the shared fixture — parity by construction through /// run_redirect_selected, redirected count via the transactional /// confirmed_cargo_uuids), with NO manifest and NO blobs. Then the diff --git a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs index b5abf52e5..73da2c89e 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs @@ -7,7 +7,8 @@ //! own version's registry, and removing both purls restores every byte. //! * `legacy_config` — an existing legacy `.cargo/config`: the registry //! block lands there, and `remove` restores the file byte-for-byte — -//! also when it lacks a final newline or ends in a blank line. +//! also when it ends in a blank line (one lacking a final newline gets +//! that newline back: v5 keeps no ledger fragment to tell them apart). //! * `crlf` — CRLF `Cargo.toml` + `Cargo.lock`: rewritten with CRLF kept, //! and restored byte-for-byte. //! * `workspace_direct_member` — a virtual workspace whose root pins @@ -27,10 +28,14 @@ //! wiremock sparse registry per patch, `scan --mode hosted`, then a FRESH //! checkout (only the committed files travel) where `cargo fetch --locked` //! and an offline `cargo build --locked` must link each patched-only symbol -//! and a post-install `vex` must attest exactly the patches, and finally +//! and a post-install `vex` must attest exactly the patches (v5: no hosted +//! ledger, so each record comes from the patch API), and finally //! `remove ` for every patch — in apply order and, from the same //! post-scan state, in reverse — which must leave the project -//! byte-identical to its pre-scan state. +//! byte-identical to its pre-scan state. v5 `remove` restores each hosted +//! pin's crates.io entry, re-resolving the checksum from the sparse index: +//! a wiremock mirror of the pristine checksums (`SOCKET_CRATES_INDEX`), with +//! the mock origin named the patch server. //! //! `SOCKET_PATCH_CARGO_E2E_LOCK_VERSION` / `_TOOLCHAIN` (see //! `cargo_e2e_matrix`) re-encode the baseline lock, so a v1 lock's full-id @@ -107,6 +112,16 @@ fn binary() -> PathBuf { } fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { + run_socket_env(cwd, args, cargo_home, &[]) +} + +/// [`run_socket`] with extra env applied after the scrub. +fn run_socket_env( + cwd: &Path, + args: &[&str], + cargo_home: &Path, + env: &[(&str, &str)], +) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { @@ -116,6 +131,9 @@ fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, Str } cmd.env("SOCKET_NO_CONFIG", "1"); cmd.env("CARGO_HOME", cargo_home); + for (k, v) in env { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -153,6 +171,40 @@ fn sparse_index_rel(name: &str) -> String { } } +/// A crates.io sparse-index mirror of the PRISTINE lock's checksums for +/// every patched crate — what the v5 upstream restore reads to put a hosted +/// `Cargo.lock` entry back on crates.io. +async fn mount_crates_index_mirror(pristine_lock: &str, patches: &[Patch]) -> MockServer { + let server = MockServer::start().await; + let lock = pristine_lock.replace("\r\n", "\n"); + let mut rows: BTreeMap> = BTreeMap::new(); + for pkg in cargo_e2e_matrix::parse_lock(&lock) { + if !patches.iter().any(|p| p.name == pkg.name) { + continue; + } + let cksum = pkg + .checksum + .unwrap_or_else(|| panic!("{} {} has no checksum", pkg.name, pkg.version)); + rows.entry(pkg.name.clone()).or_default().push( + serde_json::json!({ + "name": pkg.name, "vers": pkg.version, "deps": [], "cksum": cksum, + "features": {}, "yanked": false, + }) + .to_string(), + ); + } + for (name, lines) in rows { + Mock::given(wiremock::matchers::path(format!( + "/{}", + sparse_index_rel(&name) + ))) + .respond_with(ResponseTemplate::new(200).set_body_string(lines.join("\n"))) + .mount(&server) + .await; + } + server +} + fn build_crate(stage: &Path, crate_dir: &Path, leaf: &str, patched: &[u8]) -> Vec { let pkg = stage.join(leaf); copy_tree(crate_dir, &pkg); @@ -579,6 +631,13 @@ async fn run_shape(shape: Shape) -> Option<()> { "pkg:cargo/consumer@0.1.0", "--patch-server-url", &uri, + // No hosted ledger (v5): the records come from the patch API. + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "fake", "--cwd", &fresh_s, ], @@ -605,9 +664,25 @@ async fn run_shape(shape: Shape) -> Option<()> { expected.sort(); assert_eq!(attested, expected, "{}: attested purls: {doc}", shape.tag); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "{}: hosted mode writes no redirect ledger", + shape.tag + ); + // Rollback: removing every purl restores the pre-scan project exactly — // in apply order AND in reverse (a v1 lock's shared dependent blocks // once made the first-applied purl unremovable before its sibling). + // v5: each removal restores the pin's crates.io entry from the index + // mirror; the mock origin is named the patch server so the pins are + // found without a ledger. + let pristine_lock = String::from_utf8(before["Cargo.lock"].clone()).unwrap(); + let index = mount_crates_index_mirror(&pristine_lock, &shape.patches).await; + let index_uri = index.uri(); + let unwind_env = [ + ("SOCKET_CRATES_INDEX", index_uri.as_str()), + ("SOCKET_PATCH_SERVER_URL", uri.as_str()), + ]; let post_scan = tmp.path().join("post-scan"); copy_tree(&proj, &post_scan); let mut orders = vec![shape.patches.clone()]; @@ -621,7 +696,7 @@ async fn run_shape(shape: Shape) -> Option<()> { } for patch in order { let purl = patch.purl(); - let (code, stdout, err) = run_socket( + let (code, stdout, err) = run_socket_env( &proj, &[ "remove", @@ -633,6 +708,7 @@ async fn run_shape(shape: Shape) -> Option<()> { "--no-telemetry", ], &home, + &unwind_env, ); assert_eq!( code, 0, @@ -642,11 +718,23 @@ async fn run_shape(shape: Shape) -> Option<()> { } let after = snapshot(&proj); for (rel, bytes) in &before { + // v5 keeps no ledger fragment, and the rewriter separates its + // appended registry block with the same bytes whether or not the + // original file ended in a newline — so an UNTERMINATED file can + // only come back with its final newline (every other byte exact). + let want = match after.get(rel) { + Some(got) + if !bytes.ends_with(b"\n") && *got == [bytes.as_slice(), b"\n"].concat() => + { + got.clone() + } + _ => bytes.clone(), + }; assert_eq!( after .get(rel) .map(|b| String::from_utf8_lossy(b).into_owned()), - Some(String::from_utf8_lossy(bytes).into_owned()), + Some(String::from_utf8_lossy(&want).into_owned()), "{} (removal order {n}): {rel} not restored byte-for-byte by remove", shape.tag ); @@ -753,9 +841,11 @@ async fn cargo_hosted_legacy_config_is_restored_byte_for_byte() { let _ = run_shape(shape).await; } -/// Bug H, exactly: a config without a final newline, and one ending in a -/// blank line, both come back byte-for-byte (the appended block's removal -/// once normalized the trailing newline run). +/// Bug H: a config ending in a blank line comes back byte-for-byte (the +/// appended block's removal once normalized the trailing newline run). A +/// config without a final newline comes back with every byte but that +/// missing newline: v5 keeps no ledger fragment to tell it apart from a +/// terminated file, since the rewriter's separator is the same for both. #[tokio::test(flavor = "multi_thread")] async fn cargo_hosted_config_trailing_bytes_are_restored() { for (tag, rel, config) in [ diff --git a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs index 563315fc3..c96762a25 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs @@ -17,8 +17,9 @@ //! the discovery / reference / view API mocks. //! 3. `scan --redirect --json --vex …` (or its `get --mode hosted` //! twin): composer.lock's psr/log `dist` now points at the wiremock -//! archive with its sha1, the redirect ledger embeds the record, no -//! manifest is written, and the in-run VEX is `(redirected)`. +//! archive with its sha1, NO redirect ledger and no manifest is written +//! (v5: the lock is the hosted state), and the in-run VEX is +//! `(redirected)`. //! 4. FRESH-CHECKOUT PROOF: only composer.json + composer.lock + `.socket/` //! travel; a cold-home/cache `composer install` downloads the archive //! from the hosted patch server — the installed file is byte-identical @@ -29,17 +30,22 @@ //! silently install the pristine upstream commit instead.) //! 5. MANIFEST-LESS VEX on the fresh checkout (the hosted flow never wrote //! a manifest; asserted), against a mock patch API: -//! * ledger kept → standalone `vex` attests `(redirected)` with the -//! installed tree hash-verified, as does embedded `apply --vex`; a -//! tampered installed file is `hash_mismatch`; -//! * ledger deleted → attests from the composer.lock dist (the hosted -//! origin named by `--patch-server-url`) + the API record; without -//! that flag a loopback origin is not Socket's and nothing is -//! discovered; -//! * `--offline`, no ledger → `record_unavailable`, zero requests; -//! * composer.lock reverted to the registry dist with the ledger left -//! behind + a REAL re-install (pristine bytes) → `redirect_unwired`, +//! * standalone `vex` attests `(redirected)` from the composer.lock +//! dist (the hosted origin named by `--patch-server-url`) + the API +//! record, with the installed tree hash-verified, as does embedded +//! `apply --vex`; a tampered installed file is `hash_mismatch`; +//! without `--patch-server-url` a loopback origin is not Socket's and +//! nothing is discovered; +//! * `--offline` → `record_unavailable` (hosted mode keeps no local +//! record), zero requests; +//! * composer.lock reverted to the registry dist + a REAL re-install +//! (pristine bytes) → nothing names the patch any more, //! `--no-verify` and `--offline` included. +//! 6. ROLLBACK of the original project restores the upstream dist + +//! source from the REAL packagist metadata: byte-identical to the +//! registry lock on composer 2 (packagist-resolved); composer 1's +//! inline package repository is not packagist, so the pin is refused +//! with the `git checkout -- composer.lock` remedy and nothing moves. //! //! Skips (println) when composer is missing or the fixture install cannot //! reach its registry — unless `SOCKET_PATCH_COMPOSER_E2E_REQUIRED` is set, @@ -65,7 +71,7 @@ mod vex_e2e_common; use composer_e2e_common::{composer, composer_major, fresh_checkout, setup_psr_log_project}; use vex_e2e_common::{ assert_absent, assert_attested, assert_not_attested, binary, git_sha256, patch_view, run_vex, - strip_ledgers, strip_manifest, Marker, PatchApi, VexOutcome, VexRun, VexVia, + strip_manifest, Marker, PatchApi, VexOutcome, VexRun, VexVia, }; const SUITE: &str = "e2e_redirect_composer_build"; @@ -91,6 +97,8 @@ enum RedirectCli { struct Fixture { tmp: tempfile::TempDir, + /// Composer major on PATH (1 resolves from an inline repository). + major: u32, proj: PathBuf, purl: String, orig: Vec, @@ -379,11 +387,9 @@ async fn redirected_project( entry.get("source").is_none(), "the redirected entry must not keep a source fallback: {entry}" ); - let ledger = - std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).expect("ledger"); assert!( - ledger.contains(&purl) && ledger.contains(GHSA), - "the ledger embeds the record: {ledger}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode must not write the redirect ledger" ); assert!( !proj.join(".socket/manifest.json").exists(), @@ -398,6 +404,7 @@ async fn redirected_project( Some(Fixture { tmp, + major, proj, purl, orig, @@ -441,35 +448,48 @@ fn assert_manifestless_hosted_vex(fx: &Fixture, fresh: &Path, tag: &str) { let vex_in = |dir: &Path, run: VexRun| -> VexOutcome { run_vex(&binary(), dir, &run) }; let installed = fresh.join("vendor/psr/log").join(FILE_KEY); - // (1) the hosted flow never wrote a manifest; ledger kept. + // (1) the hosted flow never wrote a manifest nor a ledger: the lock's + // hosted dist + the API record. assert!( !fresh.join(".socket/manifest.json").exists(), "[{tag}] hosted checkout has no manifest" ); + assert!( + !fresh.join(".socket/vendor/redirect-state.json").exists(), + "[{tag}] hosted checkout has no redirect ledger" + ); strip_manifest(fresh); + let fetched = api.view_requests(UUID); let out = vex_in(fresh, hosted(VexRun::online(&api))); - assert_eq!(out.code, Some(0), "[{tag}] ledger kept:\n{out}"); + assert_eq!(out.code, Some(0), "[{tag}] lock + API:\n{out}"); assert_attested(out.doc(), purl, UUID, Marker::Redirected, vulns); + assert!( + api.view_requests(UUID) > fetched, + "[{tag}] the record came from the API: {:?}", + api.requests() + ); let out = vex_in(fresh, hosted(VexRun::online(&api)).via(VexVia::Apply)); assert_eq!(out.code, Some(0), "[{tag}] apply --vex:\n{out}"); assert_eq!(out.envelope["status"], "noManifest", "[{tag}]:\n{out}"); assert_eq!(out.envelope["vex"]["statements"], 1, "[{tag}]:\n{out}"); assert_attested(out.doc(), purl, UUID, Marker::Redirected, vulns); // The installed tree is hash-verified, not just the lock: a tampered - // installed file un-attests even with the ledger and wiring intact. + // installed file un-attests even with the wiring intact. std::fs::write(&installed, b" fetched, - "[{tag}] the record came from the API: {:?}", - api.requests() - ); - let out = vex_in(fresh, hosted(VexRun::online(&api)).via(VexVia::Apply)); - assert_eq!(out.code, Some(0), "[{tag}] ledger-less apply --vex:\n{out}"); - assert_attested(out.doc(), purl, UUID, Marker::Redirected, vulns); // Without `--patch-server-url` the loopback origin is not Socket's: no // reference, no fetch (`manifest_not_found`, exit 2). let seen = api.request_count(); @@ -549,7 +553,7 @@ fn assert_manifestless_hosted_vex(fx: &Fixture, fresh: &Path, tag: &str) { assert_eq!(out.envelope["error"]["code"], "manifest_not_found", "{out}"); assert_eq!(api.request_count(), seen, "[{tag}] nothing fetched"); - // (3) --offline, no ledger: record_unavailable, zero requests. + // (3) --offline: no local record → record_unavailable, zero requests. for no_verify in [false, true] { let out = vex_in( fresh, @@ -598,6 +602,58 @@ async fn full_chain(tag: &str, cli: RedirectCli) { "installed.json names {DEP}" ); tokio::task::block_in_place(|| assert_manifestless_hosted_vex(&fx, &fresh, tag)); + tokio::task::block_in_place(|| assert_rollback_restores_upstream(&fx, tag)); +} + +/// Step 6: `rollback` of the original (still hosted) project. The pin is +/// discovered from composer.lock on the `--patch-server-url` origin and +/// restored from the REAL packagist metadata (reachable wherever the +/// fixture install was). +fn assert_rollback_restores_upstream(fx: &Fixture, tag: &str) { + let origin = fx.server.uri(); + let before = std::fs::read(fx.proj.join("composer.lock")).unwrap(); + let (code, stdout, stderr) = run_socket( + &fx.proj, + &[ + "rollback", + "--json", + "--cwd", + fx.proj.to_str().unwrap(), + "--patch-server-url", + &origin, + ], + ); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("[{tag}] rollback --json is not JSON ({e}):\n{stdout}\n{stderr}") + }); + let after = std::fs::read(fx.proj.join("composer.lock")).unwrap(); + if fx.major >= 2 { + assert_eq!(code, 0, "[{tag}] rollback: {env}\n{stderr}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([fx.purl]), + "[{tag}] {env}" + ); + assert_eq!( + String::from_utf8_lossy(&after), + String::from_utf8_lossy(&fx.registry_lock), + "[{tag}] rollback restores composer.lock byte-for-byte from packagist" + ); + } else { + // Composer 1 resolved from an inline `package` repository: not + // packagist, so the restore refuses rather than guess. + assert_eq!(code, 1, "[{tag}] rollback: {env}\n{stderr}"); + assert_eq!(env["hosted"]["failed"][0]["purl"], fx.purl, "[{tag}] {env}"); + let why = env["hosted"]["failed"][0]["error"] + .as_str() + .unwrap_or_default(); + assert!( + why.contains("git checkout -- composer.lock"), + "[{tag}] the refusal names the remedy: {env}" + ); + assert_eq!(after, before, "[{tag}] a refused pin changes nothing"); + } + assert!(!fx.proj.join(".socket/vendor/redirect-state.json").exists()); } // multi_thread: the CLI/composer subprocesses block a worker thread while diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index 95283d246..5ba18f3e6 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -28,17 +28,18 @@ //! project-local `vendor/bundle` (no rubygems.org, no network beyond //! loopback). //! 2. `scan --mode hosted --json --vex …` (the real binary): the Gemfile -//! gains the `source "" do … end` block, the ledger embeds -//! the patch record, the in-run VEX is the unverified `(redirected)` -//! attestation. +//! gains the `source "" do … end` block, NO redirect ledger +//! is written (v5: the manifest pair is the hosted state), the in-run +//! VEX is the unverified `(redirected)` attestation. //! 3. FRESH-CHECKOUT PROOF: only the committable files travel; an UNFROZEN //! `bundle install` (the flow the rewriter's `redirect_gem_frozen_install` //! warning prescribes) resolves the patched gem from the mock patch //! registry: installed bytes byte-match the patch blob, the runtime dep //! installs BECAUSE the registry `/info` declares it, and a require //! probe loads the patched code. -//! 4. POST-INSTALL VERIFIED VEX: `socket-patch vex` hash-verifies the -//! installed tree against the ledger record. +//! 4. POST-INSTALL VERIFIED VEX: `socket-patch vex` discovers the pin from +//! the lock (`--patch-server-url` names the mock origin), fetches the +//! record from the patch API and hash-verifies the installed tree. //! //! The `gems.rb` twin drives the same chain through bundler's modern //! `gems.rb`/`gems.locked` spelling (which bundler prefers over `Gemfile` @@ -47,7 +48,7 @@ //! The `get --mode hosted` twin (v4.0) drives the SAME fixture //! through get's per-advisory selector instead of scan — same hosted engine //! by construction (CLI_CONTRACT.md "get --mode and installed narrowing"): -//! identical Gemfile/lock rewrite + ledger, get's envelope (nested +//! identical Gemfile/lock rewrite (no ledger), get's envelope (nested //! `redirect`, no `downloaded`/`applied`), NO manifest, NO blobs, no --vex. //! //! The deps red-arm serves an `/info` shaped like production's HISTORICAL @@ -72,11 +73,10 @@ //! //! MANIFEST-LESS VEX (every installing arm, `manifestless_vex_matrix`): on //! the fresh checkout the real bundler installed — hosted never writes a -//! `.socket/manifest.json` — `vex` attests `(redirected)` from the lock + -//! ledger; with both ledgers deleted it still attests from the lockfile -//! wiring + the patch API (and so does the embedded `apply --vex`); -//! `--offline` without ledgers is `record_unavailable` with zero requests; -//! the pair reverted to its registry version is `redirect_unwired` even +//! `.socket/manifest.json` nor a ledger — `vex` attests `(redirected)` from +//! the lockfile wiring + the patch API (and so does the embedded +//! `apply --vex`); `--offline` is `record_unavailable` with zero requests; +//! the pair reverted to its registry version names the patch nowhere, even //! under `--no-verify`. The main arm also proves the lock-only revert //! (Gemfile block kept) re-converges on the next unfrozen install. //! @@ -233,7 +233,12 @@ fn md5_hex(bytes: &[u8]) -> String { hexstr } +/// Copy `src` into `dst`; a missing `src` copies nothing (v5 hosted mode may +/// leave no `.socket/` at all). fn copy_dir_recursive(src: &Path, dst: &Path) { + if !src.exists() { + return; + } std::fs::create_dir_all(dst).unwrap(); for entry in std::fs::read_dir(src).unwrap() { let entry = entry.unwrap(); @@ -857,14 +862,14 @@ async fn redirect_scanned_project( assert_eq!(env["vex"]["statements"], 1, "vex block: {env}"); assert_eq!( env["vex"]["verified"], false, - "in-run hosted VEX is attested from the ledger, not hash-verified: {env}" + "in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}" ); } Driver::GetUuid => { // get's hosted envelope (CLI_CONTRACT.md "get --mode and // installed narrowing"): `found` counts the resolved patch; // `downloaded`/`applied` are ABSENT — nothing lands in - // `.socket/`, the redirect ledger IS the persistence — and no + // `.socket/`, the lockfile IS the persistence — and no // `vex` key (get has no --vex). assert_eq!(env["found"], 1, "envelope: {env}"); assert!( @@ -896,12 +901,8 @@ async fn redirect_scanned_project( ); } - // Ledger embeds the patch record so a post-install `vex` can verify. - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA), - "redirect ledger must embed the patch record + vulnerability: {ledger}" - ); + // v5: hosted mode writes no ledger — the manifest pair is the state. + assert_no_redirect_ledger(&proj); Some(RedirectFixture { tmp, @@ -917,6 +918,14 @@ async fn redirect_scanned_project( }) } +/// v5 hosted mode never writes `.socket/vendor/redirect-state.json`. +fn assert_no_redirect_ledger(proj: &Path) { + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode must not write the redirect ledger" + ); +} + /// New dir named `name` holding ONLY what a git checkout would carry — the /// manifest pair, `.socket/`, `.bundle/` — with a cold per-dir bundler home. fn stage_fresh_checkout(fx: &RedirectFixture, name: &str) -> PathBuf { @@ -1057,22 +1066,22 @@ async fn view_requests(fx: &RedirectFixture) -> usize { } /// Manifest-less VEX over a fresh checkout the REAL bundler just installed -/// the patched gem into (`fresh`, whose ledger came along with the commit): +/// the patched gem into (`fresh`): /// -/// 1. no `.socket/manifest.json` (hosted never writes one): `vex` attests -/// `(redirected)` from the lockfile wiring + the ledger record, hash- -/// verified against the installed tree; -/// 2. both ledgers deleted too: still attested — discovery reads the -/// converged lock's patch-registry `GEM` remote and the record comes -/// from the patch API; the embedded `apply --vex` agrees; -/// 3. `--offline` with no ledgers: `record_unavailable`, ZERO requests; -/// 4. the manifest pair reverted to its registry version (ledgers and the -/// installed patched tree kept): `redirect_unwired`, with and without -/// `--no-verify`, online and offline. +/// 1. no `.socket/manifest.json` and no ledger (hosted writes neither): +/// `vex` attests `(redirected)` — discovery reads the converged lock's +/// patch-registry `GEM` remote and the record comes from the patch +/// API, hash-verified against the installed tree; the embedded +/// `apply --vex` agrees; +/// 2. `--offline`: no local record → `record_unavailable`, ZERO requests; +/// 3. the manifest pair reverted to its registry version (the installed +/// patched tree kept): nothing names the patch any more +/// (`manifest_not_found`), with and without `--no-verify`, online and +/// offline. async fn manifestless_vex_matrix(fx: &RedirectFixture, fresh: &Path) { use vex_e2e_common::{ - assert_absent, assert_attested, assert_not_attested, run_vex, strip_ledgers, - strip_manifest, Marker, VexVia, + assert_absent, assert_attested, assert_not_attested, run_vex, strip_manifest, Marker, + VexVia, }; let bin = binary(); let lock = std::fs::read_to_string(fresh.join(fx.lock_name)).unwrap(); @@ -1082,15 +1091,11 @@ async fn manifestless_vex_matrix(fx: &RedirectFixture, fresh: &Path) { fx.lock_name, fx.bundler.version ); - let ledgers = fresh.join(".socket/vendor"); - let saved = fx.tmp.path().join(format!( - "ledgers-{}", - fresh.file_name().unwrap().to_string_lossy() - )); - copy_dir_recursive(&ledgers, &saved); + assert_no_redirect_ledger(fresh); - // 1. manifest-less (the ledger rides along). + // 1. manifest-less, ledger-less: lockfile discovery + the patch API. strip_manifest(fresh); + let views = view_requests(fx).await; let out = run_vex(&bin, fresh, &vex_run(fx)); assert_eq!( out.code, @@ -1100,24 +1105,12 @@ async fn manifestless_vex_matrix(fx: &RedirectFixture, fresh: &Path) { ); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); assert_eq!(out.envelope["summary"]["verified"], 1, "{out}"); - - // 2. no ledgers: lockfile discovery + the patch API. - strip_ledgers(fresh); - let views = view_requests(fx).await; - let out = run_vex(&bin, fresh, &vex_run(fx)); - assert_eq!( - out.code, - Some(0), - "ledger-less vex: {out}\n--- {}\n{lock}", - fx.lock_name - ); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); assert!( view_requests(fx).await > views, "the record must come from the patch API" ); let out = run_vex(&bin, fresh, &vex_run(fx).via(VexVia::Apply)); - assert_eq!(out.code, Some(0), "ledger-less apply --vex: {out}"); + assert_eq!(out.code, Some(0), "manifest-less apply --vex: {out}"); assert_eq!(out.envelope["status"], "noManifest", "{out}"); assert_eq!(out.envelope["vex"]["statements"], 1, "{out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); @@ -1126,18 +1119,17 @@ async fn manifestless_vex_matrix(fx: &RedirectFixture, fresh: &Path) { "vex / apply --vex must never write the manifest" ); - // 3. offline, no ledgers: nothing to build a statement from, no network. + // 2. offline: no local record to build a statement from, no network. let before = request_count(fx).await; let mut offline = vex_run(fx); offline.offline = true; let out = run_vex(&bin, fresh, &offline); - assert_eq!(out.code, Some(1), "offline ledger-less vex: {out}"); + assert_eq!(out.code, Some(1), "offline vex: {out}"); assert_not_attested(&out.envelope, PURL, "record_unavailable"); assert_eq!(request_count(fx).await, before, "--offline made requests"); - // 4. reverted to the registry pair; ledgers (and the patched install) - // kept. - copy_dir_recursive(&saved, &ledgers); + // 3. reverted to the registry pair (the patched install kept): no lock + // names the patch, and hosted mode keeps no ledger to remember it. std::fs::write(fresh.join(fx.gemfile_name), &fx.pristine_gemfile).unwrap(); std::fs::write(fresh.join(fx.lock_name), &fx.pristine_lock).unwrap(); for (offline, no_verify) in [(false, false), (false, true), (true, false), (true, true)] { @@ -1146,8 +1138,11 @@ async fn manifestless_vex_matrix(fx: &RedirectFixture, fresh: &Path) { run.no_verify = no_verify; let out = run_vex(&bin, fresh, &run); let cell = format!("reverted offline={offline} no_verify={no_verify}"); - assert_eq!(out.code, Some(1), "{cell}: {out}"); - assert_not_attested(&out.envelope, PURL, "redirect_unwired"); + assert_eq!(out.code, Some(2), "{cell}: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{cell}: {out}" + ); assert_absent(out.doc.as_ref(), PURL); } } @@ -1155,13 +1150,12 @@ async fn manifestless_vex_matrix(fx: &RedirectFixture, fresh: &Path) { /// The mixed pair the bundler < 2.6 rewriter leaves (and a lock-only /// `git checkout`): the Gemfile still carries the patch-registry source /// block, the lock resolves the gem from upstream. Bundler re-resolves from -/// the Gemfile — proven here with the REAL bundler — so the ledger keeps the -/// patch live (`Discovery::redirect_record_live` step 0 in socket-patch-core -/// vex/discover), while a -/// ledger-less checkout has nothing discovery reads until that install -/// re-converges the lock, after which it attests from the lock alone. +/// the Gemfile — proven here with the REAL bundler. v5 keeps no ledger, so +/// the mixed checkout has nothing discovery reads (lockfile pins only) until +/// that install re-converges the lock, after which it attests from the lock +/// alone. async fn lock_only_revert_reconverges(fx: &RedirectFixture) { - use vex_e2e_common::{assert_attested, run_vex, strip_ledgers, Marker}; + use vex_e2e_common::{assert_attested, run_vex, Marker}; let bin = binary(); let dir = stage_fresh_checkout(fx, "fresh-lock-only-revert"); let install = bundle(&dir, &["install"]); @@ -1171,20 +1165,12 @@ async fn lock_only_revert_reconverges(fx: &RedirectFixture) { String::from_utf8_lossy(&install.stderr) ); std::fs::write(dir.join(fx.lock_name), &fx.pristine_lock).unwrap(); - let out = run_vex(&bin, &dir, &vex_run(fx)); - assert_eq!( - out.code, - Some(0), - "Gemfile-wired, lock reverted, ledger kept: {out}" - ); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); - - strip_ledgers(&dir); + assert_no_redirect_ledger(&dir); let out = run_vex(&bin, &dir, &vex_run(fx)); assert_eq!( out.code, Some(2), - "no ledger and no lockfile reference: nothing to attest: {out}" + "Gemfile-wired, lock reverted: no lockfile reference, nothing to attest: {out}" ); assert_eq!(out.envelope["error"]["code"], "manifest_not_found", "{out}"); @@ -1202,7 +1188,7 @@ async fn lock_only_revert_reconverges(fx: &RedirectFixture) { ); assert_patched_install(fx, &dir); let out = run_vex(&bin, &dir, &vex_run(fx)); - assert_eq!(out.code, Some(0), "re-converged, ledger-less: {out}"); + assert_eq!(out.code, Some(0), "re-converged: {out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); } @@ -1251,8 +1237,10 @@ async fn gem_hosted_fresh_checkout_bundle_install_installs_patched_bytes_and_vex ); // POST-INSTALL VERIFIED VEX: default verify mode hash-verifies the - // installed tree against the ledger's patch record. + // installed tree against the patch API's record (v5: no ledger — the pin + // comes from the lock on the `--patch-server-url` origin). let doc_path = fresh.join("doc.json"); + let server = fx._server.uri(); let (code, stdout, stderr) = run_socket( &fresh, &[ @@ -1263,6 +1251,14 @@ async fn gem_hosted_fresh_checkout_bundle_install_installs_patched_bytes_and_vex PRODUCT, "--cwd", fresh.to_str().unwrap(), + "--patch-server-url", + &server, + "--api-url", + &server, + "--org", + ORG, + "--api-token", + "fake", ], ); assert_eq!( @@ -1292,7 +1288,7 @@ async fn gem_hosted_fresh_checkout_bundle_install_installs_patched_bytes_and_vex /// GET-DRIVEN TWIN of the main capstone: `get --mode hosted` (v4.0, /// the per-advisory selector) must leave the same committable redirect -/// state as `scan --mode hosted` — same Gemfile source block, same ledger, +/// state as `scan --mode hosted` — same Gemfile source block, NO ledger, /// NO manifest, NO blobs — proven the same way against the REAL bundler: a /// fresh checkout of only the committable files resolves the PATCHED gem /// (bytes + runtime dep + require probe) from the mock patch registry. @@ -1313,9 +1309,9 @@ async fn gem_get_uuid_hosted_fresh_checkout_bundle_install() { return; }; - // Persistence parity with `scan --mode hosted`: the redirect ledger is - // the ONLY .socket/ artifact — no manifest, no blobs (the fixture - // already asserted the ledger + the Gemfile source block). + // Persistence parity with `scan --mode hosted`: nothing lands in + // .socket/ — no ledger, no manifest, no blobs (the fixture already + // asserted the missing ledger + the Gemfile source block). assert!( !fx.proj.join(".socket/manifest.json").exists(), "get --mode hosted must NOT write the manifest (parity with scan --mode hosted)" @@ -1458,8 +1454,8 @@ async fn gem_hosted_registry_info_without_deps_breaks_install_like_production() /// FLIPPED CANARY — CHECKSUMS locks (bundler >= 4 default) must come out /// FULLY CONVERGED: patch-registry GEM section holding the dep's spec, -/// ` (= )!` DEPENDENCIES pin, patched CHECKSUMS sha (upstream sha -/// recorded in the ledger for revert). The old mixed-state rewrite (pin only, +/// ` (= )!` DEPENDENCIES pin, patched CHECKSUMS sha (v5 keeps no +/// ledger: a rollback re-resolves the upstream sha from the registry). The old mixed-state rewrite (pin only, /// GEM section left upstream) made the prescribed unfrozen install fail with /// "Bundler found mismatched checksums" (exit 37 — the bundler-4 DEFAULT /// lock, i.e. the mainstream hosted-gem path) and forced a frozen-install @@ -1484,31 +1480,24 @@ async fn gem_hosted_checksums_lock_converges_and_installs_frozen_and_unfrozen() return; }; - // The rewrite half: the ledger's CHECKSUMS edit must carry the UPSTREAM - // sha as `original` (the only revert path back to the registry line). - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(fx.proj.join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - let edits = ledger["edits"].as_array().expect("ledger edits"); - let edit = edits - .iter() - .find(|e| e["kind"] == "redirect_gemfile_lock_checksum") - .expect("CHECKSUMS pin edit recorded in the ledger"); - assert_eq!(edit["path"], "Gemfile.lock", "edit path: {edit}"); - let original = edit["original"].as_str().expect("original recorded"); - assert!( - original.starts_with(&format!("{DEP} ({DEP_VERSION}) sha256=")), - "original must be the pre-edit registry line: {original}" - ); + // The rewrite half: the registry lock's upstream CHECKSUMS line must + // actually have been replaced (else the pin is vacuous). No ledger + // records it (v5). + let pristine_lock = String::from_utf8_lossy(&fx.pristine_lock).into_owned(); + let original = pristine_lock + .lines() + .map(str::trim) + .find(|l| l.starts_with(&format!("{DEP} ({DEP_VERSION}) sha256="))) + .unwrap_or_else(|| panic!("the registry lock pins an upstream sha:\n{pristine_lock}")) + .to_string(); let lock = std::fs::read_to_string(fx.proj.join("Gemfile.lock")).unwrap(); assert!( - !lock.contains(original), + !lock.contains(&original), "the upstream sha line must actually have been replaced (else the pin is vacuous)" ); + assert_no_redirect_ledger(&fx.proj); - // The converged half: GEM section attribution + bundler's own `!` pin, - // with the move and the pin recorded in the ledger. + // The converged half: GEM section attribution + bundler's own `!` pin. assert!( lock.contains(&format!( "GEM\n remote: {}\n specs:\n {DEP} ({DEP_VERSION})", @@ -1520,12 +1509,6 @@ async fn gem_hosted_checksums_lock_converges_and_installs_frozen_and_unfrozen() lock.contains(&format!(" {DEP} (= {DEP_VERSION})!")), "DEPENDENCIES must carry the source-pinned entry:\n{lock}" ); - assert!( - edits - .iter() - .any(|e| e["kind"] == "redirect_gemfile_lock_gem_source"), - "the GEM-section move must be a ledger edit: {edits:?}" - ); // FROZEN fresh checkout: the converged pair needs no unfrozen two-step — // bundler's deployment contract accepts it as-is and the lock stays @@ -1568,8 +1551,7 @@ async fn gem_hosted_checksums_lock_converges_and_installs_frozen_and_unfrozen() /// index URL per request, so a periodic/CI re-scan sees a NEW index URL for /// the SAME redirect. The re-scan must (1) be byte-idempotent under the same /// grant, (2) refresh the source block's URL IN PLACE under a rotated grant — -/// exactly one Socket source block, a `redirect_gemfile_source_url` ledger -/// edit, no stale token anywhere — and (3) leave a pair a fresh checkout +/// exactly one Socket source block, no stale token anywhere, no ledger — and (3) leave a pair a fresh checkout /// installs the patched bytes from — never wrap the old block's indented /// gem line in a new NESTED source block (+1 nesting per re-scan) with the /// stale token URL still live. @@ -1594,19 +1576,9 @@ async fn gem_hosted_rotated_grant_rescan_refreshes_source_block_and_installs() { let index_url_b = format!("{api}/patch-registry/gem/{TOKEN_B}/{UUID}/"); let gemfile_after_run1 = std::fs::read_to_string(fx.proj.join("Gemfile")) .expect("read Gemfile after initial hosted scan"); - let ledger_edits = |proj: &Path| -> Vec { - serde_json::from_str::( - &std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")) - .expect("read redirect ledger"), - ) - .expect("ledger is JSON")["edits"] - .as_array() - .expect("ledger edits array") - .clone() - }; - let edits_after_run1 = ledger_edits(&fx.proj).len(); + let lock_after_run1 = std::fs::read_to_string(fx.proj.join(fx.lock_name)).unwrap(); - // Re-scan 2, SAME grant: byte-idempotent, no ledger growth. + // Re-scan 2, SAME grant: byte-idempotent (Gemfile AND lock), no ledger. let (code, stdout, stderr) = run_hosted_scan(&fx.proj, &api); assert_eq!( code, 0, @@ -1621,10 +1593,11 @@ async fn gem_hosted_rotated_grant_rescan_refreshes_source_block_and_installs() { "same-grant re-scan must leave the Gemfile byte-identical" ); assert_eq!( - ledger_edits(&fx.proj).len(), - edits_after_run1, - "same-grant re-scan must not grow the ledger" + std::fs::read_to_string(fx.proj.join(fx.lock_name)).unwrap(), + lock_after_run1, + "same-grant re-scan must leave the lock byte-identical" ); + assert_no_redirect_ledger(&fx.proj); // Re-scan 3, ROTATED grant (token B, same uuid): refresh in place. let (code, stdout, stderr) = run_hosted_scan(&fx.proj, &api); @@ -1651,20 +1624,11 @@ async fn gem_hosted_rotated_grant_rescan_refreshes_source_block_and_installs() { !gemfile.contains(TOKEN), "the stale grant token must be gone from the Gemfile:\n{gemfile}" ); - let refresh = ledger_edits(&fx.proj) - .into_iter() - .find(|e| e["kind"] == "redirect_gemfile_source_url") - .expect("rotation must be recorded as a redirect_gemfile_source_url ledger edit"); - assert_eq!( - refresh["original"], - serde_json::Value::String(fx.index_url.clone()), - "refresh edit original: {refresh}" - ); - assert_eq!( - refresh["new"], - serde_json::Value::String(index_url_b), - "refresh edit new: {refresh}" + assert!( + !gemfile.contains(&fx.index_url), + "the grant-A index URL must be refreshed away:\n{gemfile}" ); + assert_no_redirect_ledger(&fx.proj); // Fresh checkout of the rotated pair: the prescribed unfrozen install // resolves the patched gem from the rotated registry path. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index b3bbb5866..34e641ec6 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs @@ -15,19 +15,20 @@ //! 3. FRESH checkout (no materialization) → quiet. //! 4. TWO gem homes both stale → one warning per home, each naming its //! own paths. -//! 5. RE-FIRE: a re-scan whose /patches/view fetch fails transiently -//! still warns, judged from the redirect ledger's persisted record. -//! 6. Same-run `--vex`: the stale purl is excluded from the ledger-based -//! attestation — the envelope must never attest a CVE its own warning -//! says is live. +//! 5. RE-SCAN with a failing record fetch: v5 hosted mode keeps no ledger, +//! so a re-scan whose /patches/view fetch fails has no persisted record +//! to judge from — it surfaces `record_fetch_failed` (the VEX-omission +//! detail) and leaves the committed wiring untouched. +//! 6. Same-run `--vex`: the stale purl is excluded from the attestation +//! (built from this run's fetched records) — the envelope must never +//! attest a CVE its own warning says is live. //! 7. Manifest-less standalone `vex` over the same committed state follows -//! the installed tree: stale → `not_applied` (ledger kept) / nothing -//! discoverable (ledgers deleted, lock not yet converged); after the -//! prescribed re-install (the lock bundler then writes — separate -//! patch-registry `GEM` section on bundler >= 2.2, the merged -//! multi-remote section on <= 2.1) it attests from the lock + patch API -//! with no ledger; offline → `record_unavailable`; tampered → -//! `hash_mismatch`; reverted pair → `redirect_unwired`. +//! the installed tree: stale → nothing discoverable (no ledger, lock not +//! yet converged); after the prescribed re-install (the lock bundler +//! then writes — separate patch-registry `GEM` section on bundler >= +//! 2.2, the merged multi-remote section on <= 2.1) it attests from the +//! lock + patch API; offline → `record_unavailable`; tampered → +//! `hash_mismatch`; reverted pair → not attested (nothing wires it). use std::path::{Path, PathBuf}; @@ -417,12 +418,13 @@ async fn gem_hosted_redirect_warns_once_per_stale_gem_home() { assert!(b.contains(&cache_b.display().to_string()), "{b}"); } -/// RE-FIRE guarantee: scan 1 warns and persists the patch record in the -/// redirect ledger; scan 2's /patches/view fetch fails transiently (500) — -/// the warning must STILL fire, judged from the ledger's persisted record, -/// alongside the record_fetch_failed warning for the fetch itself. +/// v5 hosted mode keeps no ledger: scan 1 warns (from its fetched record) +/// and persists nothing but the Gemfile/lock edits; scan 2's /patches/view +/// fetch fails transiently (500), so it surfaces `record_fetch_failed` with +/// the VEX-omission detail, exits 0, and leaves the committed wiring +/// byte-identical (the re-run is idempotent) — still with no ledger. #[tokio::test(flavor = "multi_thread")] -async fn gem_hosted_stale_warning_refires_when_record_fetch_fails() { +async fn gem_hosted_rescan_with_failing_record_fetch_reports_it_and_keeps_the_wiring() { let server = MockServer::start().await; mount_api(&server, Some(1)).await; // view answers 200 exactly once let tmp = tempfile::tempdir().unwrap(); @@ -431,43 +433,51 @@ async fn gem_hosted_stale_warning_refires_when_record_fetch_fails() { write_manifest_pair(&proj); let (gem_dir, ..) = materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB); - // Scan 1: fresh record, warning fires, ledger persists the record. + // Scan 1: fresh record, warning fires, no ledger persists anything. let (code, stdout, _) = hosted_scan_json(&proj, &server.uri()); assert_eq!(code, 0); let env = common::parse_json_envelope(&stdout); - assert_eq!(stale_warnings(&env).len(), 1, "scan 1 must warn: {env}"); - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - ledger.contains(UUID), - "the ledger must persist the record scan 2 falls back to: {ledger}" - ); + let details = stale_warnings(&env); + assert_eq!(details.len(), 1, "scan 1 must warn: {env}"); + assert!(details[0].contains(&gem_dir.display().to_string()), "{env}"); + let ledger = proj.join(".socket/vendor/redirect-state.json"); + assert!(!ledger.exists(), "hosted mode writes no redirect ledger"); + let gemfile = std::fs::read(proj.join("Gemfile")).unwrap(); + let lock = std::fs::read(proj.join("Gemfile.lock")).unwrap(); - // Scan 2: view 500s → record_fetch_failed, but the stale warning - // re-fires from the ledger record. + // Scan 2: view 500s → record_fetch_failed with the VEX-omission detail. let (code, stdout, _) = hosted_scan_json(&proj, &server.uri()); - assert_eq!(code, 0); + assert_eq!(code, 0, "{stdout}"); let env = common::parse_json_envelope(&stdout); - let codes: Vec<&str> = env["redirect"]["warnings"] + let failed = env["redirect"]["warnings"] .as_array() .expect("warnings") .iter() - .filter_map(|w| w["code"].as_str()) - .collect(); - assert!( - codes.contains(&"record_fetch_failed"), - "the transient fetch failure itself is surfaced: {env}" + .find(|w| w["code"] == "record_fetch_failed") + .unwrap_or_else(|| panic!("the transient fetch failure is surfaced: {env}")); + assert_eq!( + failed["detail"], + format!( + "{PURL} redirected, but its patch record could not be fetched; this run's VEX \ + attestation omits it (`socket-patch vex` fetches it again once the API answers)" + ), + "{env}" ); - let details = stale_warnings(&env); assert_eq!( - details.len(), - 1, - "a flaky record fetch must not retire the stale warning: {env}" + std::fs::read(proj.join("Gemfile")).unwrap(), + gemfile, + "the re-run leaves the Gemfile as the first run wrote it" ); - assert!(details[0].contains(&gem_dir.display().to_string()), "{env}"); + assert_eq!( + std::fs::read(proj.join("Gemfile.lock")).unwrap(), + lock, + "the re-run leaves the lock as the first run wrote it" + ); + assert!(!ledger.exists(), "still no ledger"); } /// Same-run `--vex` consistency: a stale-flagged purl is EXCLUDED from the -/// ledger-based `assume_applied` attestation — the envelope must never +/// run's `assume_applied` attestation — the envelope must never /// attest a CVE its own warning says is live. With the only patch stale, /// verification finds nothing attestable, so the run fails the VEX step /// (the embedded-VEX fail-the-command contract) and no document attests @@ -576,16 +586,14 @@ async fn gem_hosted_manifest_less_vex_follows_the_installed_tree() { 1 ); strip_manifest(&proj); - let ledger = std::fs::read(proj.join(".socket/vendor/redirect-state.json")).unwrap(); - - // Stale, ledger kept: the Gemfile block keeps the claim live, the - // pristine installed tree decides. - let out = run_vex(&bin, &proj, &base); - assert_eq!(out.code, Some(1), "stale install: {out}"); - assert_not_attested(&out.envelope, PURL, "not_applied"); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no redirect ledger" + ); - // Stale, no ledgers: the lock is not converged yet (bundler < 2.6 mixed - // pair) and the Gemfile is not a discovery input — nothing to attest. + // Stale: the lock is not converged yet (bundler < 2.6 mixed pair), the + // Gemfile is not a discovery input, and v5 keeps no ledger claim — + // nothing to attest. strip_ledgers(&proj); let out = run_vex(&bin, &proj, &base); assert_eq!(out.code, Some(2), "nothing discoverable: {out}"); @@ -631,15 +639,15 @@ async fn gem_hosted_manifest_less_vex_follows_the_installed_tree() { assert_not_attested(&out.envelope, PURL, "hash_mismatch"); std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), PATCHED_LIB).unwrap(); - // Reverted pair, ledger (and the patched install) kept. - std::fs::write(proj.join(".socket/vendor/redirect-state.json"), &ledger).unwrap(); + // Reverted pair (the patched install kept): nothing wires the patch any + // more, so nothing attests it — verified or not. std::fs::write(proj.join("Gemfile"), &pristine_gemfile).unwrap(); std::fs::write(proj.join("Gemfile.lock"), &pristine_lock).unwrap(); for no_verify in [false, true] { let mut run = base.clone(); run.no_verify = no_verify; let out = run_vex(&bin, &proj, &run); - assert_eq!(out.code, Some(1), "reverted no_verify={no_verify}: {out}"); - assert_not_attested(&out.envelope, PURL, "redirect_unwired"); + assert_ne!(out.code, Some(0), "reverted no_verify={no_verify}: {out}"); + assert_absent(out.doc.as_ref(), PURL); } } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs index 69c235e48..27013ad65 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs @@ -19,7 +19,8 @@ //! production-shaped `…/patch-registry/maven///maven2` //! path, next to the discovery / reference / view API mocks. //! 3. `scan --mode hosted --json --vex …` (the real binary) rewires the -//! three files, writes the ledger, and attests in-run `(redirected)`. +//! three files (v5: NO redirect ledger — the pom is the hosted state) +//! and attests in-run `(redirected)`. //! 4. FRESH CHECKOUT: only `pom.xml` + `.mvn/` + `.socket/` travel, the //! fixture is purged from the local repository, and Maven resolves //! with a user `settings.xml` mirroring ONLY `socket-patch-` @@ -33,17 +34,18 @@ //! lines ignore the `aether.*` properties — asserted, so a change in //! either direction is noticed). //! 6. MANIFEST-LESS VEX over the fresh checkout (`vex_e2e_common`): -//! * the ledger present, online → the installed suffixed copy -//! hash-verifies, attested `(redirected)`; `--offline` → attested -//! from the ledger's record; -//! * the ledgers deleted, online → attested from the pom wiring + -//! the API record; `--offline` → `record_unavailable`, ZERO -//! requests; +//! * online → attested from the pom wiring + the API record (the +//! installed suffixed copy hash-verifies, `(redirected)`); +//! `--offline` → `record_unavailable`, ZERO requests (hosted mode +//! keeps no local record); //! * embedded `apply --vex` with no manifest attests the same; //! * a tampered installed jar → `hash_mismatch`; -//! * the pom reverted to the registry version (ledgers, `.mvn/` and -//! the installed suffixed copy left behind) → `redirect_unwired`, +//! * the pom reverted to the registry version (`.mvn/` and the +//! installed suffixed copy left behind) → nothing names the patch, //! with and without `--no-verify`. +//! 7. ROLLBACK (`--offline`: Maven restores with no network) returns the +//! original project's pom byte-for-byte to its registry version and +//! drops the `.mvn/` provenance hosted mode wrote. //! //! Gated like the other real-toolchain capstones: `#[ignore]` (network to //! Maven Central for the fixture), toolchain selection and the @@ -323,7 +325,7 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { mount_api(&server, &patched_jar, &sfx_pom, &view); server.serve_repo(&patched_jar, &sfx_pom, None); - // 3. The real writer: three-file rewrite + ledger + in-run VEX. + // 3. The real writer: three-file rewrite + in-run VEX, no ledger. let (code, env, stderr) = socket( &proj, &m2, @@ -367,7 +369,10 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { checksums.contains(&sha256_hex(&patched_jar)) && checksums.contains(&sha256_hex(&sfx_pom)), "trusted checksums pin the jar and the served pom:\n{checksums}" ); - assert!(proj.join(".socket/vendor/redirect-state.json").is_file()); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode must not write the redirect ledger" + ); assert!( !proj.join(".socket/manifest.json").exists(), "hosted mode never writes the manifest" @@ -473,35 +478,8 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { let api = PatchApi::start(vec![(UUID.to_string(), view.clone())]); let run = vex_run(&m2); - // Ledger present, online: the installed suffixed copy hash-verifies. - let out = run_vex( - &binary(), - &fresh, - &VexRun { - proxy_url: Some(api.uri()), - ..run.clone() - }, - ); - assert_eq!(out.code, Some(0), "{out}"); - assert_attested(out.doc(), &purl(), UUID, Marker::Redirected, &vulns()); - // ...and offline from the ledger's embedded record. - let quiet = PatchApi::empty(); - let out = run_vex( - &binary(), - &fresh, - &VexRun { - offline: true, - proxy_url: Some(quiet.uri()), - ..run.clone() - }, - ); - assert_eq!(out.code, Some(0), "{out}"); - assert_attested(out.doc(), &purl(), UUID, Marker::Redirected, &vulns()); - quiet.assert_no_requests(); - - // Ledgers gone: the pom wiring + the API record. - let ledger = std::fs::read(fresh.join(".socket/vendor/redirect-state.json")).unwrap(); - strip_ledgers(&fresh); + // Online: the pom wiring + the API record; the installed suffixed copy + // hash-verifies. let before = api.view_requests(UUID); let out = run_vex( &binary(), @@ -530,7 +508,8 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { assert_eq!(out.code, Some(0), "{out}"); assert_attested(out.doc(), &purl(), UUID, Marker::Redirected, &vulns()); - // Offline, no ledgers: record_unavailable with zero network. + // Offline: no local record (hosted mode keeps none) → record_unavailable + // with zero network. let quiet = PatchApi::empty(); let out = run_vex( &binary(), @@ -574,36 +553,55 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() { assert_not_attested(&out.envelope, &purl(), "hash_mismatch"); std::fs::write(&installed, &patched_jar).unwrap(); - // Reverted to the registry version, ledger + `.mvn/` + the installed - // suffixed copy left behind: dead, with and without --no-verify. + // Reverted to the registry version, `.mvn/` + the installed suffixed + // copy left behind: nothing names the patch any more, with and without + // --no-verify. std::fs::write(fresh.join("pom.xml"), &pristine_pom).unwrap(); - std::fs::write(fresh.join(".socket/vendor/redirect-state.json"), &ledger).unwrap(); for no_verify in [false, true] { - let quiet = PatchApi::empty(); let out = run_vex( &binary(), &fresh, &VexRun { - offline: true, + proxy_url: Some(api.uri()), no_verify, - proxy_url: Some(quiet.uri()), ..run.clone() }, ); - assert_eq!(out.code, Some(1), "reverted no_verify={no_verify}: {out}"); - assert_not_attested(&out.envelope, &purl(), "redirect_unwired"); + assert_eq!(out.code, Some(2), "reverted no_verify={no_verify}: {out}"); + assert_eq!(out.envelope["error"]["code"], "manifest_not_found", "{out}"); assert_absent(out.doc.as_ref(), &purl()); } - // ...and with the ledger gone too there is nothing to attest at all. - strip_ledgers(&fresh); - let out = run_vex( - &binary(), - &fresh, - &VexRun { - proxy_url: Some(api.uri()), - ..run.clone() - }, + + // 7. ROLLBACK of the original project: Maven's restore needs no + // network, so `--offline` returns the pom byte-for-byte to its registry + // version and drops the `.mvn/` provenance. + let (code, env, stderr) = socket( + &proj, + &m2, + &[ + "rollback", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], ); - assert_eq!(out.code, Some(2), "{out}"); - assert_eq!(out.envelope["error"]["code"], "manifest_not_found", "{out}"); + assert_eq!(code, Some(0), "rollback --offline: {env}\n{stderr}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([purl()]), + "rollback restores the hosted pin: {env}" + ); + assert_eq!( + std::fs::read_to_string(proj.join("pom.xml")).unwrap(), + pristine_pom, + "rollback restores the pom byte-for-byte" + ); + for rel in [".mvn/checksums/checksums.sha256", ".mvn/maven.config"] { + assert!( + !proj.join(rel).exists(), + "rollback removes the {rel} hosted mode wrote" + ); + } + assert!(!proj.join(".socket/vendor/redirect-state.json").exists()); } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs index d578076dc..dce12a6c4 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs @@ -2,9 +2,9 @@ //! //! `scan --redirect` never lands patched bytes in the repo: it rewrites the //! lockfile so the patched dependency RESOLVES from Socket's hosted vendored -//! patch (here: a wiremock standing in for patch.socket.dev) and records the -//! patch (file hashes + vulnerabilities) in the redirect ledger. This test -//! proves every link of that chain against the REAL npm: +//! patch (here: a wiremock standing in for patch.socket.dev). v5 keeps no +//! redirect ledger: the lockfile pin IS the hosted state. This test proves +//! every link of that chain against the REAL npm: //! //! 1. `npm install left-pad@1.3.0` into a tempdir project (network used for //! fixture setup only, private cache). @@ -12,29 +12,31 @@ //! comment prepended to `index.js`) and serve it from wiremock, alongside //! the discovery / reference / view API mocks. //! 3. `scan --redirect --json --vex …` (the real binary): the lockfile now -//! pins the wiremock tarball URL + the patched tarball's sha512, the -//! ledger embeds the patch record, and the in-run VEX is the unverified -//! `(redirected)` attestation (`verified: false`). +//! pins the wiremock tarball URL + the patched tarball's sha512, NO +//! `.socket/vendor/redirect-state.json` is written, and the in-run VEX +//! is the unverified `(redirected)` attestation (`verified: false`). //! 4. FRESH-CHECKOUT PROOF: only package.json + package-lock.json + //! `.socket/` travel; `npm ci --cache ` MUST install the patched //! bytes — npm pulls them from the hosted patch server because the //! lockfile says so. //! 5. POST-INSTALL VERIFIED VEX: `socket-patch vex` (default verify mode) -//! hash-verifies the installed tree against the ledger records and emits -//! the `(redirected)` statement. +//! discovers the hosted pin from the lockfile (`--patch-server-url` +//! names the wiremock origin), fetches the record from the patch API, +//! hash-verifies the installed tree and emits the `(redirected)` +//! statement. //! //! The negative twin serves TAMPERED tarball bytes while the lockfile keeps //! the real sha512: the fresh `npm ci` must FAIL with an integrity error — //! the lockfile pin is enforcement, not decoration. //! //! v4.0 adds get-driven twins through the SAME fixture: `get --mode -//! hosted` must land the identical redirect (no manifest, no blobs — the -//! ledger is the persistence), and `get --mode hosted` must narrow a +//! hosted` must land the identical redirect (no manifest, no blobs, no +//! ledger — the lockfile is the persistence), and `get --mode hosted` must narrow a //! two-version fan-out to the installed version BEFORE the grant request. //! //! v5 adds the MANIFEST-LESS VEX tail to every flow that installs -//! (`npm_e2e_common::manifestless_vex_matrix`): with the fresh checkout's -//! ledger present, then deleted (lockfile discovery + a mock patch API), +//! (`npm_e2e_common::manifestless_vex_matrix`): lockfile discovery + a mock +//! patch API, //! then `--offline` (`record_unavailable`, zero requests), then with the //! lock reverted to its registry bytes (`redirect_unwired`, `--no-verify` //! too) — plus the embedded `apply --vex` twin. And it runs against EVERY @@ -45,11 +47,12 @@ //! the redirected lock (EALLOWREMOTE) unless `.npmrc` sets //! `allow-remote=all`, so the hosted run AUTO-CONFIGURES it: every flow //! asserts the run wrote `allow-remote=all` to a new project `.npmrc` -//! (ledger-recorded, `redirect_npm_allow_remote` warned), the fresh checkout +//! (`redirect_npm_allow_remote` warned), the fresh checkout //! carries that committed `.npmrc` and installs with a PLAIN `npm ci` on //! every major — npm >= 12 additionally proves the setting is load-bearing //! (a checkout WITHOUT it is refused EALLOWREMOTE) — and the main capstone -//! ends with `rollback` removing exactly the `.npmrc` it created. +//! ends with `rollback` restoring the upstream registry entry (re-resolved +//! from the REAL npm registry) and removing exactly the `.npmrc` it created. //! //! Skips (with a println) when `npm` is missing or the fixture install //! cannot reach the registry — unless `SOCKET_PATCH_NPM_E2E_REQUIRED` is set; @@ -168,7 +171,7 @@ enum RedirectCli { /// Steps 1–3 of the module doc: real install, patched tarball + API mocks /// (same contract as `tests/in_process_redirect.rs`), the `cli`-selected -/// redirect invocation, and the envelope/lockfile/ledger assertions. When +/// redirect invocation, and the envelope/lockfile/no-ledger assertions. When /// `tamper_served_tarball` is set, the tarball route serves DIFFERENT bytes /// than the sha512 pinned into the lockfile — the negative twin's premise. /// `None` = skip (message already printed). @@ -448,7 +451,7 @@ async fn redirect_scanned_project( assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); assert_eq!( env["vex"]["verified"], false, - "in-run redirect VEX is attested from the ledger, not hash-verified: {env}" + "in-run redirect VEX is attested from this run's fetched record, not hash-verified: {env}" ); } RedirectCli::GetUuidHosted => { @@ -530,8 +533,8 @@ async fn redirect_scanned_project( ); // ...and the run AUTO-CONFIGURED it: a new project `.npmrc` holding - // exactly `allow-remote=all`, said so in the warning, ledger-recorded - // (so `rollback` can remove exactly what it added). + // exactly `allow-remote=all`, said so in the warning (`rollback` removes + // the file while it is still byte-identical to what the run created). let allow_remote = env["redirect"]["warnings"] .as_array() .and_then(|w| w.iter().find(|w| w["code"] == "redirect_npm_allow_remote")) @@ -548,15 +551,10 @@ async fn redirect_scanned_project( "the hosted run must write allow-remote=all to the project .npmrc" ); - // Ledger embeds the patch record so a post-install `vex` can verify. - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + // v5: hosted mode writes no ledger — the lockfile pin is the state. assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA), - "redirect ledger must embed the patch record + vulnerability: {ledger}" - ); - assert!( - ledger.contains("\"redirect_npmrc_allow_remote\""), - "the .npmrc auto-config must be ledger-recorded: {ledger}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode must not write the redirect ledger" ); Some(RedirectFixture { @@ -609,17 +607,37 @@ fn fresh_checkout_npm_ci(fx: &RedirectFixture) -> (PathBuf, Output) { (fresh, ci) } -/// The capstone's last step: `rollback` in the redirected project unwinds -/// the lock redirect AND removes exactly the `.npmrc` the hosted run created -/// (the ledger's `redirect_npmrc_allow_remote` `created` edit), leaving the -/// committed lock(s) at their registry resolution and no ledger behind. +/// The capstone's last step: `rollback` in the redirected project restores +/// the upstream registry entry (re-resolved from the REAL npm registry — the +/// pin is discovered from the lock on the `--patch-server-url` origin) AND +/// removes exactly the `.npmrc` the hosted run created (still byte-identical +/// to the scaffold), leaving the committed lock(s) at their registry +/// resolution and no ledger behind. fn rollback_removes_npmrc(fx: &RedirectFixture) { let proj = fx.proj.to_str().unwrap(); - let (code, stdout, stderr) = run_socket(&fx.proj, &["rollback", "--json", "--cwd", proj]); + let uri = fx.server.uri(); + let (code, stdout, stderr) = run_socket( + &fx.proj, + &[ + "rollback", + "--json", + "--cwd", + proj, + "--patch-server-url", + &uri, + ], + ); assert_eq!( code, 0, "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("rollback --json is not JSON: {e}\n{stdout}")); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([PURL]), + "rollback restores the hosted pin to its upstream entry: {env}" + ); assert!( !fx.proj.join(".npmrc").exists(), "rollback must remove the .npmrc the hosted run created:\n{stdout}" @@ -635,7 +653,7 @@ fn rollback_removes_npmrc(fx: &RedirectFixture) { } assert!( !fx.proj.join(".socket/vendor/redirect-state.json").exists(), - "the emptied redirect ledger is deleted" + "rollback never creates a redirect ledger" ); } @@ -680,8 +698,8 @@ fn fresh_install_patched(fx: &RedirectFixture) -> (PathBuf, bool) { } /// A patch API (public-proxy view route) serving the capstone's record -/// with the REAL patched hash — what manifest-less VEX fetches once the -/// ledger is gone. Built on its own runtime thread (the test's own runtime +/// with the REAL patched hash — what manifest-less VEX fetches (hosted mode +/// keeps no local record). Built on its own runtime thread (the test's own runtime /// cannot host a nested one). fn manifestless_tail(fx: &RedirectFixture, fresh: &Path, installed: bool, embedded: &[VexVia]) { std::thread::scope(|scope| { @@ -748,23 +766,26 @@ async fn npm_redirect_fresh_checkout_npm_ci_installs_patched_bytes_and_vex_verif let (fresh, installed) = fresh_install_patched(&fx); // 5. POST-INSTALL VERIFIED VEX: default verify mode hash-verifies the - // installed tree against the ledger's patch record (npm <= 6 installed + // installed tree against the API's patch record (npm <= 6 installed // nothing — the manifest-less tail below covers its lockfile basis). if installed { - post_install_ledger_vex(&fresh); + post_install_vex(&fresh, &fx.server.uri()); } - // 6. MANIFEST-LESS VEX over the fresh checkout: ledger present, ledger - // deleted (lockfile + API), offline, reverted — standalone and via - // the embedded `apply --vex`. - manifestless_tail(&fx, &fresh, installed, &[VexVia::Apply]); - - // 7. ROLLBACK: the lock redirect AND the auto-configured .npmrc go. + // 6. ROLLBACK of the redirected project (the fresh checkout is its own + // tree): the lock goes back to the upstream registry entry and the + // auto-configured .npmrc goes. rollback_removes_npmrc(&fx); + + // 7. MANIFEST-LESS VEX over the fresh checkout: lockfile + API, + // offline, reverted — standalone and via the embedded `apply --vex`. + manifestless_tail(&fx, &fresh, installed, &[VexVia::Apply]); } -/// Step 5 of the capstone: the ledger-backed, hash-verified `vex`. -fn post_install_ledger_vex(fresh: &Path) { +/// Step 5 of the capstone: the hash-verified `vex`. v5 hosted mode keeps no +/// ledger, so the pin comes from the committed lock (its host named by +/// `--patch-server-url`) and the record from the org-scoped patch API. +fn post_install_vex(fresh: &Path, server: &str) { let doc_path = fresh.join("doc.json"); let (code, stdout, stderr) = run_socket( fresh, @@ -776,6 +797,14 @@ fn post_install_ledger_vex(fresh: &Path) { PRODUCT, "--cwd", fresh.to_str().unwrap(), + "--patch-server-url", + server, + "--api-url", + server, + "--org", + ORG, + "--api-token", + "fake", ], ); assert_eq!( @@ -865,7 +894,7 @@ async fn npm_redirect_tampered_hosted_tarball_fails_fresh_npm_ci() { /// `get --mode hosted` twin of the capstone: the same fixture (real /// npm install, patched hosted tarball, API mocks) driven by get's UUID path /// must land the identical redirect — lockfile pinned to the hosted tarball, -/// ledger written, NO manifest/blobs (all asserted inside the fixture) — and +/// NO ledger, NO manifest/blobs (all asserted inside the fixture) — and /// a fresh checkout's `npm ci` must install the patched bytes. #[tokio::test(flavor = "multi_thread")] #[ignore = "wall-bound real-npm install (~150s); runs on all 3 OSes as an e2e CI matrix leg"] @@ -890,7 +919,7 @@ async fn npm_get_uuid_hosted_fresh_checkout_npm_ci_installs_patched_bytes() { /// uninstalled 9.9.9. The coarse installed-version narrowing must drop the /// latter BEFORE the grant request (the reference body is the oracle — the /// lockfile rewriter could never catch a granted-but-unmatchable version), -/// only the installed purl may land in the ledger, and the fresh-checkout +/// only the installed purl may land in the lockfile, and the fresh-checkout /// install must still land the patched bytes. #[tokio::test(flavor = "multi_thread")] #[ignore = "wall-bound real-npm install (~150s); runs on all 3 OSes as an e2e CI matrix leg"] @@ -940,18 +969,16 @@ async fn npm_get_ghsa_hosted_narrows_and_installs() { "the uninstalled version's view must never be fetched" ); - // Ledger: only the installed purl's record. - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(fx.proj.join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); + // The lockfile (v5's only hosted state): only the installed purl's + // patch uuid is pinned. + let lock = std::fs::read_to_string(fx.proj.join("package-lock.json")).unwrap(); assert!( - ledger["records"][PURL].is_object(), - "the installed purl must be recorded in the ledger: {ledger}" + lock.contains(&format!("/{UUID}/")), + "the installed purl must be pinned in the lockfile: {lock}" ); assert!( - ledger["records"][PURL_UNINSTALLED].is_null(), - "no ledger record for the uninstalled version: {ledger}" + !lock.contains(UUID_UNINSTALLED) && !lock.contains("9.9.9"), + "nothing may pin the uninstalled version: {lock}" ); // Fresh-checkout proof: the narrowed redirect still installs the diff --git a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs index b627eebc8..ecd2686f0 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_pnpm_build.rs @@ -4,8 +4,9 @@ //! `scan --mode hosted` never lands patched bytes in the repo: it splices the //! patched package's `resolution:` in pnpm-lock.yaml to `{integrity: //! , tarball: }` (a wiremock standing in for -//! patch.socket.dev) and records the patch in the redirect ledger. The -//! corepack legs prove every link of that chain against the REAL pnpm: +//! patch.socket.dev) and writes nothing else but the pnpm >=11 trust setting +//! (v5: no redirect ledger; the lock IS the hosted state). The corepack legs +//! prove every link of that chain against the REAL pnpm: //! //! 1. `corepack pnpm@ install left-pad@1.3.0` into a tempdir project //! (network used for fixture setup only, private `--store-dir`). @@ -14,11 +15,10 @@ //! the discovery / reference / view API mocks. //! 3. `scan --mode hosted --json --yes` (the real binary): the lock's //! `resolution:` now pins the wiremock tarball URL + the patched -//! tarball's sha512, the ledger holds the `redirect_pnpm_resolution` -//! edit + the patch record, and a second scan is idempotent (lock -//! byte-stable, no duplicate ledger edits). -//! 4. FRESH-CHECKOUT PROOF: only package.json + pnpm-lock.yaml + `.socket/` -//! travel, the `.npmrc` registry points at a DEAD port, the store is +//! tarball's sha512, no ledger is written, and a second scan is +//! idempotent (lock and workspace file byte-stable). +//! 4. FRESH-CHECKOUT PROOF: only package.json + pnpm-lock.yaml (+ `.socket/` +//! when present) travel, the `.npmrc` registry points at a DEAD port, the store is //! empty — `pnpm install --frozen-lockfile` MUST land the marker bytes, //! because the only reachable artifact URL is the hosted tarball. //! @@ -43,8 +43,7 @@ //! `e2e_vex_lockfile/pnpm.rs`. //! //! TRUST AUTO-CONFIG: a scan that rewrites a ROOT v9 lock also ensures -//! `trustLockfile: true` in pnpm-workspace.yaml (ledger edit kind -//! `redirect_pnpm_workspace_trust`; the workspace file joins +//! `trustLockfile: true` in pnpm-workspace.yaml (the workspace file joins //! `rewrittenFiles`), because pnpm >=11's lockfile supply-chain policy //! rejects the rewritten lock otherwise. Legacy locks need no trust setting //! or flag. The auto-config gate is lock-major >=9. Two @@ -217,9 +216,17 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { } fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + run_socket_env(cwd, args, &[]) +} + +/// [`run_socket`] with extra env applied after the scrub. +fn run_socket_env(cwd: &Path, args: &[&str], env: &[(String, String)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); scrub_socket_env(&mut cmd); + for (k, v) in env { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -737,56 +744,17 @@ async fn redirect_scanned_pnpm_project( "hosted mode must not edit package.json" ); - // Ledger: the lock edit (with the original resolution preserved for - // revert) + the embedded patch record a post-install `vex` verifies. + // v5 hosted mode keeps no ledger: the lock (+ the trust setting) is the + // whole hosted state. let ledger_path = proj.join(".socket/vendor/redirect-state.json"); - let ledger: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); - let edits = ledger["edits"].as_array().unwrap().clone(); - assert!( - edits.iter().any(|e| e["kind"] == "redirect_pnpm_resolution" - && e["key"] == format!("{DEP}@{DEP_VERSION}") - && e["path"] == lock_name), - "the ledger must record the redirect_pnpm_resolution edit: {ledger}" - ); - let trust_edits: Vec<&serde_json::Value> = edits - .iter() - .filter(|e| e["kind"] == "redirect_pnpm_workspace_trust") - .collect(); - if auto_trust { - // Exactly one trust edit, so `--revert` unwinds exactly one write. - assert_eq!( - trust_edits.len(), - 1, - "a v9 rewrite must record exactly one workspace trust edit: {ledger}" - ); - let edit = trust_edits[0]; - assert_eq!(edit["path"], "pnpm-workspace.yaml", "trust edit: {edit}"); - assert_eq!(edit["key"], "trustLockfile", "trust edit: {edit}"); - // "created" = new file (revert deletes it); "added" = line appended - // to a pre-existing file (revert removes only that line). - let expected_action = if ws_existed_before { - "added" - } else { - "created" - }; - assert_eq!(edit["action"], expected_action, "trust edit: {edit}"); - } else { - assert!( - trust_edits.is_empty(), - "legacy-lock / --no-trust-lockfile-config runs must record no workspace \ - trust edit: {ledger}" - ); - } assert!( - ledger["records"][PURL]["vulnerabilities"][GHSA].is_object(), - "the ledger must embed the patch record + vulnerability: {ledger}" + !ledger_path.exists(), + "hosted mode writes no redirect ledger ({tag})" ); // Idempotency: the second scan still counts the dep as redirected (the // hosted URL is already in the lock) but rewrites nothing — lock AND - // workspace file byte-stable — and appends no duplicate edits (which - // would poison a revert). + // workspace file byte-stable — and still writes no ledger. let (code, stdout, stderr) = run_hosted(driver, &proj, &server.uri(), scan_extra); assert_eq!( code, 0, @@ -814,13 +782,7 @@ async fn redirect_scanned_pnpm_project( "the re-run must leave pnpm-workspace.yaml byte-stable" ); } - let ledger2: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); - assert_eq!( - edits.len(), - ledger2["edits"].as_array().unwrap().len(), - "a re-run must not append duplicate ledger edits: {ledger2}" - ); + assert!(!ledger_path.exists(), "the re-run writes no ledger ({tag})"); Some(PnpmRedirectFixture { tmp, @@ -858,7 +820,10 @@ fn fresh_checkout_install( ) .expect("the v9 scan must have written pnpm-workspace.yaml"); } - copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + // v5 hosted mode writes nothing under `.socket/`; carry it when present. + if fx.proj.join(".socket").is_dir() { + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + } // Dead registry: the only reachable artifact URL is the wiremock hosted // tarball, so a successful install can only have come from it. The retry // clamps keep the negative legs from pnpm's default 10s + 60s retry @@ -1342,10 +1307,7 @@ async fn pnpm_pinned_matrix_install_verify_revert_and_tamper() { ); assert!(warm.status.success(), "warm install failed: {warm:?}"); let installed = std::fs::read(fx.proj.join("node_modules").join(DEP).join("index.js")).unwrap(); - let (vex_code, _, _) = run_socket( - &fx.proj, - &["vex", "--offline", "--product", "pkg:npm/consumer@0.0.0"], - ); + let (vex_code, _, _) = vex_online(&fx.proj, &fx._server.uri()); assert_eq!( vex_code == 0, installed == fx.patched, @@ -1376,10 +1338,7 @@ async fn pnpm_pinned_matrix_install_verify_revert_and_tamper() { // Local evidence of remediation: the default VEX path verifies installed // hashes. This deliberately makes no assertion about dashboard alerts. - let (code, stdout, stderr) = run_socket( - &fresh, - &["vex", "--offline", "--product", "pkg:npm/consumer@0.0.0"], - ); + let (code, stdout, stderr) = vex_online(&fresh, &fx._server.uri()); assert_eq!(code, 0, "verified VEX failed: {stdout}\n{stderr}"); let vex: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(vex["statements"][0]["status"], "not_affected", "{vex}"); @@ -1400,9 +1359,12 @@ async fn pnpm_pinned_matrix_install_verify_revert_and_tamper() { // Revert committed wiring without an installed tree: no in-place patch // reversal or blob fetching can hide a lock/trust-setting rollback bug. + // v5: the upstream restore re-resolves the registry integrity (a mirror + // of the pristine lock's), with the mock origin named the patch server. std::fs::remove_dir_all(fx.proj.join("node_modules")).unwrap(); + let unwind = unwind_env(&fx._server, &fx.lock_before).await; let (code, stdout, stderr) = - run_socket(&fx.proj, &["rollback", "--offline", "--yes", "--json"]); + run_socket_env(&fx.proj, &["rollback", "--yes", "--json"], &unwind); assert_eq!(code, 0, "rollback failed: {stdout}\n{stderr}"); assert_eq!( std::fs::read_to_string(fx.proj.join(&fx.lock_name)).unwrap(), @@ -1446,6 +1408,106 @@ async fn pnpm_pinned_matrix_install_verify_revert_and_tamper() { ); } +/// The env an unwind of a hosted pnpm pin runs with: `server` named the +/// patch server (so its tarball URL counts as a hosted pin — v5 keeps no +/// ledger) and an npm registry mirror under `/registry` serving +/// `left-pad@1.3.0`'s version document with the integrity `lock_before` +/// (the pristine lock) recorded — all the v5 upstream restore reads. +async fn unwind_env(server: &MockServer, lock_before: &str) -> Vec<(String, String)> { + unwind_env_for(server, &server.uri(), lock_before).await +} + +/// [`unwind_env`] with the hosted origin given explicitly (the synthetic +/// legs pin `http://patch.test`, not the mock server's own origin). +async fn unwind_env_for( + server: &MockServer, + patch_origin: &str, + lock_before: &str, +) -> Vec<(String, String)> { + let integrity = regex::Regex::new(r"integrity: (sha512-[A-Za-z0-9+/=]+)") + .unwrap() + .captures(lock_before) + .map(|c| c[1].to_string()) + .unwrap_or_else(|| panic!("no integrity in the pristine lock:\n{lock_before}")); + Mock::given(method("GET")) + .and(path(format!("/registry/{DEP}/{DEP_VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": DEP, + "version": DEP_VERSION, + "dist": { + "tarball": format!("https://registry.npmjs.org/{DEP}/-/{DEP}-{DEP_VERSION}.tgz"), + "integrity": integrity + } + }))) + .mount(server) + .await; + vec![ + ( + "SOCKET_PATCH_SERVER_URL".to_string(), + patch_origin.to_string(), + ), + ( + "SOCKET_NPM_REGISTRY".to_string(), + format!("{}/registry", server.uri()), + ), + ] +} + +/// v5 unwind of a synthetic-leg hosted pin: `rollback` restores the +/// upstream resolution from the registry mirror — the lock lands back on +/// `pristine` byte for byte — and no ledger appears at any point. +async fn assert_rollback_restores_pristine(server: &MockServer, root: &Path, pristine: &str) { + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no redirect ledger" + ); + let unwind = unwind_env_for(server, "http://patch.test", pristine).await; + let (code, stdout, stderr) = run_socket_env( + root, + &[ + "rollback", + "--yes", + "--json", + "--cwd", + root.to_str().unwrap(), + ], + &unwind, + ); + assert_eq!( + code, 0, + "rollback failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + pristine, + "rollback restores the upstream resolution byte for byte" + ); + assert!(!root.join(".socket/vendor/redirect-state.json").exists()); +} + +/// `vex` over `cwd`, ONLINE: v5 keeps no hosted ledger, so the patch record +/// comes from the patch API (`server_uri`, which is also the patch-server +/// origin the lock's hosted URL lives on). The installed tree is still +/// hash-verified. +fn vex_online(cwd: &Path, server_uri: &str) -> (i32, String, String) { + run_socket( + cwd, + &[ + "vex", + "--product", + "pkg:npm/consumer@0.0.0", + "--api-url", + server_uri, + "--org", + ORG, + "--api-token", + "fake", + "--patch-server-url", + server_uri, + ], + ) +} + /// A project whose only lockfile is the synthesized `lock`, with an installed /// node_modules stub so the crawler discovers the dep (a real pnpm project /// always has one). @@ -1574,25 +1636,9 @@ async fn pnpm_v5_lock_key_rewrite_splices_in_place() { "the upstream integrity must be replaced; got:\n{lock_after}" ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - let edit = ledger["edits"] - .as_array() - .unwrap() - .iter() - .find(|e| { - e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") - }) - .unwrap_or_else(|| panic!("the ledger must record the v5 redirect edit: {ledger}")); - assert!( - edit["original"] - .as_str() - .unwrap_or_default() - .contains(UPSTREAM_SHA512), - "the ledger must preserve the original upstream integrity for revert: {edit}" - ); + // The unwind: no ledger preserves the original — rollback re-resolves + // the upstream integrity and splices it back in place. + assert_rollback_restores_pristine(&server, tmp.path(), &v5_lock()).await; } /// pnpm v6 PLAIN lock keys (`/name@version:` with no peer suffix) stay inside @@ -1680,33 +1726,9 @@ async fn pnpm_v6_plain_lock_key_rewrite_stays_supported() { "the upstream integrity must be replaced; got:\n{lock_after}" ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - let edit = ledger["edits"] - .as_array() - .unwrap() - .iter() - .find(|e| { - e["kind"] == "redirect_pnpm_resolution" && e["key"] == format!("{DEP}@{DEP_VERSION}") - }) - .unwrap_or_else(|| panic!("the ledger must record the v6 redirect edit: {ledger}")); - assert!( - edit["original"] - .as_str() - .unwrap_or_default() - .contains(UPSTREAM_SHA512), - "the ledger must preserve the original upstream integrity for revert: {edit}" - ); - assert!( - !ledger["edits"] - .as_array() - .unwrap() - .iter() - .any(|e| e["kind"] == "redirect_pnpm_workspace_trust"), - "a v6-lock scan must record no workspace trust edit: {ledger}" - ); + // The unwind: no ledger preserves the original — rollback re-resolves + // the upstream integrity and splices it back in place. + assert_rollback_restores_pristine(&server, tmp.path(), &v6_lock()).await; } /// Real pnpm workspace graph with a scoped target, an npm alias, two peer @@ -1887,7 +1909,9 @@ async fn pnpm_pinned_matrix_workspace_peer_instances() { } // Manifest-less VEX over the fresh workspace: both peer instances are // hash-verified, from the ledger and then from the lockfile alone. - copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + if proj.join(".socket").is_dir() { + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + } assert_manifestless_hosted_vex( &fresh, &server.uri(), diff --git a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs index dd589c500..9a0c2a023 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs @@ -37,7 +37,7 @@ mod cache_env; mod vex_e2e_common; use vex_e2e_common::{ assert_absent, assert_attested, assert_not_attested, git_sha256, patch_view, run_vex, - strip_ledgers, strip_manifest, Marker, PatchApi, VexRun, + strip_manifest, Marker, PatchApi, VexRun, }; const ORG: &str = "test-org"; @@ -253,7 +253,7 @@ async fn mount_hosted( }))) .mount(server) .await; - // The record the ledger embeds: the patched bytes' real hash, so a + // The record the hosted run fetches: the patched bytes' real hash, so a // post-install VEX verifies what the install landed. Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) @@ -336,11 +336,11 @@ fn simulate_rush_install(root: &Path, store: &Path) -> Output { /// Manifest-less VEX over the Rush repo root after an install landed the /// patched bytes. Discovery reads `common/config/rush/pnpm-lock.yaml` (the /// rewritten source of truth); the hosted URLs sit on `patch_server` (the -/// wiremock), named via `--patch-server-url`. Cells: ledger kept (the -/// ledger record); ledgers deleted (the lock + the patch API record); -/// `--offline` with no ledgers (`record_unavailable`, zero requests); the -/// common lock reverted with the ledger restored (`redirect_unwired`, -/// `--no-verify` too). +/// wiremock), named via `--patch-server-url`. v5 hosted mode writes no +/// ledger, so the cells are: online (the lock + the patch API record); +/// `--offline` (`record_unavailable`, zero requests); the common lock +/// reverted (nothing names the patch any more — never attested, with or +/// without `--no-verify`). fn assert_rush_manifestless_vex(root: &Path, patch_server: &str, patched: &[u8], pristine: &[u8]) { std::thread::scope(|s| { s.spawn(|| { @@ -362,15 +362,12 @@ fn assert_rush_manifestless_vex(root: &Path, patch_server: &str, patched: &[u8], let lock = root.join("common/config/rush/pnpm-lock.yaml"); let wired = std::fs::read(&lock).unwrap(); strip_manifest(root); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode must not write the redirect ledger" + ); let out = run_vex(&bin, root, &online(false)); - assert_eq!(out.code, Some(0), "rush, ledger kept: {out}"); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); - - let ledger = root.join(".socket/vendor/redirect-state.json"); - let ledger_bytes = std::fs::read(&ledger).unwrap(); - strip_ledgers(root); - let out = run_vex(&bin, root, &online(false)); - assert_eq!(out.code, Some(0), "rush, ledgers deleted: {out}"); + assert_eq!(out.code, Some(0), "rush, lock + API: {out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); assert!(api.view_requests(UUID) >= 1, "{:?}", api.requests()); @@ -387,12 +384,10 @@ fn assert_rush_manifestless_vex(root: &Path, patch_server: &str, patched: &[u8], assert_not_attested(&out.envelope, PURL, "record_unavailable"); assert_eq!(api.request_count(), seen, "--offline hit the API"); - std::fs::write(&ledger, &ledger_bytes).unwrap(); std::fs::write(&lock, pristine).unwrap(); for no_verify in [false, true] { let out = run_vex(&bin, root, &online(no_verify)); assert_ne!(out.code, Some(0), "rush, reverted: {out}"); - assert_not_attested(&out.envelope, PURL, "redirect_unwired"); assert_absent(out.doc.as_ref(), PURL); } std::fs::write(&lock, &wired).unwrap(); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs index f3b6e5b02..55a4708ac 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_vlt_build.rs @@ -181,7 +181,7 @@ async fn vlt_pinned_matrix_hosted_rollback_byte_exact() { assert_eq!(state(&fx.proj, fx.t()), State::Patched); let ids = fx.store_ids(fx.t()); let snap = fx.snapshot(&fx.proj, &ids); - let out = rollback(&fx.proj, &[]); + let out = fx.rollback(&[]); assert_eq!(out.code, 0, "{out}"); let doc = out.json(); assert_eq!( @@ -189,7 +189,10 @@ async fn vlt_pinned_matrix_hosted_rollback_byte_exact() { String::from_utf8_lossy(&fx.lock_before), "rollback restores vlt-lock.json byte-for-byte" ); - assert!(fx.ledger().is_none(), "the ledger is gone"); + assert!( + fx.ledger().is_none(), + "no hosted ledger is ever written (v5)" + ); fx.assert_advisory(&doc, &advisory_rolled_back(1)); for id in &ids { assert!(!store_entry(&fx.proj, id).exists(), "{id} healed"); @@ -201,7 +204,8 @@ async fn vlt_pinned_matrix_hosted_rollback_byte_exact() { } /// A second scan of an already-patched, installed project changes -/// nothing: the lock and ledger stay byte-identical and nothing is healed. +/// nothing: the lock stays byte-identical, no ledger appears and nothing is +/// healed. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_hosted_rerun_noop() { @@ -213,11 +217,11 @@ async fn vlt_pinned_matrix_hosted_rerun_noop() { fx.vlt_ok(&fx.proj, &fx.leg.locked_install_args()); assert_eq!(state(&fx.proj, fx.t()), State::Patched); let lock = lock_bytes(&fx.proj); - let ledger = fx.ledger(); + assert!(fx.ledger().is_none(), "v5 hosted mode writes no ledger"); let snap = fx.snapshot(&fx.proj, &[]); let doc = fx.scan(&[]); assert_eq!(lock_bytes(&fx.proj), lock, "rerun keeps the lock"); - assert_eq!(fx.ledger(), ledger, "rerun keeps the ledger"); + assert!(fx.ledger().is_none(), "the rerun writes no ledger either"); fx.assert_advisory(&doc, &advisory_nothing_stale()); snap.assert_same(&fx.snapshot(&fx.proj, &[]), "a healthy rerun"); assert_eq!(state(&fx.proj, fx.t()), State::Patched); @@ -461,7 +465,8 @@ async fn vlt_pinned_matrix_hosted_peer_workspace_instances() { /// From 1.0.8 vlt keys a root dependency with resolved peers by its peer /// context (`~peer.<16 hex>`), so `vlt install @` re-keys the /// pinned node and carries the pin to the new DepID: a rescan leaves the -/// ledger alone, and rollback restores the registry pin on the new DepID. +/// lock alone (no ledger), and rollback restores the registry pin on the new +/// DepID. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_hosted_peer_rekey_rollback() { @@ -497,11 +502,10 @@ async fn vlt_pinned_matrix_hosted_peer_rekey_rollback() { assert_pinned(&fx.proj, &fx.svc, &t); assert_eq!(state(&fx.proj, &t), State::Patched); let lock = lock_bytes(&fx.proj); - let ledger = fx.ledger(); fx.scan(&[]); assert_eq!(lock_bytes(&fx.proj), lock, "the rescan keeps the lock"); - assert_eq!(fx.ledger(), ledger, "the rescan keeps the ledger"); - let out = rollback(&fx.proj, &[]); + assert!(fx.ledger().is_none(), "v5 hosted mode writes no ledger"); + let out = fx.rollback(&[]); assert_eq!(out.code, 0, "{out}"); assert_not_pinned(&fx.proj, &t); fx.assert_advisory(&out.json(), &advisory_rolled_back(1)); @@ -633,23 +637,22 @@ async fn resave_install_rollback(name: &'static str, crlf: bool) { } if resave_drops_hosted_url(fx.leg.version()) { assert_url_dropped(&fx); + // The re-save kept the patched integrity but dropped the hosted + // URL: no lockfile pin is left for v5 rollback to discover (it keeps + // no ledger), so it finds no state and writes nothing; a re-scan + // re-pins the node and the rollback below restores it. let dropped = lock_bytes(&fx.proj); - let out = rollback(&fx.proj, &[]); - assert_eq!(out.code, 1, "{out}"); - let failed = out.json()["hosted"]["failed"][0]["error"] - .as_str() - .unwrap_or_default() - .to_string(); - assert!( - failed.contains("drifted from the recorded redirect") - && failed.contains("re-run `socket-patch scan --mode hosted` and then roll back"), - "{out}" + let out = fx.rollback(&[]); + assert_eq!( + (out.code, out.json()["error"].as_str()), + (1, Some("Manifest not found")), + "a URL-less node is no hosted pin: {out}" ); assert_eq!(lock_bytes(&fx.proj), dropped, "drift: no write"); fx.scan(&[]); } assert_pinned(&fx.proj, &fx.svc, fx.t()); - let out = rollback(&fx.proj, &[]); + let out = fx.rollback(&[]); assert_eq!(out.code, 0, "{out}"); assert_not_pinned(&fx.proj, fx.t()); let lock = read_lock(&fx.proj); @@ -678,8 +681,10 @@ async fn vlt_pinned_matrix_hosted_resave_crlf_rollback() { resave_install_rollback("resave_crlf_rollback", true).await; } -/// scan → `vlt update` → rollback: already reverted, and the patched store -/// copy the update left behind is invalidated (r-vlt L3). +/// scan → `vlt update` → rollback. Through 1.0.7 the update keeps the pin: +/// rollback restores it and invalidates the patched store copy (r-vlt L3). +/// From 1.0.8 the update re-resolves from the registry: no pin is left, so +/// the ledger-free v5 rollback finds nothing to do and writes nothing. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_hosted_resave_update_rollback() { @@ -705,17 +710,25 @@ async fn vlt_pinned_matrix_hosted_resave_update_rollback() { let lock = lock_bytes(&fx.proj); let ids = fx.store_ids(fx.t()); let snap = fx.snapshot(&fx.proj, &ids); - let out = rollback(&fx.proj, &[]); - assert_eq!(out.code, 0, "{out}"); + let out = fx.rollback(&[]); if drops { + // The update already put the lock back on the registry: v5 keeps no + // ledger, so no hosted pin (and no heal target) is left to find. + assert_eq!( + (out.code, out.json()["error"].as_str()), + (1, Some("Manifest not found")), + "already reverted: nothing to roll back: {out}" + ); assert_eq!( lock_bytes(&fx.proj), lock, "already reverted: no lock write" ); - } else { - assert_not_pinned(&fx.proj, fx.t()); + fx.leg.ran(); + return; } + assert_eq!(out.code, 0, "{out}"); + assert_not_pinned(&fx.proj, fx.t()); fx.assert_advisory(&out.json(), &advisory_rolled_back(1)); for id in &ids { assert!(!store_entry(&fx.proj, id).exists(), "{id} invalidated"); @@ -747,7 +760,7 @@ async fn vlt_pinned_matrix_hosted_crlf_lock() { let co = fx.checkout("fresh-crlf"); fx.vlt_ok_profile(&co, &fx.leg.locked_install_args(), "fresh-crlf"); assert_eq!(state(&co, fx.t()), State::Patched); - let out = rollback(&fx.proj, &[]); + let out = fx.rollback(&[]); assert_eq!(out.code, 0, "{out}"); assert_eq!( lock_bytes(&fx.proj), @@ -1166,16 +1179,20 @@ async fn vlt_pinned_matrix_hosted_idempotence() { let fx = Fixture::build(leg, Shape::left_pad()).await; get_hosted(&fx.proj, &fx.svc, UUID, &[]); let lock = lock_bytes(&fx.proj); - let ledger = fx.ledger(); + assert!(fx.ledger().is_none(), "v5 hosted mode writes no ledger"); get_hosted(&fx.proj, &fx.svc, UUID, &[]); assert_eq!(lock_bytes(&fx.proj), lock); - assert_eq!(fx.ledger(), ledger); - let out = rollback(&fx.proj, &[]); + assert!(fx.ledger().is_none()); + let out = fx.rollback(&[]); assert_eq!(out.code, 0, "{out}"); - assert_eq!(lock_bytes(&fx.proj), fx.lock_before); + assert_eq!( + String::from_utf8_lossy(&lock_bytes(&fx.proj)), + String::from_utf8_lossy(&fx.lock_before), + "rollback restores the registry lock byte-for-byte: {out}" + ); assert!(fx.ledger().is_none()); let files = package_files(&fx.proj); - let out = rollback(&fx.proj, &[]); + let out = fx.rollback(&[]); assert_eq!( (out.code, out.json()["error"].as_str()), (1, Some("Manifest not found")), @@ -1222,9 +1239,11 @@ async fn vlt_pinned_matrix_hosted_manifestless_vex() { fx.leg.ran(); } -/// The TS twin's golden output for `basic` (the server's PR-flow lock and -/// ledger) is reverted by SP `rollback`, and `vlt ci` then installs the -/// registry bytes. +/// The TS twin's golden output for `basic` (the server's PR-flow lock, its +/// pin on `patch.socket.dev`) is restored by SP `rollback` to its upstream +/// registry entry — byte-identical to the input — and `vlt ci` then +/// installs the registry bytes. The pre-v5 ledger the server wrote beside it +/// is never replayed, only retired. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_hosted_ts_written_lock() { @@ -1273,12 +1292,21 @@ async fn vlt_pinned_matrix_hosted_ts_written_lock() { &proj.join(".socket/vendor/redirect-state.json"), serde_json::to_vec_pretty(&ledger).unwrap(), ); - let out = rollback(&proj, &[]); + let out = rollback_upstream(&proj, ®.url(), None, &[]); assert_eq!(out.code, 0, "{out}"); + assert_eq!( + out.json()["hosted"]["reverted"], + json!(["pkg:npm/left-pad@1.3.0"]), + "{out}" + ); assert_eq!( String::from_utf8_lossy(&lock_bytes(&proj)), input, - "SP reverts the TS-written lock to its input" + "SP restores the TS-written lock to its input" + ); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "the pre-v5 ledger is retired once no hosted pin remains" ); leg.vlt_ok(&proj, &["ci"]); let lp = reg.pkg(LP.0, LP.1); @@ -1379,7 +1407,7 @@ async fn vlt_pinned_matrix_hosted_optional_dependency_heal() { } assert_eq!(lock_bytes(&fx.proj), before_ci, "ci keeps the lock"); let snap = fx.snapshot(&fx.proj, &lp_ids); - let out = rollback(&fx.proj, &[]); + let out = fx.rollback(&[]); assert_eq!(out.code, 0, "{out}"); snap.assert_same( &fx.snapshot(&fx.proj, &lp_ids), @@ -1513,7 +1541,10 @@ async fn vlt_pinned_matrix_hosted_then_vendored_optional_takeover() { hidden_lock_exists(&proj), "{kind}: the warm tree has a hidden lock" ); - let out = socket_api(&proj, &svc, &["scan", "--mode", "vendored"], &[]); + // The hosted pin sits on the mock patch service: name its origin so + // the takeover classifies it (v5 discovers hosted pins from the lock + // alone), and restore its upstream entry from the harness registry. + let out = vendored_over_hosted(&proj, &svc, ®); assert_eq!(out.code, 0, "{kind}: {out}"); let doc = out.json(); let detail = event_reason(&doc, ADVISORY); @@ -1568,6 +1599,36 @@ async fn vlt_pinned_matrix_hosted_then_vendored_optional_takeover() { leg.ran(); } +/// `scan --mode vendored` over a hosted pin on the mock patch service: +/// `--patch-server-url` names its origin (v5 recognizes no other hosted +/// host) and `SOCKET_NPM_REGISTRY` points the takeover's upstream restore +/// at the harness registry. +fn vendored_over_hosted(proj: &Path, svc: &PatchService, reg: &Registry) -> SocketOut { + let cwd = proj.to_str().unwrap().to_string(); + let uri = svc.uri(); + socket( + proj, + &[ + "scan", + "--mode", + "vendored", + "--json", + "--yes", + "--cwd", + &cwd, + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "sktsec_placeholder_value_for_tests_api", + "--patch-server-url", + &uri, + ], + &[("SOCKET_NPM_REGISTRY", ®.url())], + ) +} + fn optional_only_kind(leg: &Leg) -> OptionalOnly { optional_only(leg.version()) } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs index 440774dbc..0ffb717d4 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs @@ -5,8 +5,9 @@ //! `scan --mode hosted` never lands patched bytes in the repo: it rewrites //! `yarn.lock` so the patched dependency resolves via //! `npm:::__archiveUrl=` with `checksum: 10c0/` (yarn's -//! cache-zip sha512), and records the patch in the redirect ledger. This test -//! proves every link against the REAL `corepack yarn@4.12.0`: +//! cache-zip sha512); v5 keeps no redirect ledger — the lock pin is the +//! whole hosted state. This test proves every link against the REAL +//! `corepack yarn@4.12.0`: //! //! 1. `yarn install` of left-pad@1.3.0 (network for fixture setup only, //! private global cache, node-modules linker). @@ -17,8 +18,8 @@ //! (yarn recomputes the same zip checksum whether the locator is `file:` //! or `::__archiveUrl=`, so `--check-cache` will accept it). //! 3. `scan --mode hosted --json --vex` (the real binary): yarn.lock now -//! pins the hosted `__archiveUrl` + the `10c0` checksum, the ledger -//! embeds the record, the in-run VEX is the `(redirected)` attestation. +//! pins the hosted `__archiveUrl` + the `10c0` checksum, NO ledger is +//! written, the in-run VEX is the `(redirected)` attestation. //! 4. FRESH-CHECKOUT PROOF: only package.json + yarn.lock + .yarnrc.yml + //! .socket/ travel; `yarn install --immutable --check-cache` (offline //! from the registry, `unsafeHttpWhitelist` for the wiremock host) MUST @@ -522,7 +523,7 @@ async fn berry_hosted_project( assert_eq!(env["vex"]["format"], "openvex-0.2.0", "vex block: {env}"); assert_eq!( env["vex"]["verified"], false, - "in-run redirect VEX is attested from the ledger, not hash-verified: {env}" + "in-run redirect VEX is attested from this run's fetched record, not hash-verified: {env}" ); let vex_doc: serde_json::Value = serde_json::from_slice(&std::fs::read(proj.join("out.vex.json")).unwrap()).unwrap(); @@ -565,10 +566,10 @@ async fn berry_hosted_project( ({checksum_line:?}); got:\n{lock}" ); - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + // v5: hosted mode writes no ledger — the yarn.lock pin is the state. assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA), - "redirect ledger must embed the patch record + vulnerability: {ledger}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode must not write the redirect ledger" ); Some(BerryRedirectFixture { @@ -606,7 +607,10 @@ fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) { // A fresh .yarnrc.yml: node-modules linker, no global cache, and the // wiremock host whitelisted for plain http (yarn refuses http otherwise). std::fs::write(fresh.join(".yarnrc.yml"), fresh_yarnrc(fx)).unwrap(); - copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + // v5 hosted mode may leave no `.socket/` at all (no ledger, no manifest). + if fx.proj.join(".socket").is_dir() { + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + } let fresh_global = fx.tmp.path().join("fresh-yarn-global"); let ci = corepack( &fresh, diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index 97b2b1f08..511788da5 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -5,7 +5,8 @@ //! `scan --mode hosted` never lands patched bytes in the repo: it rewrites the //! classic `yarn.lock` block to //! `resolved "#"` + a recomputed `integrity sha512-…` -//! line, and records the patch in the redirect ledger. This test proves every +//! line — and writes nothing else (v5: no redirect ledger; the lock IS the +//! hosted state). This test proves every //! link against the REAL `corepack yarn@1.22.22` — the gap the 2026-07 strapi //! incident exposed: hosted wiring for a classic lock had never been //! install-proven with the installer that actually honors the v1 format @@ -21,7 +22,7 @@ //! cache-zip `10c0` checksum). //! 3. `scan --mode hosted --json --vex` (the real binary) against a wiremock //! Socket API: yarn.lock now pins the hosted URL + `#sha1` + recomputed -//! integrity, the ledger embeds the record, the in-run VEX is the +//! integrity, no ledger is written, the in-run VEX is the //! `(redirected)` attestation. //! 4. FRESH-CHECKOUT PROOF: only package.json + yarn.lock + .socket/ travel; //! `yarn install --frozen-lockfile` (empty private cache; the only dep @@ -34,7 +35,7 @@ //! //! Manifest-less VEX (the depscan / never-committed-manifest shape): once the //! fresh checkout has installed the hosted bytes, `ManifestlessVex` deletes -//! `.socket/manifest.json`, then the redirect ledger, and proves the patch is +//! `.socket/manifest.json` (and any ledger), and proves the patch is //! still attested `(redirected)` from the `yarn.lock` wiring alone (record //! from the patch API), is `record_unavailable` `--offline` with zero API //! requests, and is NOT attested once the lock is reverted to the registry @@ -455,10 +456,9 @@ async fn classic_hosted_project( "the registry resolution must be gone from the rewired block:\n{lock}" ); - let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA), - "redirect ledger must embed the patch record + vulnerability: {ledger}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger — the lock is the hosted state" ); Some(ClassicRedirectFixture { @@ -479,7 +479,10 @@ fn fresh_checkout_yarn_install(fx: &ClassicRedirectFixture) -> (PathBuf, Output) std::fs::create_dir_all(&fresh).unwrap(); std::fs::copy(fx.proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(fx.proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap(); - copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + // v5 hosted mode writes nothing under `.socket/`; carry it when present. + if fx.proj.join(".socket").is_dir() { + copy_dir_recursive(&fx.proj.join(".socket"), &fresh.join(".socket")); + } let fresh_cache = fx.tmp.path().join("fresh-yarn-cache"); let ci = corepack( &fresh, diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs index 095e3bc16..278cae19e 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs @@ -25,9 +25,10 @@ //! 5. manifest-less VEX there (`vex_pypi_real_common::VexMatrix`, with //! `VIRTUAL_ENV` naming Hatch's out-of-tree environment so the crawler //! hashes the real install): manifest deleted, tampered install (hosted), -//! ledgers deleted, embedded `apply --vex` / `vendor --vex`, offline with -//! no ledger → `record_unavailable`, declaration reverted → -//! `redirect_unwired` / `vendor_unwired` (`--no-verify` too); plus the +//! ledgers deleted (v5 hosted writes none), embedded `apply --vex` / +//! `vendor --vex`, offline with no ledger → `record_unavailable`, +//! declaration reverted → `vendor_unwired` / hosted: nothing names the +//! patch (`--no-verify` too); plus the //! manifest-less `scan --vex` re-run and, hosted, the not-installed //! (pin) basis. //! @@ -290,6 +291,12 @@ fn flow(flavor: Flavor, mode: Mode) { Flavor::HatchTomlEnv => "hatch.toml", }; let wired = std::fs::read_to_string(project.join(wired_file)).unwrap(); + assert!( + !project + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL) + .exists(), + "{what}: v5 writes no redirect ledger" + ); match mode { Mode::Hosted => assert!( wired.contains(&format!( diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs index 28bcc9590..4e79d00cf 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs @@ -18,13 +18,14 @@ //! from the mock patch server (hosted) or the committed wheel (vendored) //! — and `import six` must see the patched bytes; //! 5. manifest-less VEX there (`vex_pypi_real_common::VexMatrix`): manifest -//! deleted (ledger offline + online), tampered install → `hash_mismatch` +//! deleted (vendored: vendor ledger offline + online; hosted: online — +//! v5 hosted writes no ledger), tampered install → `hash_mismatch` //! (hosted), ledgers deleted (lockfile discovery + API), embedded //! `apply --vex` / `vendor --vex`, `--offline` with no ledger → //! `record_unavailable` with zero requests, and the lock reverted to the -//! registry (ledgers + artifacts kept) → `redirect_unwired` / -//! `vendor_unwired`, `--no-verify` included; plus a manifest-less -//! `scan --redirect|--vendor --vex` re-run. +//! registry (vendor ledger + artifacts kept) → `vendor_unwired` / +//! hosted: nothing names the patch, `--no-verify` included; plus a +//! manifest-less `scan --redirect|--vendor --vex` re-run. //! //! Releases whose lock format loses url/path identity (PDM 1.8 – 1.15 = //! 3.1, 2.0 – 2.7 = 4.0 – 4.2) must REFUSE both scans with the lock @@ -417,6 +418,12 @@ fn flow(mode: Mode) { assert_attested(&doc, PURL, mode.uuid(), mode.marker(), VULNS); std::fs::remove_file(&vex_out).unwrap(); let wired_lock = std::fs::read_to_string(project.join("pdm.lock")).unwrap(); + assert!( + !project + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL) + .exists(), + "{what}: v5 writes no redirect ledger" + ); match mode { Mode::Hosted => assert!( wired_lock.contains(&api.artifact_url()), diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs index 7549b0fe9..bd6d2b4a6 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs @@ -5,8 +5,8 @@ //! (PyPI is used for fixture setup only); //! 2. OUR CLI produces the committed state against a wiremock patch service: //! hosted = `scan --redirect --vex` on the lock-only checkout (the lock is -//! repointed at a patched wheel the mock serves, the redirect ledger is -//! written, the same-run VEX attests from the lock's sha256 pin); vendored +//! repointed at a patched wheel the mock serves — v5 writes NO redirect +//! ledger — the same-run VEX attests from the lock's sha256 pin); vendored //! = `scan --vendor --vendor-source build --vex` over the pristine //! install (the patched wheel is committed under //! `.socket/vendor/pypi//`, the lock is rewired to it, and only the @@ -19,19 +19,21 @@ //! 4. manifest-less VEX over that installed checkout, with standalone //! `socket-patch vex --json --output` against a separate records API: //! - (1) `.socket/manifest.json` deleted → attested with the right -//! `(redirected)` / `(vendored)` marker and vulnerability ids, and also -//! offline from the committed ledger; -//! - (2) the ledgers deleted too → still attested, via lockfile discovery -//! and the API record (installed tree / committed wheel hash-verified); -//! embedded `apply --vex` (and vendored `vendor --vex`) agree; +//! `(redirected)` / `(vendored)` marker and vulnerability ids (vendored +//! also offline from the committed vendor ledger); +//! - (2) no ledgers → still attested, via lockfile discovery and the API +//! record (installed tree / committed wheel hash-verified); embedded +//! `apply --vex` (and vendored `vendor --vex`) agree; //! - (3) `--offline` with no ledgers → `record_unavailable`, zero API //! requests; -//! - (4) the lock reverted to the registry version with the ledgers and -//! artifacts kept → NOT attested (`redirect_unwired` / -//! `vendor_unwired`), under `--no-verify` too; +//! - (4) the lock reverted to the registry version (vendor ledger and +//! artifacts kept) → NOT attested: `vendor_unwired` for vendored, +//! nothing names the patch at all for hosted — under `--no-verify` too; //! -//! then the real `rollback` / `vendor --revert` in the original project -//! restores the pristine lock byte for byte. +//! then the real `rollback` (hosted: the upstream PyPI entry re-resolved +//! from the registry, pins discovered on the `--patch-server-url` origin) +//! / `vendor --revert` in the original project restores the pristine lock +//! byte for byte. //! //! Poetry selection: `SOCKET_PATCH_POETRY_BIN` (a Poetry executable, e.g. //! `/bin/poetry` of a pinned release) or `poetry` on `PATH`. With @@ -632,16 +634,16 @@ fn manifestless_vex_matrix( ) { let label = format!("poetry {} {marker:?}", ctx.poetry.version); let api = PatchApi::start(vec![(uuid.into(), record_view(uuid, pristine, patched))]); - let unwired = match marker { - Marker::Redirected => "redirect_unwired", - _ => "vendor_unwired", - }; + let hosted = marker == Marker::Redirected; - // (1) manifest deleted, ledger kept: offline from the ledger, online. + // (1) manifest deleted, ledger kept: offline from the vendor ledger, + // online. v5 hosted keeps no ledger: no local record to go offline from. strip_manifest(ctx.project); - let out = ctx.standalone(&api, true, &[]); - attested(&format!("{label} (1) ledger offline"), &out, uuid, marker); - api.assert_no_requests(); + if !hosted { + let out = ctx.standalone(&api, true, &[]); + attested(&format!("{label} (1) ledger offline"), &out, uuid, marker); + api.assert_no_requests(); + } let out = ctx.standalone(&api, false, &[]); attested(&format!("{label} (1) online"), &out, uuid, marker); @@ -656,7 +658,11 @@ fn manifestless_vex_matrix( (p, bytes) }) .collect(); - assert!(!ledgers.is_empty(), "{label}: the writer left a ledger"); + assert_eq!( + ledgers.is_empty(), + hosted, + "{label}: vendored leaves its vendor ledger, hosted (v5) none: {ledgers:?}" + ); strip_ledgers(ctx.project); let before = api.view_requests(uuid); let out = ctx.standalone(&api, false, &[]); @@ -700,20 +706,29 @@ fn manifestless_vex_matrix( for extra in [&[][..], &["--no-verify"][..]] { for offline in [true, false] { let out = ctx.standalone(&api, offline, extra); - omitted( - &format!("{label} (4) reverted offline={offline} {extra:?}"), - &out, - unwired, - ); + let what = format!("{label} (4) reverted offline={offline} {extra:?}"); + if hosted { + // No ledger and no wiring: nothing names the patch any more. + assert_eq!(out.code, Some(2), "{what}: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{what}: {out}" + ); + assert_absent(out.doc.as_ref(), PURL); + } else { + omitted(&what, &out, "vendor_unwired"); + } } } - let out = ctx.run(&api, VexVia::Apply, false, &["--vex-no-verify"]); - assert_eq!(out.code, Some(1), "{label} (4) apply --vex: {out}"); - assert_eq!( - out.envelope["error"]["code"], "no_applicable_patches", - "{label} (4) apply --vex: {out}" - ); - assert!(out.doc.is_none(), "{label} (4) apply --vex: {out}"); + if !hosted { + let out = ctx.run(&api, VexVia::Apply, false, &["--vex-no-verify"]); + assert_eq!(out.code, Some(1), "{label} (4) apply --vex: {out}"); + assert_eq!( + out.envelope["error"]["code"], "no_applicable_patches", + "{label} (4) apply --vex: {out}" + ); + assert!(out.doc.is_none(), "{label} (4) apply --vex: {out}"); + } } // ════════════════════════════════════════════════════════════════════════ @@ -859,14 +874,28 @@ fn poetry_hosted_fresh_install_then_manifestless_vex() { let out = ctx.standalone(&api, false, &[]); omitted("hosted pristine install", &out, "not_applied"); - // The real rollback in the writer's project restores every byte. - let (code, env) = socket_patch(&project, &poetry, &service, &["rollback"]); + // The real rollback in the writer's project restores every byte: the + // pin is discovered from the lock on the mock's origin + // (`--patch-server-url`) and its upstream entry re-resolved from PyPI. + let uri = service.uri(); + let (code, env) = socket_patch( + &project, + &poetry, + &service, + &["rollback", "--patch-server-url", &uri], + ); assert_eq!(code, Some(0), "rollback: {env}"); + assert_eq!( + env["hosted"]["reverted"], + json!([PURL]), + "rollback restores the hosted pin: {env}" + ); assert_eq!( std::fs::read_to_string(project.join("poetry.lock")).unwrap(), pristine_lock, "rollback restores the pristine lock" ); + assert!(!project.join(".socket/vendor/redirect-state.json").exists()); } // ════════════════════════════════════════════════════════════════════════ diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs index 2bee5e293..67ffce618 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs @@ -21,11 +21,15 @@ //! `(redirected)` / `(vendored)` marker; exit 0; `verified` events. //! * b) `--offline` with no local record → `record_unavailable`, exit 1, //! and the mock records ZERO requests. -//! * c) ledger present, manifest absent → attests offline from the ledger's -//! embedded record (ledgers written by the REAL CLI: `scan --mode hosted` -//! / `scan --mode vendored`, whose embedded `--vex` is asserted too). -//! * d) lock reverted to the registry while ledger + artifact remain → -//! `redirect_unwired` / `vendor_unwired`, including under `--no-verify`. +//! * c) vendored: ledger present, manifest absent → attests offline from +//! the ledger's embedded record (written by the REAL CLI: `scan --mode +//! vendored`, whose embedded `--vex` is asserted too). Hosted: the REAL +//! `scan --mode hosted` writes no ledger (v5), so offline the wired lock +//! is `record_unavailable` and online it attests from the API. +//! * d) lock reverted to the registry: vendored, while ledger + artifact +//! remain → `vendor_unwired`, including under `--no-verify`; hosted → +//! nothing is discovered (the lock was the only hosted state); a PRE-v5 +//! redirect ledger left behind → `redirect_unwired`. //! * e) tampered installed tree (hosted) / tampered artifact member //! (vendored) → `hash_mismatch` / `vendor_hash_mismatch`. //! * f) spoofs: a uuid on a non-Socket host (and look-alike hosts), a @@ -723,12 +727,13 @@ fn b_api_without_the_record_is_record_unavailable() { } // ────────────────────────────────────────────────────────────────────── -// c) + d) + embedded: the ledgers written by the REAL CLI. +// c) + d) + embedded: the state written by the REAL CLI. // ────────────────────────────────────────────────────────────────────── /// `scan --mode hosted --vex` on a lock-only checkout (nothing installed): -/// the real engine rewires the lock to the mock server's hosted url, and -/// the in-run VEX attests `(redirected)`. Returns the pre-scan lock bytes. +/// the real engine rewires the lock to the mock server's hosted url, the +/// in-run VEX attests `(redirected)` from this run's fetched records, and +/// no ledger is written (v5). Returns the pre-scan lock bytes. fn scan_hosted(cwd: &Path, flavor: Flavor, api: &Api) -> Vec { write_lock(cwd, flavor, &Wiring::Registry); let registry_lock = std::fs::read(cwd.join(flavor.lock_file())).unwrap(); @@ -764,8 +769,8 @@ fn scan_hosted(cwd: &Path, flavor: Flavor, api: &Api) -> Vec { "{what}: the lock must be rewired" ); assert!( - cwd.join(".socket/vendor/redirect-state.json").is_file(), - "{what}" + !cwd.join(".socket/vendor/redirect-state.json").exists(), + "{what}: v5 hosted mode writes no ledger" ); assert!(!cwd.join(".socket/manifest.json").exists(), "{what}"); registry_lock @@ -830,7 +835,7 @@ fn drop_ledgers(cwd: &Path) { } #[test] -fn c_d_hosted_ledger_from_real_scan_attests_offline_and_dies_with_the_lock() { +fn c_d_hosted_real_scan_attests_online_only_and_dies_with_the_lock() { for flavor in FLAVORS { let what = format!("{flavor:?} hosted"); let tmp = project(); @@ -841,49 +846,18 @@ fn c_d_hosted_ledger_from_real_scan_attests_offline_and_dies_with_the_lock() { let origin = api.uri(); let psu = ["--patch-server-url", origin.as_str()]; - // c) ledger present, manifest absent: offline, from the ledger. + // c) the scan left no local record (v5 keeps no hosted ledger): + // offline, the wired lock is `record_unavailable`. let (code, env) = vex(cwd, &[&["--offline"][..], &psu].concat()); - assert_attested(cwd, code, &env, UUID, "hosted", &format!("{what} ledger")); - - // The hosted url is on the operator's patch server, so without - // `--patch-server-url` it is not a discovered Socket reference. - // DELIBERATE (vex_sources' raw-text fallback, kept so staging - // servers keep working): an off-allowlist origin still proves the - // LEDGER claim live — but only a discovered pinned ref may attest - // from the lock, so with nothing installed the claim is - // `package_not_found`, never attested. - let (code, env) = vex(cwd, &["--offline"]); - assert_omitted( - cwd, - code, - &env, - "package_not_found", - &format!("{what} no psu"), - ); - install(cwd, PATCHED); - let (code, env) = vex(cwd, &["--offline"]); - assert_attested( - cwd, - code, - &env, - UUID, - "hosted", - &format!("{what} no psu, installed"), - ); - install(cwd, PRISTINE); - let (code, env) = vex(cwd, &["--offline"]); assert_omitted( cwd, code, &env, - "not_applied", - &format!("{what} no psu, pristine"), + "record_unavailable", + &format!("{what} offline"), ); - std::fs::remove_dir_all(cwd.join("node_modules")).unwrap(); - // a) no ledger either: the record comes from the API. - let saved = std::fs::read(cwd.join(".socket/vendor/redirect-state.json")).unwrap(); - drop_ledgers(cwd); + // a) online: the record comes from the API. let (code, env) = vex_online(cwd, &api, &psu); assert_attested( cwd, @@ -893,10 +867,19 @@ fn c_d_hosted_ledger_from_real_scan_attests_offline_and_dies_with_the_lock() { "hosted", &format!("{what} lock only"), ); - std::fs::write(cwd.join(".socket/vendor/redirect-state.json"), &saved).unwrap(); - // d) lock reverted to the registry, ledger left behind: never - // attested, not even under --no-verify, offline or online. + // The hosted url is on the operator's patch server, so without + // `--patch-server-url` it is not a Socket reference at all — and + // no ledger is left to vouch for it — installed or not. + let (code, env) = vex_online(cwd, &api, &[]); + assert_nothing_found(code, &env, &format!("{what} no psu")); + install(cwd, PATCHED); + let (code, env) = vex_online(cwd, &api, &[]); + assert_nothing_found(code, &env, &format!("{what} no psu, installed")); + std::fs::remove_dir_all(cwd.join("node_modules")).unwrap(); + + // d) lock reverted to the registry: no hosted state is left, so + // nothing is discovered — offline or online, --no-verify included. std::fs::write(cwd.join(flavor.lock_file()), ®istry_lock).unwrap(); for extra in [ &["--offline"][..], @@ -904,13 +887,7 @@ fn c_d_hosted_ledger_from_real_scan_attests_offline_and_dies_with_the_lock() { &[][..], ] { let (code, env) = vex_online(cwd, &api, &[extra, &psu].concat()); - assert_omitted( - cwd, - code, - &env, - "redirect_unwired", - &format!("{what} reverted {extra:?}"), - ); + assert_nothing_found(code, &env, &format!("{what} reverted {extra:?}")); } } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs index b47c5cf8a..67368f84a 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs @@ -1782,11 +1782,12 @@ fn embedded_scan_vex_attests_lockfile_wired_patches() { assert!(!out_path.exists(), "the stale document is removed"); } -/// Embedded `apply --vex` / `vendor --vex` on the manifest-less mixed -/// project: neither command has anything of its own to do, but the -/// requested document must still be produced (`status: noManifest` + a -/// `vex` summary) — and offline, with no record anywhere, the requested VEX -/// fails the command rather than exiting 0 without it. +/// Embedded `apply --vex` on the manifest-less mixed project: the command +/// has nothing of its own to do, but the requested document must still be +/// produced (`status: noManifest` + a `vex` summary) — and offline, with no +/// record anywhere, the requested VEX fails the command rather than exiting +/// 0 without it. (`vendor` is not driven here: over the project's HOSTED +/// pins it is the v5 eject flow, not a manifest-less no-op.) #[test] fn embedded_apply_and_vendor_vex_attest_lockfile_wired_patches_without_manifest() { let api = mixed_api(); @@ -1797,7 +1798,8 @@ fn embedded_apply_and_vendor_vex_attest_lockfile_wired_patches_without_manifest( .map(|f| fx.read(f)) .collect(); let out_path = fx.cwd.join("embedded.vex.json"); - for command in ["apply", "vendor"] { + { + let command = "apply"; let run = |extra: &[&str]| { let _ = std::fs::remove_file(&out_path); let mut args = vec![ @@ -1838,7 +1840,7 @@ fn embedded_apply_and_vendor_vex_attest_lockfile_wired_patches_without_manifest( ); assert!(!out_path.exists(), "{command}"); } - // Neither command touched the committed wiring. + // The command never touched the committed wiring. let after: Vec = ["Cargo.toml", "Cargo.lock", ".cargo/config.toml"] .iter() .map(|f| fx.read(f)) diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs index cfdfa598f..83a8de749 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/common_selftest.rs @@ -102,7 +102,10 @@ fn run_vex_hosted_lockfile_only_offline_then_online() { assert_absent(out.doc.as_ref(), "pkg:npm/other@1.0.0"); assert!(api.view_requests(UUID) >= 1, "{:?}", api.requests()); - for via in [VexVia::Apply, VexVia::Vendor] { + // `apply --vex` treats the hosted checkout as manifest-less; `vendor` + // would EJECT it (v5), which `manifestless_embedded` covers. + { + let via = VexVia::Apply; std::fs::remove_file(p.join(DEFAULT_OUTPUT)).unwrap(); let out = run_vex(&binary(), p, &VexRun::online(&api).via(via)); assert_eq!(out.code, Some(0), "{via:?}: {out}"); @@ -117,6 +120,49 @@ fn run_vex_hosted_lockfile_only_offline_then_online() { } } +/// A checkout wired by a PRE-v5 socket-patch still commits +/// `.socket/vendor/redirect-state.json`. v5 never writes it, but reads it as +/// an extra local record source: a hosted pin it describes (same purl and +/// uuid) attests OFFLINE, with zero network, and the ledger is left +/// byte-identical. A ledger record for another uuid is no record for this +/// pin. +#[test] +fn committed_pre_v5_ledger_lets_a_hosted_pin_attest_offline() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path(); + let purl = write_hosted_npm_lock(p, "left-pad", "1.3.0", UUID); + write_legacy_redirect_ledger(p, &purl, &left_pad_view()); + let ledger = p.join(LEGACY_REDIRECT_LEDGER); + let before = std::fs::read(&ledger).unwrap(); + let api = PatchApi::empty(); + for no_verify in [false, true] { + let run = VexRun { + offline: true, + no_verify, + ..VexRun::online(&api) + }; + let out = run_vex(&binary(), p, &run); + assert_eq!(out.code, Some(0), "nv={no_verify}: {out}"); + assert_attested( + out.doc(), + &purl, + UUID, + Marker::Redirected, + &[(GHSA, &[CVE])], + ); + } + api.assert_no_requests(); + assert_eq!(std::fs::read(&ledger).unwrap(), before, "vex never rewrites it"); + + let other = "0b0b0b0b-0b0b-4b0b-8b0b-0b0b0b0b0b0b"; + let mut stale = left_pad_view(); + stale["uuid"] = other.into(); + write_legacy_redirect_ledger(p, &purl, &stale); + let out = run_vex(&binary(), p, &VexRun::offline()); + assert_eq!(out.code, Some(1), "{out}"); + assert_not_attested(&out.envelope, &purl, "record_unavailable"); +} + /// With a token the CLI uses the org-scoped route, which the stand-in /// serves too. #[test] diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/gem.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/gem.rs index b79513d0e..f023f6e00 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/gem.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/gem.rs @@ -445,7 +445,9 @@ fn vendored_spoofed_paths_are_not_references() { // ── embedded ────────────────────────────────────────────────────────── /// The embedded `apply --vex` / `vendor --vex` of a manifest-less checkout -/// attest exactly what standalone `vex` does, in every era. +/// attest exactly what standalone `vex` does, in every era. (`vendor` over a +/// HOSTED checkout is the v5 eject flow, not a manifest-less no-op, so the +/// hosted cells drive `apply` only.) #[test] fn embedded_vex_agrees_in_every_era() { for era in ERAS { @@ -453,7 +455,12 @@ fn embedded_vex_agrees_in_every_era() { ("hosted", Marker::Redirected), ("vendored", Marker::Vendored), ] { - for via in [VexVia::Apply, VexVia::Vendor] { + let vias: &[VexVia] = if mode == "hosted" { + &[VexVia::Apply] + } else { + &[VexVia::Apply, VexVia::Vendor] + }; + for &via in vias { let api = api(); let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path(); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/manifestless_embedded.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/manifestless_embedded.rs index 7d46cd424..49ace8cec 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/manifestless_embedded.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/manifestless_embedded.rs @@ -18,6 +18,11 @@ //! - `--dry-run` => no generation, no document, no network; //! - `apply --check` => no generation, the output path untouched, no //! network (read-only, like the with-manifest `--check`). +//! +//! `vendor` on a checkout whose lockfiles carry HOSTED pins is not a +//! no-manifest no-op any more: it EJECTS the pins into `.socket/vendor/` +//! (v5 WS2), so the hosted-wiring cells below drive `apply` only, and +//! [`vendor_on_a_hosted_checkout_takes_the_eject_path`] pins the switch. use crate::vex_e2e_common; @@ -28,6 +33,9 @@ const GHSA: &str = "GHSA-mfls-embd-0001"; const CVE: &str = "CVE-2026-5151"; const EMBEDDED: [VexVia; 2] = [VexVia::Apply, VexVia::Vendor]; +/// The embedded commands that treat a HOSTED-wired checkout as manifest-less +/// (`vendor` ejects it instead). +const EMBEDDED_OVER_HOSTED: [VexVia; 1] = [VexVia::Apply]; fn api() -> PatchApi { PatchApi::start(vec![( @@ -56,7 +64,7 @@ fn write_stale_doc(project: &std::path::Path) { #[test] fn manifest_less_embedded_vex_attests_hosted_wiring() { - for via in EMBEDDED { + for via in EMBEDDED_OVER_HOSTED { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path(); let purl = write_hosted_npm_lock(p, "left-pad", "1.3.0", UUID); @@ -156,7 +164,7 @@ fn nothing_discovered_still_reports_discovery_diagnostics() { #[test] fn vex_failure_without_manifest_fails_the_command() { - for via in EMBEDDED { + for via in EMBEDDED_OVER_HOSTED { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path(); write_hosted_npm_lock(p, "left-pad", "1.3.0", UUID); @@ -235,7 +243,11 @@ fn dry_run_skips_manifest_less_vex() { for via in EMBEDDED { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path(); - write_hosted_npm_lock(p, "left-pad", "1.3.0", UUID); + // `vendor` over hosted pins is the eject flow; its no-manifest + // dry-run skip is pinned on a checkout without them. + if via == VexVia::Apply { + write_hosted_npm_lock(p, "left-pad", "1.3.0", UUID); + } let api = api(); let run = VexRun { dry_run: true, @@ -268,3 +280,49 @@ fn dry_run_skips_manifest_less_vex() { api.assert_no_requests(); } } + +/// v5 WS2: `vendor` with no manifest and HOSTED pins in the lockfiles is the +/// eject flow, not the calm no-manifest no-op — it fetches each pin's patch +/// record from the API. With the API not serving the patch the purl fails +/// `patch_fetch_failed`, the command exits 1, no document is written, and +/// the hosted lock is left as it was. +#[test] +fn vendor_on_a_hosted_checkout_takes_the_eject_path() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path(); + let purl = write_hosted_npm_lock(p, "left-pad", "1.3.0", UUID); + let lock_before = std::fs::read(p.join("package-lock.json")).unwrap(); + let api = PatchApi::empty(); + let out = run_vex(&binary(), p, &VexRun::online(&api).via(VexVia::Vendor)); + assert_eq!(out.code, Some(1), "{out}"); + assert_ne!(out.envelope["status"], "noManifest", "{out}"); + let failed = out.envelope["events"] + .as_array() + .into_iter() + .flatten() + .find(|e| e["action"] == "failed" && e["purl"] == purl.as_str()) + .unwrap_or_else(|| panic!("the hosted pin must be an eject candidate: {out}")); + assert_eq!(failed["errorCode"], "patch_fetch_failed", "{out}"); + assert!(api.view_requests(UUID) >= 1, "{:?}", api.requests()); + assert!(out.envelope.get("vex").is_none(), "{out}"); + assert!(out.doc.is_none(), "{out}"); + assert_eq!( + std::fs::read(p.join("package-lock.json")).unwrap(), + lock_before, + "a failed eject leaves the hosted lock alone" + ); + assert_no_hosted_ledger(p, "vendor eject"); + + // Human mode names the eject. + let human = VexRun { + human: true, + ..VexRun::online(&api).via(VexVia::Vendor) + }; + let out = run_vex(&binary(), p, &human); + assert_eq!(out.code, Some(1), "{out}"); + assert!( + out.stdout + .contains("Ejecting 1 hosted package into .socket/vendor/..."), + "{out}" + ); +} diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs index 4683f0198..96947ce6f 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs @@ -1190,8 +1190,8 @@ fn vendored_spoofed_locations_never_attest() { // ══════════════════════════════════════════════════════════════════════════ // EMBEDDED — the REAL writers lay the wiring down (`vendor --vex`, // `scan --mode hosted --vex`, `apply --vex`); then the manifest and the -// ledgers are deleted and the standalone `vex` must re-attest from the -// project files alone. +// vendor ledger are deleted (v5 hosted mode writes no ledger at all) and +// the standalone `vex` must re-attest from the project files alone. // ══════════════════════════════════════════════════════════════════════════ const ORG: &str = "test-org"; @@ -1310,6 +1310,43 @@ fn standalone_after_writer( } } +/// After the real HOSTED writer ran: v5 hosted mode left NO ledger, so +/// the lock pin is the only hosted state — offline there is no local +/// record (`record_unavailable`, zero requests), online the API's record +/// attests, and with the wiring reverted nothing is discovered at all. +fn standalone_after_hosted_writer( + fx: &Fx, + uuid: &str, + record_purl: &str, + api_view: Value, + revert: &dyn Fn(&Fx), +) { + fx.rm(".socket/manifest.json"); + fx.rm(".socket/blobs"); + assert!( + !fx.cwd.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no ledger" + ); + let api = Api::serve(vec![(uuid, api_view)]); + let (code, env) = fx.vex(&["--offline", "--proxy-url", &api.uri()]); + assert_omitted( + code, + &env, + record_purl, + "record_unavailable", + "hosted writer, offline", + ); + assert_eq!(api.requests(), 0, "--offline never asks the API"); + let (code, env) = fx.vex(&["--proxy-url", &api.uri()]); + assert_attested(fx, code, &env, uuid, record_purl, "redirected"); + + revert(fx); + for extra in [&["--offline"][..], &["--offline", "--no-verify"][..]] { + let (code, env) = fx.vex(extra); + assert_nothing_to_attest(code, &env, "hosted writer, reverted"); + } +} + /// A plausible upstream pom for the cached artifact (the maven vendor /// backend copies it verbatim next to the rebuilt jar). const COMMONS_TEXT_POM: &str = "\n \ @@ -1475,8 +1512,9 @@ fn scan_hosted_vex(fx: &Fx, api: &Api) -> (Option, Value, String) { /// `scan --mode hosted --vex` against a project whose pristine base /// version is cached: the real rewriter pins `-socket.`, the in-run -/// VEX attests; the pristine base is never the consumed copy, so the -/// standalone vex keeps attesting from the pin with no manifest/ledger. +/// VEX attests and no ledger is written; the pristine base is never the +/// consumed copy, so the standalone vex keeps attesting from the pin (with +/// the API's record) with no manifest/ledger. #[test] fn maven_scan_hosted_wiring_reattests_without_manifest_or_ledger() { let golden = "maven/pom/basic"; @@ -1496,15 +1534,12 @@ fn maven_scan_hosted_wiring_reattests_without_manifest_or_ledger() { ); let reverted = std::fs::read_to_string(fixture_dir(&format!("{golden}/input/pom.xml"))).unwrap(); - standalone_after_writer( + standalone_after_hosted_writer( &fx, - ".socket/vendor/redirect-state.json", MVN_HOSTED_UUID, MVN_PURL, - "redirected", mvn_hosted_view(), &|fx| fx.put("pom.xml", &reverted), - "redirect_unwired", ); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/npm.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/npm.rs index 3fdfe8b19..f63340878 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/npm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/npm.rs @@ -494,7 +494,10 @@ fn json_envelopes_carry_why_a_patch_was_gated() { "{out}" ); - for via in [VexVia::Apply, VexVia::Vendor] { + // Embedded: `apply --vex` (on this HOSTED checkout `vendor` is the v5 + // eject flow, not a manifest-less VEX run). + { + let via = VexVia::Apply; let out = run_vex(&binary(), p, &unreachable.clone().via(via)); assert_eq!(out.code, Some(1), "{via:?}:\n{out}"); let warnings = warnings_of(&out.envelope); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs index 007fcd401..3973a9e7b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs @@ -1153,6 +1153,44 @@ fn standalone_after_writer( } } +/// After the real HOSTED writer ran: v5 hosted mode left NO ledger, so +/// the pin (lock + nuget.config) is the only hosted state — offline there +/// is no local record (`record_unavailable`, zero requests), online the +/// API's record attests, and with the wiring reverted nothing is +/// discovered at all. +fn standalone_after_hosted_writer( + fx: &Fx, + uuid: &str, + record_purl: &str, + api_view: Value, + revert: &dyn Fn(&Fx), +) { + fx.rm(".socket/manifest.json"); + fx.rm(".socket/blobs"); + assert!( + !fx.cwd.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no ledger" + ); + let api = Api::serve(vec![(uuid, api_view)]); + let (code, env) = fx.vex(&["--offline", "--proxy-url", &api.uri()]); + assert_omitted( + code, + &env, + record_purl, + "record_unavailable", + "hosted writer, offline", + ); + assert_eq!(api.requests(), 0, "--offline never asks the API"); + let (code, env) = fx.vex(&["--proxy-url", &api.uri()]); + assert_attested(fx, code, &env, uuid, record_purl, "redirected"); + + revert(fx); + for extra in [&["--offline"][..], &["--offline", "--no-verify"][..]] { + let (code, env) = fx.vex(extra); + assert_nothing_to_attest(code, &env, "hosted writer, reverted"); + } +} + #[test] fn nuget_vendor_command_wiring_reattests_without_manifest_or_ledger() { let fx = Fx::new(); @@ -1359,9 +1397,16 @@ fn nuget_scan_hosted_wiring_reattests_without_manifest_or_ledger() { "{config}" ); - // The pristine shared-folder copy is installed evidence: omitted. + assert!( + !fx.cwd.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no ledger" + ); + + // The pristine shared-folder copy is installed evidence: omitted (the + // record comes from the API — the scan left no local one). fx.rm(".socket/manifest.json"); - let (code, env) = fx.vex(&["--offline"]); + let view_api = Api::serve(vec![(NUGET_HOSTED_UUID, nuget_hosted_view())]); + let (code, env) = fx.vex(&["--proxy-url", &view_api.uri()]); assert_omitted( code, &env, @@ -1372,19 +1417,16 @@ fn nuget_scan_hosted_wiring_reattests_without_manifest_or_ledger() { // Cleared (a fresh CI restore would fetch the patched nupkg): the pin. std::fs::remove_dir_all(fx.nuget().join("newtonsoft.json")).unwrap(); let inputs = copy_golden_to_vec(&format!("{golden}/input")); - standalone_after_writer( + standalone_after_hosted_writer( &fx, - ".socket/vendor/redirect-state.json", NUGET_HOSTED_UUID, NUGET_PURL, - "redirected", nuget_hosted_view(), &|fx| { for (file, bytes) in &inputs { fx.put(file, bytes); } }, - "redirect_unwired", ); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pdm.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pdm.rs index c2b1300e0..f2678626d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pdm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pdm.rs @@ -19,8 +19,10 @@ //! //! The cells (see `vex_pdm_hatch_common`) run for every (release, mode): //! a) wiring-only checkout attests online; b) `record_unavailable` offline / -//! unreachable / 404 with zero requests offline; c) ledger without manifest -//! attests offline; d) reverted lock is unwired even with `--no-verify`; +//! unreachable / 404 with zero requests offline; c) the vendor ledger +//! without manifest attests offline (v5 hosted keeps no ledger: offline it +//! is `record_unavailable`, online it attests); d) reverted lock is unwired +//! even with `--no-verify` (hosted: nothing is discovered); //! e) tampered installed tree / wheel member omitted; f) foreign host, //! root-escaping path and mismatched records never attest; g) hosted //! installed-tree states (not installed → pin, patched → hashed, pristine → diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs index f730b5ce9..efef9f43b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pip.rs @@ -267,9 +267,8 @@ fn sibling_requirements_file_the_root_never_includes_is_not_read() { } /// A wired line commented out (`# vexdemo @ …` / `# ./.socket/vendor/…`) -/// is not something pip installs: not wiring, and with the ledger kept the -/// ledger claim is dead (`redirect_unwired` / `vendor_unwired`), even -/// under `--no-verify`. +/// is not something pip installs: not wiring, and with the vendor ledger +/// kept its claim is dead (`vendor_unwired`), even under `--no-verify`. #[test] fn commented_out_wiring_is_not_a_reference() { let root = &flavors()[0]; @@ -280,7 +279,7 @@ fn commented_out_wiring_is_not_a_reference() { .map(|l| format!("# {l}\n")) .collect::() + "vexdemo==1.2.3\n"; - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in mode.ledger_keeps() { let what = format!("{} commented ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs index e63251105..f076a1608 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs @@ -13,9 +13,10 @@ //! //! Every cell of `vex_pipenv_pip_common` runs per (flavor, mode): wiring //! alone attests online; `--offline` / unreachable / 404 / 403 with no -//! local record is `record_unavailable` (zero requests offline); ledger-only -//! attests offline; a reverted lock is `redirect_unwired` / -//! `vendor_unwired` under `--no-verify` too; tampered installed tree / +//! local record is `record_unavailable` (zero requests offline); the vendor +//! ledger alone attests offline (v5 hosted keeps no ledger: offline it is +//! `record_unavailable`); a reverted lock is `vendor_unwired` under +//! `--no-verify` too (hosted: nothing is discovered); tampered installed tree / //! wheel member is omitted; foreign-host, root-escaping and //! record-mismatched references never attest; hosted not-installed attests //! from the pin, a pristine install is `not_applied`, pinless needs an @@ -25,9 +26,8 @@ //! //! Pipenv-specific cells below: a relock that re-serializes AROUND our //! reference (Pipenv 2023+ restores `version` / `index` / registry -//! `hashes`) still attests unless the restored version disagrees, in which -//! case even the ledger claim is dead; an entry carrying both `file` and -//! `path` is ambiguous; and the vendored backend refusing a legacy installer +//! `hashes`) still attests unless the restored version disagrees; an entry +//! carrying both `file` and `path` is ambiguous; and the vendored backend refusing a legacy installer //! leaves nothing to attest. //! //! The real-Pipenv counterpart (every calendar major 2022..2026, real @@ -213,9 +213,8 @@ fn edit_entry(lock: &str, edit: impl Fn(&mut serde_json::Map)) -> /// but restores the registry `version` / `index` / `hashes` around it /// (`reserialized_around_reference`). Pipenv still installs the reference, /// so it is still wired: attested with no ledger. When the restored -/// `version` names ANOTHER release the entry is diagnosed, and the stale -/// ledger claim dies with it (discover rule 11: a recognized-but-rejected -/// mention is authoritative), `--no-verify` included. +/// `version` names ANOTHER release the entry is diagnosed and never +/// attested, `--no-verify` included. #[test] fn relock_reserialized_around_the_reference_attests_unless_the_version_disagrees() { for flavor in flavors().into_iter().filter(|f| f.pipenv_major == "2026") { @@ -246,34 +245,26 @@ fn relock_reserialized_around_the_reference_attests_unless_the_version_disagrees &format!("{what} hybrid"), ); - for keep in [NOTHING, LEDGERS_ONLY] { - let (_tmp, cwd) = fresh(); - wired.restore(&cwd, keep); - put(&cwd, "Pipfile.lock", hybrid("9.9.9").as_bytes()); - for no_verify in [false, true] { - let run = VexRun { - no_verify, - offline: keep.ledgers, - ..vex_run(Some(&api)) - }; - let out = vex(&cwd, &run); - let what = format!( - "{what} hybrid@9.9.9 ledgers={} no_verify={no_verify}", - keep.ledgers - ); - assert_ne!(out.code, Some(0), "{what}: {out}"); - assert_no_statement(&out, &what); - if keep.ledgers { - assert_omitted(&out, "redirect_unwired", &what); - } - } + let (_tmp, cwd) = fresh(); + wired.restore(&cwd, NOTHING); + put(&cwd, "Pipfile.lock", hybrid("9.9.9").as_bytes()); + for no_verify in [false, true] { + let run = VexRun { + no_verify, + ..vex_run(Some(&api)) + }; + let out = vex(&cwd, &run); + let what = format!("{what} hybrid@9.9.9 no_verify={no_verify}"); + assert_ne!(out.code, Some(0), "{what}: {out}"); + assert_no_statement(&out, &what); } } } /// An entry carrying BOTH `file` and `path` (neither writer produces one) /// is ambiguous — which one Pipenv reads depends on its release — so it is -/// diagnosed, never attested, and it kills the stale ledger claim. +/// diagnosed, never attested, and (vendored) it kills the stale ledger +/// claim. #[test] fn entry_with_both_file_and_path_is_ambiguous() { let flavor = &flavors()[0]; @@ -289,7 +280,7 @@ fn entry_with_both_file_and_path_is_ambiguous() { let value = entry[have].clone(); entry.insert(add.into(), value); }); - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in mode.ledger_keeps() { let what = format!("{} both keys ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs index 30cefb670..8601113ee 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs @@ -1014,10 +1014,17 @@ fn every_hosted_pin_spelling_attests_and_a_pinless_entry_needs_an_install() { let api = api_for(Mode::Hosted); let files_line = format!("files = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]"); let metadata_entry = format!("{PKG} = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]"); + // A `[metadata.files]` entry that listed files before the rewrite + // keeps Poetry's one-file-per-line layout (rollback restores the full + // list from it; an inline entry means the original was `[]`). + let metadata_block = format!( + "{PKG} = [\n {{file = \"{WHEEL}\", hash = \"sha256:{sha}\"}},\n]" + ); let fragment = format!("#sha256={sha}&"); let spellings: Vec<(&str, &str)> = [ ("package files", files_line.as_str()), ("metadata.files", metadata_entry.as_str()), + ("metadata.files block", metadata_block.as_str()), ("url fragment", fragment.as_str()), ] .into_iter() @@ -1422,12 +1429,13 @@ fn embedded(p: &Proj, api: &PatchApi, via: VexVia, offline: bool, extra: &[&str] } /// `scan --redirect --vex` on a lock-only checkout (nothing installed) -/// writes the hosted wiring + the redirect ledger and attests in-run; then, -/// with no manifest: -/// c. the ledger alone attests offline (standalone and `apply --vex`); -/// b. without the ledger, offline is `record_unavailable` with no request; -/// a. without the ledger, online attests (standalone and `apply --vex`); -/// d. `rollback` unwinds the wiring; with the ledger put back it is dead. +/// writes the hosted wiring — and NO ledger (v5) — and attests in-run from +/// this run's records; then, with no manifest: +/// b. offline is `record_unavailable` with no request (standalone and +/// `apply --vex`): the lock is the only hosted state, it carries no +/// record; +/// a. online attests (standalone and `apply --vex`); +/// d. the wiring reverted to the native files: nothing is discovered. /// /// Both a production `patch.socket.dev` artifact url (nothing is fetched /// from it) and a self-hosted patch server (the mock's own origin, which @@ -1472,7 +1480,10 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { "{what}: lock not wired:\n{lock}" ); assert!(lock.contains("type = \"url\""), "{what}:\n{lock}"); - assert!(p.exists(".socket/vendor/redirect-state.json"), "{what}"); + assert!( + !p.exists(".socket/vendor/redirect-state.json"), + "{what}: v5 hosted mode writes no ledger" + ); assert!( !p.exists(".socket/manifest.json"), "{what}: hosted writes no manifest" @@ -1487,19 +1498,7 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { let origin: Vec<&str> = origin_flag.iter().map(String::as_str).collect(); let api = api_for(Mode::Hosted); - // c. the writer's ledger attests offline. - let out = vex(&p, &api, true, &origin); - assert_attested(&format!("{what} ledger"), &out, Mode::Hosted, HOSTED_UUID); - let out = embedded(&p, &api, VexVia::Apply, true, &origin); - assert_eq!(out.code, Some(0), "{what} apply --vex ledger: {out}"); - assert_eq!(out.envelope["status"], "noManifest", "{what}: {out}"); - assert_eq!(out.envelope["vex"]["statements"], 1, "{what}: {out}"); - api.assert_no_requests(); - - // b / a. no ledger. - let ledger_path = p.root.join(".socket/vendor/redirect-state.json"); - let ledger = std::fs::read(&ledger_path).unwrap(); - strip_ledgers(&p); + // b / a. the wiring alone (the writer left no ledger). let out = vex(&p, &api, true, &origin); assert_omitted( &format!("{what} no ledger offline"), @@ -1519,6 +1518,8 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { ); let out = embedded(&p, &api, VexVia::Apply, false, &origin); assert_eq!(out.code, Some(0), "{what} apply --vex online: {out}"); + assert_eq!(out.envelope["status"], "noManifest", "{what}: {out}"); + assert_eq!(out.envelope["vex"]["statements"], 1, "{what}: {out}"); vex_e2e_common::assert_attested( out.doc(), &purl(), @@ -1528,32 +1529,21 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { ); assert_no_manifest_written(&p, &what); - // d. the real revert, then the ledger restored behind its back. - std::fs::write(&ledger_path, &ledger).unwrap(); - let (code, env) = run_authed(&p, &scan, &["rollback"]); - assert_eq!(code, Some(0), "{what} rollback: {env}"); - for (name, text) in native_files(release) { - assert_eq!(p.read(name), text, "{what}: rollback restores {name}"); - } - // A fully reverted project keeps no `.socket/` at all: recreate - // the directory the stale ledger is planted back into. - std::fs::create_dir_all(ledger_path.parent().unwrap()).unwrap(); - std::fs::write(&ledger_path, &ledger).unwrap(); + // d. the wiring reverted to the native files: no hosted state + // is left anywhere, so nothing is discovered, offline or online. + p.write_files(&native_files(release)); for extra in [&[][..], &["--no-verify"][..]] { let mut args = origin.clone(); args.extend_from_slice(extra); - let out = vex(&p, &api, true, &args); - assert_omitted( - &format!("{what} reverted {extra:?}"), - &out, - "redirect_unwired", - ); - let out = vex(&p, &api, false, &args); - assert_omitted( - &format!("{what} reverted online {extra:?}"), - &out, - "redirect_unwired", - ); + for offline in [true, false] { + let out = vex(&p, &api, offline, &args); + let cell = format!("{what} reverted offline={offline} {extra:?}"); + assert_eq!(out.code, Some(2), "{cell}: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{cell}: {out}" + ); + } } let _ = scan.requests(); } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs index e8777484b..5a9f79603 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs @@ -1517,12 +1517,12 @@ fn writer_flavors() -> Vec { /// `scan --redirect --vex` on a lock-only checkout (nothing installed; an /// empty in-project venv keeps the crawl off the host interpreters) writes -/// the hosted wiring + the redirect ledger and attests in-run; afterwards, -/// with no manifest: -/// c. the ledger alone attests offline; -/// b. without the ledger, offline is `record_unavailable` with no request; -/// a. without the ledger, online (public-proxy view) attests; -/// d. `rollback` unwinds the wiring; with the ledger put back, it is dead. +/// the hosted wiring — and NO ledger (v5) — and attests in-run from this +/// run's records; afterwards, with no manifest: +/// b. offline is `record_unavailable` with no request (the lock carries +/// no record, and nothing else local does); +/// a. online (public-proxy view) attests; +/// d. the wiring reverted to the native files: nothing is discovered. /// /// uv locks are wired to the mock's origin (the writer fetches the hosted /// wheel's METADATA for them), so `vex` is told that origin is the patch @@ -1568,7 +1568,10 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { lock.contains(&artifact_url), "{what}: lock not wired:\n{lock}" ); - assert!(p.exists(".socket/vendor/redirect-state.json"), "{what}"); + assert!( + !p.exists(".socket/vendor/redirect-state.json"), + "{what}: v5 hosted mode writes no ledger" + ); assert!( !p.exists(".socket/manifest.json"), "{what}: hosted writes no manifest" @@ -1582,25 +1585,9 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { Vec::new() }; - // c. the writer's ledger attests offline. + // b / a. the wiring alone (the writer left no ledger). let before = api.requests(); let (code, env, doc) = vex_offline(&p, &api, &origin); - assert_attested( - &format!("{what} ledger"), - code, - &env, - &doc, - &flavor.api_purl(), - HOSTED_UUID, - Mode::Hosted, - ); - assert_eq!(api.requests(), before, "{what}: offline made a request"); - - // b / a. no ledger. - let ledger_path = p.root.join(".socket/vendor/redirect-state.json"); - let ledger = std::fs::read(&ledger_path).unwrap(); - std::fs::remove_file(&ledger_path).unwrap(); - let (code, env, doc) = vex_offline(&p, &api, &origin); assert_omitted( &format!("{what} no ledger offline"), code, @@ -1622,29 +1609,17 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { ); assert_no_manifest_written(&p, &what); - // d. the real revert, then the ledger restored behind its back. - std::fs::write(&ledger_path, &ledger).unwrap(); - let (code, env) = run_authed(&p, &api, &["rollback"]); - assert_eq!(code, Some(0), "{what} rollback: {env}"); - for (name, text) in flavor.native_files() { - assert_eq!(p.read(name), text, "{what}: rollback restores {name}"); - } - // A fully reverted project keeps no `.socket/` at all: recreate the - // directory the stale ledger is planted back into. - std::fs::create_dir_all(ledger_path.parent().unwrap()).unwrap(); - std::fs::write(&ledger_path, &ledger).unwrap(); + // d. the wiring reverted to the native files: no hosted state is + // left anywhere, so nothing is discovered. + p.write_files(&flavor.native_files()); for extra in [&[][..], &["--no-verify"][..]] { let mut args = origin.clone(); args.extend_from_slice(extra); let (code, env, doc) = vex_offline(&p, &api, &args); - assert_omitted( - &format!("{what} reverted {extra:?}"), - code, - &env, - &doc, - &flavor.purl(), - "redirect_unwired", - ); + let cell = format!("{what} reverted {extra:?}"); + assert_eq!(code, Some(2), "{cell}: {env}"); + assert_eq!(env["error"]["code"], "manifest_not_found", "{cell}: {env}"); + assert!(doc.is_none(), "{cell}: {doc:?}"); } } } @@ -1841,11 +1816,14 @@ fn embedded_vex(p: &Proj, command: &str, extra: &[&str]) -> (Option, Value, /// the embedded VEX (`generate_vex_without_manifest`) rather than return /// `noManifest` / exit 0 with the requested document silently never /// written. Every flavor × mode, with -/// the writer's ledger and fully offline: the document is written (exit 0, -/// envelope `vex` summary) with the right marker; `--dry-run` writes none; -/// the lock reverted with the ledger left behind fails the command (exit 1, +/// a committed ledger (vendored: the vendor ledger; hosted: a PRE-v5 +/// redirect ledger, which v5 still reads as a local record source) and +/// fully offline: the document is written (exit 0, envelope `vex` summary) +/// with the right marker; `--dry-run` writes none; the lock reverted with +/// the ledger left behind fails the command (exit 1, /// `no_applicable_patches`) and leaves no stale document; and a project with -/// NOTHING wired keeps the calm no-op. +/// NOTHING wired keeps the calm no-op. `vendor` skips the hosted cells: over +/// hosted pins it is the v5 eject flow, not a manifest-less VEX run. #[test] fn embedded_apply_and_vendor_vex_attest_manifest_less_checkouts() { for command in ["apply", "vendor"] { @@ -1859,6 +1837,9 @@ fn embedded_apply_and_vendor_vex_attest_manifest_less_checkouts() { assert!(doc.is_none(), "{command}: nothing to attest, no document"); for mode in [Mode::Hosted, Mode::Vendored] { + if command == "vendor" && mode == Mode::Hosted { + continue; + } for flavor in flavors(mode) { let what = format!("{command} --vex {} {mode:?}", flavor.label()); let p = Proj::new(); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/vlt.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/vlt.rs index 5ee6dce16..14ea135dd 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/vlt.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/vlt.rs @@ -14,9 +14,10 @@ //! lock over an importer copy; then vlt's installed state is staged (the //! importer copy and `node_modules/.vlt//node_modules/left-pad`) //! and, with the manifest deleted: -//! - the redirect ledger's embedded record attests offline; -//! - with the ledgers deleted too, installed + patched attests -//! `(redirected)` from the patch API's record; +//! - the scan wrote no ledger (v5), so offline there is no local record +//! (`record_unavailable`, zero requests); +//! - installed + patched attests `(redirected)` from the patch API's +//! record; //! - a tampered or pristine store copy is omitted (`hash_mismatch` / //! `not_applied`) even though the importer copy is patched; //! - not installed attests from the lock's sha512 pin, except in the @@ -120,7 +121,7 @@ fn hosted_project(tmp: &Path, era: Era, tag: &str) -> HostedProject { "[{tag}] the lock must pin the hosted tarball:\n{lock}" ); assert!(!root.join(".socket/manifest.json").exists()); - assert!(hosted::ledger_path(&root).is_file(), "[{tag}]"); + vlt_vex::assert_no_hosted_ledger(&root, &format!("[{tag}]")); HostedProject { root, origin: server.uri(), @@ -153,10 +154,9 @@ fn hosted_every_vlt_era_manifestless_evidence_cells() { ..VexRun::offline() }, ); - assert_eq!(out.code, Some(0), "[{tag}] ledger, offline: {out}"); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, VULNS); + assert_eq!(out.code, Some(1), "[{tag}] no local record, offline: {out}"); + assert_not_attested(&out.envelope, PURL, "record_unavailable"); silent.assert_no_requests(); - strip_ledgers(root); let api = api_with(UUID, PURL); let out = run_vex(&bin, root, &online(&api)); diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs index 59aa1bec4..ff575b68d 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_pnpm_linker_build.rs @@ -378,7 +378,10 @@ fn fresh_checkout_install(tmp: &Path, proj: &Path, yarnrc: &str) -> (PathBuf, Ou std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); std::fs::copy(proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap(); std::fs::write(fresh.join(".yarnrc.yml"), yarnrc).unwrap(); - copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + // v5 hosted mode may leave no `.socket/` at all (no ledger, no manifest). + if proj.join(".socket").is_dir() { + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + } let fresh_global = tmp.join("fresh-yarn-global"); let ci = corepack( &fresh, diff --git a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs index b0b1883b8..bbffec465 100644 --- a/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs +++ b/crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs @@ -375,7 +375,10 @@ fn fresh_checkout_install(tmp: &Path, proj: &Path, yarnrc: &str) -> (PathBuf, Ou .unwrap(); std::fs::copy(proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap(); std::fs::write(fresh.join(".yarnrc.yml"), yarnrc).unwrap(); - copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + // v5 hosted mode may leave no `.socket/` at all (no ledger, no manifest). + if proj.join(".socket").is_dir() { + copy_dir_recursive(&proj.join(".socket"), &fresh.join(".socket")); + } let fresh_global = tmp.join("fresh-yarn-global"); let ci = corepack( &fresh, diff --git a/crates/socket-patch-cli/tests/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get_modes_e2e.rs index a50a6bc0e..c815575af 100644 --- a/crates/socket-patch-cli/tests/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get_modes_e2e.rs @@ -553,10 +553,12 @@ async fn get_hosted_silent_prints_nothing_to_stdout() { lock.contains(HOSTED_URL1), "silent run must still redirect; lock:\n{lock}" ); - assert!(tmp - .path() - .join(".socket/vendor/redirect-state.json") - .is_file()); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "v5 hosted mode writes no redirect ledger" + ); // Loud control: without --silent the human path prints the redirect // summary — otherwise the empty-stdout assertion above proves nothing. @@ -781,12 +783,13 @@ async fn get_vendored_then_hosted_takes_over_cleanly() { "the vendored ledger must no longer claim the purl; got:\n{state}" ); } - // Hosted ledger present with the record. - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert_eq!(ledger["records"][PURL1]["uuid"], UUID1); + // v5: the lock pin is the whole hosted state — no redirect ledger. + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "v5 hosted mode writes no redirect ledger" + ); // The takeover is announced, not silent. assert!( stdout.contains("redirect_takeover_reverted_vendored"), diff --git a/crates/socket-patch-cli/tests/hosted_management_refusals.rs b/crates/socket-patch-cli/tests/hosted_management_refusals.rs new file mode 100644 index 000000000..2d84dd983 --- /dev/null +++ b/crates/socket-patch-cli/tests/hosted_management_refusals.rs @@ -0,0 +1,318 @@ +//! Management commands over hosted state they cannot act on safely: +//! +//! * contested hosted wiring (a hosted `npm-shrinkwrap.json` beside an +//! upstream `package-lock.json`) is hosted state, so `rollback`, `list` +//! and `vendor` refuse and name it instead of reporting a bare project; +//! * `vendor` ejecting a hosted project needs every patch record from the +//! API, so an offline run (flag or env, wet or dry) refuses before it +//! sends a single request. + +use std::path::Path; +use std::process::Command; + +use serde_json::Value; +use wiremock::MockServer; + +const PATCH: &str = "11111111-1111-4111-8111-111111111111"; +const GRANT: &str = "22222222-2222-4222-8222-222222222222"; + +fn cli() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + 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"); + cmd +} + +fn lock(resolved: &str, integrity: &str) -> String { + format!( + r#"{{"name":"app","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{{"":{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"1.3.0"}}}},"node_modules/left-pad":{{"version":"1.3.0","resolved":"{resolved}","integrity":"{integrity}"}}}}}}"# + ) +} + +fn hosted_lock() -> String { + lock( + &format!( + "https://patch.socket.dev/patch/npm/left-pad/1.3.0/{GRANT}/{PATCH}/left-pad-1.3.0.tgz" + ), + "sha512-patched==", + ) +} + +fn write_package_json(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{"name":"app","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); +} + +/// Hosted shrinkwrap, upstream package-lock: the locks disagree. +fn write_contested(root: &Path) { + write_package_json(root); + std::fs::write( + root.join("package-lock.json"), + lock( + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-upstream==", + ), + ) + .unwrap(); + std::fs::write(root.join("npm-shrinkwrap.json"), hosted_lock()).unwrap(); +} + +fn run_json(args: &[&str], cwd: &Path) -> (Option, Value) { + let out = cli() + .args(args) + .arg("--cwd") + .arg(cwd) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let v: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "{args:?}: stdout is not JSON ({e}): {stdout}\nstderr: {}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code(), v) +} + +fn snapshot(root: &Path) -> Vec<(String, String)> { + ["package-lock.json", "npm-shrinkwrap.json"] + .iter() + .map(|f| { + ( + f.to_string(), + std::fs::read_to_string(root.join(f)).unwrap_or_default(), + ) + }) + .collect() +} + +#[test] +fn rollback_refuses_contested_hosted_wiring_and_names_it() { + let tmp = tempfile::tempdir().unwrap(); + write_contested(tmp.path()); + let before = snapshot(tmp.path()); + let (code, v) = run_json(&["rollback", "--json", "--yes"], tmp.path()); + assert_eq!(code, Some(1), "{v}"); + let err = v["error"].as_str().unwrap_or_default(); + assert!( + err.contains("npm-shrinkwrap.json") && err.contains("git checkout --"), + "the refusal names the contested file and the remedy: {v}" + ); + assert!(!err.contains("Manifest not found"), "{v}"); + assert_eq!(snapshot(tmp.path()), before, "nothing was rewritten"); +} + +#[test] +fn list_reports_contested_hosted_wiring_instead_of_no_manifest() { + let tmp = tempfile::tempdir().unwrap(); + write_contested(tmp.path()); + let (code, v) = run_json(&["list", "--json"], tmp.path()); + assert_eq!(code, Some(1), "{v}"); + assert_eq!(v["error"]["code"], "hosted_wiring_contested", "{v}"); +} + +#[test] +fn vendor_refuses_to_eject_contested_hosted_wiring() { + let tmp = tempfile::tempdir().unwrap(); + write_contested(tmp.path()); + let before = snapshot(tmp.path()); + let (code, v) = run_json(&["vendor", "--offline", "--dry-run", "--json"], tmp.path()); + assert_eq!(code, Some(1), "{v}"); + assert_eq!(v["error"]["code"], "hosted_wiring_contested", "{v}"); + assert_eq!(snapshot(tmp.path()), before); + assert!(!tmp.path().join(".socket").exists()); +} + +/// An offline eject refuses with ZERO requests to the API, whether offline +/// comes from the flag or the env, and on a dry run too. +#[tokio::test] +async fn offline_eject_refuses_before_any_request() { + let server = MockServer::start().await; + for (flag, env, dry) in [ + (true, false, false), + (true, false, true), + (false, true, false), + (false, true, true), + ] { + let tmp = tempfile::tempdir().unwrap(); + write_package_json(tmp.path()); + std::fs::write(tmp.path().join("package-lock.json"), hosted_lock()).unwrap(); + let before = snapshot(tmp.path()); + let mut cmd = cli(); + cmd.args(["vendor", "--json", "--org", "test-org", "--api-token", "fake"]) + .arg("--api-url") + .arg(server.uri()) + .arg("--cwd") + .arg(tmp.path()); + if flag { + cmd.arg("--offline"); + } + if env { + cmd.env("SOCKET_OFFLINE", "1"); + } + if dry { + cmd.arg("--dry-run"); + } + let out = cmd.output().unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "flag={flag} env={env} dry={dry}: {e}: {}", + String::from_utf8_lossy(&out.stdout) + ) + }); + assert_eq!(out.status.code(), Some(1), "flag={flag} env={env} dry={dry}: {v}"); + assert_eq!(v["error"]["code"], "offline_eject_unavailable", "{v}"); + assert_eq!(snapshot(tmp.path()), before); + } + let received = server.received_requests().await.unwrap_or_default(); + assert!( + received.is_empty(), + "an offline eject must not touch the network: {:?}", + received.iter().map(|r| r.url.to_string()).collect::>() + ); +} + +// ── eject is one transaction ──────────────────────────────────────────────── + +const ORG: &str = "test-org"; +const MOCK_PATCH: &str = "33333333-3333-4333-8333-333333333333"; + +/// A fresh hosted checkout: package.json + a package-lock.json pinning +/// left-pad to the mock patch server, and NOTHING installed. +fn write_fresh_hosted_checkout(root: &Path, patch_origin: &str) -> String { + write_package_json(root); + let lock = lock( + &format!( + "{patch_origin}/patch/npm/left-pad/1.3.0/{GRANT}/{MOCK_PATCH}/left-pad-1.3.0.tgz" + ), + "sha512-patched==", + ); + std::fs::write(root.join("package-lock.json"), &lock).unwrap(); + lock +} + +async fn mount_view_and_registry(server: &MockServer, tarball_status: u16) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{MOCK_PATCH}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": MOCK_PATCH, + "purl": "pkg:npm/left-pad@1.3.0", + "publishedAt": "2024-01-01T00:00:00Z", + "files": { "package/index.js": { "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) } }, + "vulnerabilities": {}, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/npm-registry/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": { + "tarball": format!("{}/npm-registry/left-pad/-/left-pad-1.3.0.tgz", server.uri()), + "integrity": "sha512-upstream==", + } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/npm-registry/left-pad/-/left-pad-1.3.0.tgz")) + .respond_with(ResponseTemplate::new(tarball_status)) + .mount(server) + .await; +} + +fn eject_cmd(server: &MockServer, cwd: &Path, dry: bool) -> Command { + let mut cmd = cli(); + cmd.args(["vendor", "--json", "--org", ORG, "--api-token", "fake"]) + .args(["--vendor-source", "build"]) + .arg("--api-url") + .arg(server.uri()) + .arg("--patch-server-url") + .arg(server.uri()) + .arg("--cwd") + .arg(cwd) + .env("SOCKET_NPM_REGISTRY", format!("{}/npm-registry", server.uri())); + if dry { + cmd.arg("--dry-run"); + } + cmd +} + +/// Failure injection: the pristine tarball cannot be fetched, so vendoring +/// fails AFTER the upstream restore landed — the eject rolls everything +/// back and the project stays hosted, byte for byte. +#[tokio::test] +async fn failed_eject_rolls_back_and_keeps_the_project_hosted() { + let server = MockServer::start().await; + mount_view_and_registry(&server, 404).await; + let tmp = tempfile::tempdir().unwrap(); + let hosted = write_fresh_hosted_checkout(tmp.path(), &server.uri()); + let out = eject_cmd(&server, tmp.path(), false).output().unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "{e}: {}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + }); + assert_eq!(out.status.code(), Some(1), "{v}"); + assert!( + v["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "eject_rolled_back")), + "the rollback is announced: {v}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + hosted, + "the hosted pin survives a failed eject" + ); + assert!( + !tmp.path().join(".socket/vendor").exists() + || std::fs::read_dir(tmp.path().join(".socket/vendor")) + .unwrap() + .next() + .is_none(), + "no vendored residue" + ); + assert!( + !tmp.path().join(".npmrc").exists(), + "the restore's side-config cleanup is rolled back with the rest" + ); +} + +/// A dry-run eject verifies the plan (records, upstream restore) and writes +/// nothing. +#[tokio::test] +async fn dry_run_eject_verifies_the_plan_and_writes_nothing() { + let server = MockServer::start().await; + mount_view_and_registry(&server, 200).await; + let tmp = tempfile::tempdir().unwrap(); + let hosted = write_fresh_hosted_checkout(tmp.path(), &server.uri()); + let out = eject_cmd(&server, tmp.path(), true).output().unwrap(); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(out.status.code(), Some(0), "{v}"); + assert_eq!(v["dryRun"], true, "{v}"); + assert!( + v["events"] + .as_array() + .is_some_and(|e| e.iter().any(|e| e["purl"] == "pkg:npm/left-pad@1.3.0")), + "{v}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + hosted + ); + assert!(!tmp.path().join(".socket").exists(), "a dry run creates no .socket/"); +} diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index 8b57dfc1a..761d34ea9 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -89,10 +89,8 @@ async fn roots_sharing_a_purl_share_every_lookup() { paths, vec![ "a/.npmrc", - "a/.socket/vendor/redirect-state.json", "a/package-lock.json", "b/.npmrc", - "b/.socket/vendor/redirect-state.json", "b/package-lock.json" ] ); @@ -276,7 +274,16 @@ async fn malformed_and_deeply_nested_inputs_never_panic() { .iter() .find(|p| p.root == "deepjson") .unwrap(); - assert_eq!(deepjson.error.as_ref().unwrap().code, "corrupt_ledger"); + // The deep pre-v5 ledger is never parsed: whatever the project reports, + // it is not a ledger fault. + assert!( + deepjson + .error + .as_ref() + .is_none_or(|e| e.code != "corrupt_ledger"), + "{:?}", + deepjson.error + ); } #[test] @@ -484,7 +491,7 @@ async fn unauthorized_is_a_project_error_without_proxy_fallback() { } #[tokio::test] -async fn dry_run_previews_without_records_or_ledger() { +async fn dry_run_previews_without_records() { let server = npm_server().await; let output = run_engine(&server, build_input(&npm_files(), &[], options(true))).await; let paths: Vec<&str> = output @@ -584,8 +591,11 @@ async fn vendored_takeover_is_refused() { assert!(output.changed_files.is_empty()); } +/// A pre-v5 redirect ledger (`.socket/vendor/redirect-state.json`) is +/// never read by the v5 engine: a torn one neither fails its project nor +/// changes its plan, and the engine never emits (or rewrites) the file. #[tokio::test] -async fn corrupt_ledger_fails_only_its_project() { +async fn corrupt_pre_v5_ledger_is_ignored() { let server = npm_server().await; let mut repo = prefixed("good", &npm_files()); repo.extend(prefixed("bad", &npm_files())); @@ -596,14 +606,25 @@ async fn corrupt_ledger_fails_only_its_project() { let output = run_engine(&server, build_input(&repo, &[], options(false))).await; let bad = output.projects.iter().find(|p| p.root == "bad").unwrap(); let good = output.projects.iter().find(|p| p.root == "good").unwrap(); - assert_eq!(bad.error.as_ref().unwrap().code, "corrupt_ledger"); - assert_eq!(bad.redirect, serde_json::json!({ "mode": "hosted" })); - assert!(good.error.is_none()); + assert!(bad.error.is_none(), "{:?}", bad.error); + assert!(good.error.is_none(), "{:?}", good.error); + assert_eq!(bad.redirected.len(), 1); assert_eq!(good.redirected.len(), 1); - assert!(output + let paths: Vec<&str> = output .changed_files .iter() - .all(|f| !f.path.starts_with("bad/"))); + .map(|f| f.path.as_str()) + .collect(); + assert_eq!( + paths, + vec![ + "bad/.npmrc", + "bad/package-lock.json", + "good/.npmrc", + "good/package-lock.json" + ], + "the ledger is neither consumed nor emitted" + ); } #[tokio::test] diff --git a/crates/socket-patch-cli/tests/hosted_symlinked_files.rs b/crates/socket-patch-cli/tests/hosted_symlinked_files.rs index a3d2ca5a2..4ab06e221 100644 --- a/crates/socket-patch-cli/tests/hosted_symlinked_files.rs +++ b/crates/socket-patch-cli/tests/hosted_symlinked_files.rs @@ -9,8 +9,8 @@ //! restore bytes but never the link (git: a 120000→100644 typechange). The //! hosted REVERT side already refuses symlinked files fail-closed; these //! tests pin the WRITE side to the same policy: the whole (transactional) -//! rewrite is refused with a stable code, before the ledger and before any -//! file write. +//! rewrite is refused with a stable code before any file write (v5 hosted +//! mode keeps no ledger at all; the tests still pin that none appears). //! //! A FIFO planted under a candidate name (`pyproject.toml`) must be skipped //! like an unreadable file, never opened with a blocking `open(2)` that waits @@ -472,7 +472,10 @@ async fn hosted_rewrites_the_same_lock_once_it_is_a_regular_file() { assert!(std::fs::read_to_string(root.join("package-lock.json")) .unwrap() .contains(NPM_HOSTED_URL)); - assert!(root.join(LEDGER_REL).exists()); + assert!( + !root.join(LEDGER_REL).exists(), + "v5 hosted mode keeps no ledger: the lockfile is the record" + ); } /// A FIFO planted as `pyproject.toml` beside a real uv.lock: the candidate diff --git a/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs index 1a8c2a21d..f6ab3f7a3 100644 --- a/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_get_hosted_ecosystems.rs @@ -155,20 +155,11 @@ async fn reference_bodies(server: &MockServer) -> Vec { .collect() } -fn read_ledger(cwd: &Path) -> serde_json::Value { - let ledger_path = cwd.join(".socket/vendor/redirect-state.json"); - assert!( - ledger_path.is_file(), - "redirect ledger must be written at {}", - ledger_path.display() - ); - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()) - .expect("redirect-state.json parses") -} - -/// Hosted mode's persistence contract: the ledger IS the store — never the -/// manifest, never blobs (parity with `scan --mode hosted`). +/// Hosted mode's persistence contract: the lockfile edits ARE the store — +/// never the manifest, never blobs, and (v5) never the redirect ledger +/// (parity with `scan --mode hosted`). fn assert_no_manifest_no_blobs(cwd: &Path) { + vlt_hosted_common::assert_no_ledger(cwd); assert!( !cwd.join(".socket/manifest.json").exists(), "hosted mode must NOT write the manifest" @@ -186,7 +177,7 @@ fn assert_no_manifest_no_blobs(cwd: &Path) { /// A pip project pinning `requests==2.31.0`: the hosted grant must rewrite /// that one line to `requests @ --hash=sha256:` (the /// integrity pin fails closed on tampered bytes), leave the bystander line -/// byte-identical, record the ledger — and write no manifest. +/// byte-identical — and write no manifest and no ledger. #[tokio::test] #[serial] async fn pypi_requirements_hosted_rewrites_pinned_line() { @@ -235,20 +226,6 @@ async fn pypi_requirements_hosted_rewrites_pinned_line() { "the bystander line must survive byte-identical; got:\n{reqs}" ); - let ledger = read_ledger(tmp.path()); - assert_eq!(ledger["mode"], "hosted"); - assert_eq!( - ledger["records"][PURL]["uuid"], UUID, - "the ledger must record the redirected patch for VEX; got:\n{ledger}" - ); - assert!( - ledger["edits"] - .as_array() - .unwrap() - .iter() - .any(|e| e["path"] == "requirements.txt" && e["kind"] == "redirect_requirements_line"), - "the ledger must carry the requirements.txt edit (revert data); got:\n{ledger}" - ); assert_no_manifest_no_blobs(tmp.path()); let bodies = reference_bodies(&server).await; @@ -405,8 +382,6 @@ async fn maven_pom_hosted_pins_suffixed_version_fail_closed() { "maven.config must enable the trusted-checksums post-processor; got:\n{mvn_config}" ); - let ledger = read_ledger(tmp.path()); - assert_eq!(ledger["records"][PURL]["uuid"], UUID); assert_no_manifest_no_blobs(tmp.path()); let bodies = reference_bodies(&server).await; @@ -427,11 +402,14 @@ async fn maven_pom_hosted_pins_suffixed_version_fail_closed() { /// Manifest-less VEX over what `get --mode hosted` committed for a /// maven pom (nothing installed: the fail-closed suffixed pin is the -/// evidence): the ledger present, the ledgers deleted (pom wiring + API -/// record), `--offline` without a local record (`record_unavailable`, zero -/// requests), and the pom reverted with the ledger kept (`redirect_unwired`, -/// with and without `--no-verify`). Without `--patch-server-url` the -/// `patch.test` repository is not a Socket reference at all. +/// evidence). v5 `get` writes no ledger, so: attested from the pom wiring + +/// the API record; `--offline` without a local record → +/// `record_unavailable` (zero requests); a pre-v5 ledger carrying the +/// record is an extra local record source, so the same offline run then +/// attests; (before that ledger exists) without `--patch-server-url` the +/// `patch.test` repository is not a Socket reference at all, so there is +/// nothing to attest (exit 2); and the pom reverted with the legacy ledger +/// kept → `redirect_unwired`, with and without `--no-verify`. fn maven_hosted_get_state_attests_without_manifest( project: &Path, uuid: &str, @@ -440,15 +418,13 @@ fn maven_hosted_get_state_attests_without_manifest( ) { use vex_e2e_common::*; let vulns: [(&str, &[&str]); 1] = [("GHSA-hhhh-eeee-xxxx", &["CVE-2024-4321"])]; - let api = PatchApi::start(vec![( - uuid.to_string(), - patch_view( - uuid, - purl, - &[("commons-lang3-3.12.0.jar", &git_sha256(AFTER_BYTES))], - &vulns, - ), - )]); + let view = patch_view( + uuid, + purl, + &[("commons-lang3-3.12.0.jar", &git_sha256(AFTER_BYTES))], + &vulns, + ); + let api = PatchApi::start(vec![(uuid.to_string(), view.clone())]); let m2 = tempfile::tempdir().unwrap(); let run = VexRun { product: Some("pkg:maven/dev.socket.test/consumer@1.0.0".to_string()), @@ -457,31 +433,19 @@ fn maven_hosted_get_state_attests_without_manifest( } .env("MAVEN_REPO_LOCAL", m2.path().as_os_str()); + vlt_hosted_common::assert_no_ledger(project); let out = run_vex(&binary(), project, &run); assert_eq!(out.code, Some(0), "{out}"); assert_attested(out.doc(), purl, uuid, Marker::Redirected, &vulns); - - let ledger = std::fs::read(project.join(".socket/vendor/redirect-state.json")).unwrap(); - strip_ledgers(project); - let before = api.view_requests(uuid); - let out = run_vex(&binary(), project, &run); - assert_eq!(out.code, Some(0), "{out}"); - assert_attested(out.doc(), purl, uuid, Marker::Redirected, &vulns); - assert!( - api.view_requests(uuid) > before, - "record fetched from the API" - ); + assert!(api.view_requests(uuid) >= 1, "record fetched from the API"); let quiet = PatchApi::empty(); - let out = run_vex( - &binary(), - project, - &VexRun { - offline: true, - proxy_url: Some(quiet.uri()), - ..run.clone() - }, - ); + let offline = VexRun { + offline: true, + proxy_url: Some(quiet.uri()), + ..run.clone() + }; + let out = run_vex(&binary(), project, &offline); assert_eq!(out.code, Some(1), "{out}"); assert_not_attested(&out.envelope, purl, "record_unavailable"); quiet.assert_no_requests(); @@ -500,8 +464,16 @@ fn maven_hosted_get_state_attests_without_manifest( "not a Socket host without the override: {out}" ); + vlt_hosted_common::write_legacy_ledger( + project, + &[(purl, vlt_hosted_common::legacy_record_from_view(&view))], + ); + let out = run_vex(&binary(), project, &offline); + assert_eq!(out.code, Some(0), "a pre-v5 ledger record serves offline: {out}"); + assert_attested(out.doc(), purl, uuid, Marker::Redirected, &vulns); + quiet.assert_no_requests(); + std::fs::write(project.join("pom.xml"), pristine_pom).unwrap(); - std::fs::write(project.join(".socket/vendor/redirect-state.json"), &ledger).unwrap(); for no_verify in [false, true] { let out = run_vex( &binary(), @@ -634,8 +606,6 @@ async fn nuget_hosted_wires_source_mapping_and_lock_hash() { "the resolved version stays the normalized 13.0.3; got:\n{lock}" ); - let ledger = read_ledger(tmp.path()); - assert_eq!(ledger["records"][PURL]["uuid"], UUID); assert_no_manifest_no_blobs(tmp.path()); let bodies = reference_bodies(&server).await; @@ -653,25 +623,23 @@ async fn nuget_hosted_wires_source_mapping_and_lock_hash() { /// The manifest-less VEX steps for a nuget hosted checkout `get` wired /// (`http://patch.test` is the configured patch-server origin; nothing is -/// installed, so the lock's re-pinned `contentHash` is the evidence): -/// ledger present → attested online and offline; ledger deleted → attested -/// from nuget.config + packages.lock.json + the API record; `--offline` -/// with no ledger → `record_unavailable` with zero requests; wiring -/// reverted with the ledger restored → `redirect_unwired`, with and -/// without `--no-verify`. +/// installed, so the lock's re-pinned `contentHash` is the evidence). v5 +/// `get` writes no ledger: attested from nuget.config, packages.lock.json +/// and the API record; `--offline` with no local record → +/// `record_unavailable` with zero requests; a pre-v5 ledger carrying the +/// record serves the offline run; wiring reverted with that ledger kept → +/// `redirect_unwired`, with and without `--no-verify`. fn nuget_hosted_manifestless_vex(root: &Path, uuid: &str, purl: &str) { use vex_e2e_common::*; let store = tempfile::tempdir().unwrap(); let vulns: &[(&str, &[&str])] = &[("GHSA-hhhh-eeee-xxxx", &["CVE-2024-4321"])]; - let api = PatchApi::start(vec![( - uuid.to_string(), - patch_view( - uuid, - purl, - &[("package/payload.txt", &git_sha256(AFTER_BYTES))], - vulns, - ), - )]); + let view = patch_view( + uuid, + purl, + &[("package/payload.txt", &git_sha256(AFTER_BYTES))], + vulns, + ); + let api = PatchApi::start(vec![(uuid.to_string(), view.clone())]); let run = |r: VexRun| { let r = VexRun { patch_server_url: Some("http://patch.test".to_string()), @@ -696,13 +664,7 @@ fn nuget_hosted_manifestless_vex(root: &Path, uuid: &str, purl: &str) { assert!(hit, "{purl} skipped with {reason}: {out}"); assert_absent(out.doc.as_ref(), purl); }; - for r in [VexRun::online(&api), VexRun::offline()] { - let out = run(r); - assert_eq!(out.code, Some(0), "with the ledger: {out}"); - assert_attested(out.doc(), purl, uuid, Marker::Redirected, vulns); - } - let ledger = std::fs::read(root.join(".socket/vendor/redirect-state.json")).unwrap(); - strip_ledgers(root); + vlt_hosted_common::assert_no_ledger(root); let out = run(VexRun::online(&api)); assert_eq!(out.code, Some(0), "lockfile wiring alone: {out}"); assert_attested(out.doc(), purl, uuid, Marker::Redirected, vulns); @@ -717,7 +679,14 @@ fn nuget_hosted_manifestless_vex(root: &Path, uuid: &str, purl: &str) { ); quiet.assert_no_requests(); - std::fs::write(root.join(".socket/vendor/redirect-state.json"), ledger).unwrap(); + vlt_hosted_common::write_legacy_ledger( + root, + &[(purl, vlt_hosted_common::legacy_record_from_view(&view))], + ); + let out = run(VexRun::offline()); + assert_eq!(out.code, Some(0), "a pre-v5 ledger record serves offline: {out}"); + assert_attested(out.doc(), purl, uuid, Marker::Redirected, vulns); + std::fs::write( root.join("nuget.config"), "\n\n \n \ @@ -845,8 +814,6 @@ async fn composer_lock_hosted_repoints_dist_minding_escaped_slashes() { "the bystander package's escaped dist must stay byte-identical; got:\n{lock}" ); - let ledger = read_ledger(tmp.path()); - assert_eq!(ledger["records"][PURL]["uuid"], UUID); assert_no_manifest_no_blobs(tmp.path()); let bodies = reference_bodies(&server).await; diff --git a/crates/socket-patch-cli/tests/in_process_get_modes.rs b/crates/socket-patch-cli/tests/in_process_get_modes.rs index f2317fb09..b5d70fef8 100644 --- a/crates/socket-patch-cli/tests/in_process_get_modes.rs +++ b/crates/socket-patch-cli/tests/in_process_get_modes.rs @@ -227,12 +227,12 @@ async fn requests_containing(server: &MockServer, fragment: &str) -> usize { // --------------------------------------------------------------------------- /// `get --mode hosted` must produce scan's hosted result: lockfile -/// repointed at the hosted artifact with the patched integrity, a redirect -/// ledger with the patch record — and NO manifest, NO blobs (the ledger IS -/// the persistence; parity with `scan --mode hosted`). +/// repointed at the hosted artifact with the patched integrity — and NO +/// manifest, NO blobs, NO redirect ledger (v5: the lockfile pin IS the +/// persistence; parity with `scan --mode hosted`). #[tokio::test] #[serial] -async fn get_uuid_hosted_rewrites_lockfile_and_writes_ledger_not_manifest() { +async fn get_uuid_hosted_rewrites_lockfile_and_writes_no_ledger_or_manifest() { let server = MockServer::start().await; mock_view(&server, UUID1, PURL1).await; mock_reference(&server).await; @@ -259,14 +259,11 @@ async fn get_uuid_hosted_rewrites_lockfile_and_writes_ledger_not_manifest() { "upstream resolved/integrity must be replaced; got:\n{lock}" ); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - assert!(ledger_path.is_file(), "redirect ledger must be written"); - let ledger: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); - assert_eq!(ledger["mode"], "hosted"); - assert_eq!( - ledger["records"][PURL1]["uuid"], UUID1, - "the ledger must record the redirected patch for VEX; got:\n{ledger}" + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "v5 hosted mode must NOT write the redirect ledger" ); assert!( @@ -281,8 +278,8 @@ async fn get_uuid_hosted_rewrites_lockfile_and_writes_ledger_not_manifest() { /// A GHSA fan-out across two versions must be narrowed to the INSTALLED /// version before the hosted engine runs: only its uuid is sent to the -/// reference endpoint, only its lock entry is rewritten, and only its purl -/// lands in the ledger. +/// reference endpoint, only its lock entry is rewritten, and no ledger is +/// written. #[tokio::test] #[serial] async fn get_ghsa_hosted_narrows_to_installed_version() { @@ -321,14 +318,15 @@ async fn get_ghsa_hosted_narrows_to_installed_version() { reference_bodies[0] ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert!(ledger["records"][PURL1].is_object()); assert!( - ledger["records"][PURL2].is_null(), - "no record for the uninstalled version" + !lock.contains(UUID2) && !lock.contains(&format!("{NAME}-2.0.0")), + "the uninstalled version must not be pinned; got:\n{lock}" + ); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "v5 hosted mode must NOT write the redirect ledger" ); assert!(!tmp.path().join(".socket/manifest.json").exists()); } diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index cce2616f1..9ac8bed45 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -93,6 +93,7 @@ fn params(root: &Path) -> DownloadParams { strict: false, ecosystems: None, persist_blobs: true, + patch_server_url: None, // Skip release-narrowing; npm has no variants anyway. all_releases: true, } diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 9b6ff057c..ef2c03099 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -2,7 +2,9 @@ //! (discovery + the `patches/package` reference endpoint) via wiremock, lays //! down an npm project with a lockfile, runs `scan --redirect`, and asserts the //! lockfile's patched-dependency entry was repointed at the hosted vendored -//! patch (resolved URL + sha512 integrity) and a revert ledger was written. +//! patch (resolved URL + sha512 integrity) — and (v5) that NO redirect +//! ledger was written: the lockfile pin is the whole hosted state, and +//! `rollback` restores the default upstream registry entry. //! This is the CLI counterpart of the depscan-side install-verify e2e; the //! rewriter bytes themselves are pinned by the shared golden fixtures. @@ -125,7 +127,7 @@ async fn mock_reference(server: &MockServer) { } /// The `view/{uuid}` endpoint `run_redirect` calls to build the patch record -/// (file hashes + vulnerabilities) it persists into the redirect ledger for VEX. +/// (file hashes + vulnerabilities) the in-run VEX attests from. async fn mock_view(server: &MockServer) { Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) @@ -219,19 +221,15 @@ async fn scan_redirect_rewrites_lockfile_to_hosted_patch() { !lock.contains("UPSTREAMupstream"), "the upstream resolved/integrity must be replaced; got:\n{lock}" ); - // Revert ledger written. - assert!( - tmp.path() - .join(".socket/vendor/redirect-state.json") - .is_file(), - "a redirect ledger should be written for revert" - ); + // v5: no revert ledger — the lock pin is the whole record. + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// `scan --redirect --vex` must emit a valid OpenVEX doc for the redirected /// patch. The redirected bytes aren't installed in-run, so this is a NO-VERIFY -/// attestation built from the patch records the redirect run persists into the -/// ledger; the statement carries the `(redirected)` provenance marker. +/// attestation built from the patch records this run fetched (held in memory +/// — v5 writes no ledger); the statement carries the `(redirected)` +/// provenance marker. #[tokio::test] #[serial] async fn scan_redirect_vex_emits_redirected_attestation() { @@ -254,13 +252,8 @@ async fn scan_redirect_vex_emits_redirected_attestation() { let code = run(args).await; assert_eq!(code, 0, "scan --redirect --vex should succeed"); - // The ledger embeds the patch record (so a post-install `vex` can verify). - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA) && ledger.contains(PURL), - "ledger must embed the patch record + vulnerability: {ledger}" - ); + // The record reached the attestation in memory: nothing persisted. + vlt_hosted_common::assert_no_ledger(tmp.path()); // The VEX document attests the redirected patch with the (redirected) marker. let doc: serde_json::Value = @@ -325,13 +318,12 @@ fn write_installed(root: &Path, name: &str, version: &str, bytes: &[u8]) { std::fs::write(pkg.join("index.js"), bytes).unwrap(); } -/// Idempotency guard for the revert ledger: a second `scan --redirect` run -/// (whose rewrite matches the already-redirected entries) must MERGE into -/// `redirect-state.json`, preserving the first run's edits — the entries whose -/// `original` values a future revert needs — rather than clobbering the file. +/// Idempotency: a second `scan --redirect` run over the already-redirected +/// lock plans from the current lock text (v5 keeps no ledger chain), so it +/// succeeds, leaves the lock byte-identical and still writes no ledger. #[tokio::test] #[serial] -async fn second_redirect_run_preserves_revert_edits() { +async fn second_redirect_run_is_idempotent() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -342,35 +334,17 @@ async fn second_redirect_run_preserves_revert_edits() { let code = run(redirect_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "first scan --redirect should succeed"); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - let first = std::fs::read_to_string(&ledger_path).unwrap(); - assert!( - first.contains("registry.npmjs.org"), - "first run's edits must record the ORIGINAL upstream URL: {first}" - ); + let first = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!(first.contains(HOSTED_URL), "{first}"); let code = run(redirect_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "second scan --redirect should succeed"); - let second = std::fs::read_to_string(&ledger_path).unwrap(); - assert!( - second.contains("registry.npmjs.org"), - "the second run must PRESERVE the original-upstream edit needed for \ - revert (merge, not overwrite): {second}" - ); - assert!( - second.contains(GHSA), - "records must survive the merge: {second}" - ); - // Idempotency: the rewriters see an already-redirected lockfile, record - // no new edits, and the edit list stays the same length — unbounded edit - // growth across CI re-runs would poison a future revert. - let first_json: serde_json::Value = serde_json::from_str(&first).unwrap(); - let second_json: serde_json::Value = serde_json::from_str(&second).unwrap(); assert_eq!( - first_json["edits"].as_array().unwrap().len(), - second_json["edits"].as_array().unwrap().len(), - "a re-run must not append duplicate edits: {second}" + std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), + first, + "a re-run must leave the redirected lock byte-identical" ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// A granted patch whose rewriter finds NOTHING to edit (no lockfile at all) @@ -726,7 +700,7 @@ fn write_berry_project_spelled(root: &Path, spell: impl Fn(&str) -> String) { /// The berry leg: the yarn.lock entry is repointed via `::__archiveUrl=` (the /// URL percent-encoded) and its `checksum:` becomes the yarnBerry10c0. The -/// descriptor KEY is preserved (so `--immutable` still passes), a ledger is +/// descriptor KEY is preserved (so `--immutable` still passes), no ledger is /// written, and a second run is a no-op. #[tokio::test] #[serial] @@ -757,35 +731,28 @@ async fn scan_redirect_rewrites_yarn_berry_lock() { lock.contains(&format!("\"{NAME}@npm:^{VERSION}\":")), "the descriptor key must be preserved verbatim; got:\n{lock}" ); - assert!( - tmp.path() - .join(".socket/vendor/redirect-state.json") - .is_file(), - "a redirect ledger should be written" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); - // Idempotent: a second run rewrites nothing new (no ledger edit growth). - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - let first: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); + // Idempotent: a second run rewrites nothing (the lock is byte-stable). let code = run(redirect_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "second berry run should succeed"); - let second: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); assert_eq!( - first["edits"].as_array().unwrap().len(), - second["edits"].as_array().unwrap().len(), - "a berry re-run must not append duplicate edits" + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + lock, + "a berry re-run must leave the lock byte-identical" ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// The berry leg on the Windows lock shapes: yarn berry writes a NEW /// `yarn.lock` with `os.EOL` (CRLF on Windows), a `core.autocrlf` checkout /// produces the same on any OS, and editors add a BOM. The hosted chain /// must redirect the dep (never `redirected: 0` with a line-ending -/// refusal), keep every line CRLF and the BOM, record the lock's on-disk -/// CRLF fragments in the ledger, stay a no-op on re-run, and `rollback` -/// must restore the pristine lock byte-for-byte. +/// refusal), keep every line CRLF and the BOM, write no ledger, stay a +/// no-op on re-run, and `rollback` must restore the upstream registry entry +/// (re-resolved from the mocked npm registry: the berry checksum is +/// recomputed from the upstream tarball) — the pristine lock byte-for-byte +/// but for that checksum value, CRLF and BOM kept. #[tokio::test] #[serial] async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_restores_them() { @@ -793,6 +760,13 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto mock_discovery(&server).await; mock_reference_with_berry(&server).await; mock_view(&server).await; + let tarball = upstream_tarball(); + mock_npm_registry( + &server, + &vlt_hosted_common::sha512_sri(&tarball), + Some(tarball), + ) + .await; let encoded = socket_patch_core::utils::uri::encode_uri_component(HOSTED_URL); for (label, bom) in [("crlf", ""), ("bom+crlf", "\u{feff}")] { @@ -820,31 +794,9 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto ); assert_eq!(lock.starts_with('\u{feff}'), !bom.is_empty(), "{label}"); - let ledger = read_ledger(tmp.path()); - let edit = ledger["edits"] - .as_array() - .unwrap() - .iter() - .find(|e| e["kind"] == "redirect_yarn_berry_entry") - .unwrap_or_else(|| panic!("{label}: a berry ledger edit: {ledger:#}")); - for side in ["original", "new"] { - let fragment = edit[side].as_str().unwrap(); - assert!( - fragment.contains("\r\n") && !fragment.replace("\r\n", "").contains('\n'), - "{label}: the ledger's {side} is the on-disk CRLF fragment: {fragment:?}" - ); - assert!( - String::from_utf8_lossy(if side == "original" { - &pristine - } else { - lock.as_bytes() - }) - .contains(fragment), - "{label}: {side} is a verbatim slice of the file" - ); - } + vlt_hosted_common::assert_no_ledger(tmp.path()); - // Re-run: in sync, byte-stable, no new ledger edit. + // Re-run: in sync, byte-stable. let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["redirect"]["redirected"], 1, "{label}: {env:#}"); assert_eq!( @@ -852,25 +804,23 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto lock, "{label}" ); - assert_eq!( - read_ledger(tmp.path())["edits"].as_array().unwrap().len(), - ledger["edits"].as_array().unwrap().len(), - "{label}: a re-run appends nothing" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); - let (code, env) = rollback_json(tmp.path()); + let (code, env) = rollback_json(tmp.path(), &server); assert_eq!(code, Some(0), "{label}: rollback: {env:#}"); + assert_eq!(env["hosted"]["reverted"], serde_json::json!([PURL]), "{label}: {env:#}"); + let restored = std::fs::read_to_string(&lock_path).unwrap(); + let checksum = berry_checksum_of(&restored); + assert_ne!(checksum, BERRY_CHECKSUM, "{label}: the patched checksum is gone"); assert_eq!( - std::fs::read(&lock_path).unwrap(), - pristine, - "{label}: rollback restores the pristine CRLF lock byte-for-byte" - ); - assert!( - !tmp.path() - .join(".socket/vendor/redirect-state.json") - .exists(), - "{label}: the emptied ledger is removed" + restored, + String::from_utf8(pristine.clone()) + .unwrap() + .replace(&format!("10c0/{}", "3".repeat(128)), &format!("10c0/{checksum}")), + "{label}: rollback restores the pristine CRLF lock (upstream checksum \ + re-derived from the registry tarball)" ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } } @@ -878,7 +828,7 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto /// cannot be kept in one style — and yarn itself rejects it under /// `--immutable` — so the hosted run refuses it untouched with a code that /// names the line endings and the `yarn install` remedy, redirecting -/// nothing and writing no ledger. +/// nothing and writing nothing. #[tokio::test] #[serial] async fn scan_redirect_refuses_a_mixed_line_ending_yarn_berry_lock() { @@ -975,12 +925,7 @@ async fn scan_redirect_rewrites_correct_entry_in_crlf_classic_lock() { lock.matches("\r\n").count(), "every line must keep its CRLF ending: {lock}" ); - assert!( - tmp.path() - .join(".socket/vendor/redirect-state.json") - .is_file(), - "a redirect ledger should be written" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// Write a project whose only lockfile is a text `bun.lock` (registry @@ -1063,12 +1008,7 @@ async fn scan_redirect_rewrites_bun_lock() { !lock.contains("UPSTREAMupstream"), "upstream integrity must be replaced; got:\n{lock}" ); - assert!( - tmp.path() - .join(".socket/vendor/redirect-state.json") - .is_file(), - "a redirect ledger should be written" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); bun_manifestless_vex(tmp.path(), &lock_before, "bun-v1"); } @@ -1104,12 +1044,7 @@ async fn scan_redirect_rewrites_bun_lock_v2() { lock.contains(PATCHED_SHA512), "integrity must be the patched sha512" ); - assert!( - tmp.path() - .join(".socket/vendor/redirect-state.json") - .is_file(), - "a redirect ledger should be written" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); bun_manifestless_vex(tmp.path(), &lock_before, "bun-v2"); } @@ -1176,14 +1111,11 @@ fn drop_bun_digest(line: &str) -> String { /// lock is re-saved for another reason. The digest-less 2-tuple is still /// our wiring (the spec bun installs from is intact): a repeat `scan /// --mode hosted` must report a CONSISTENT envelope — `redirected: 1` with -/// no `redirect_bun_entry_not_found` — heal the line back to the 3-tuple -/// and record the heal as a second ledger edit for the key (`original` = -/// the 2-tuple); a third run appends nothing; and `rollback` must unwind -/// the chain to the pristine registry line whether the lock is the healed -/// 3-tuple or Bun has since dropped the digest again. Before the fix the -/// repeat scan warned `entry_not_found` beside `redirected: 1` and -/// rollback refused `partial_failure` ("matches neither the redirected nor -/// the original fragment"), stranding every user on those releases. +/// no `redirect_bun_entry_not_found` — and heal the line back to the +/// 3-tuple; a third run is a no-op; no run writes a ledger; and `rollback` +/// must restore the pristine registry line (re-resolved from the mocked npm +/// registry) whether the lock is the healed 3-tuple or Bun has since +/// dropped the digest again. #[tokio::test] #[serial] async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_registry_line() { @@ -1198,7 +1130,7 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi write_bun_project(tmp.path(), 1); let lock_path = tmp.path().join("bun.lock"); let pristine = std::fs::read_to_string(&lock_path).unwrap(); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); + mock_npm_registry(&server, "sha512-UPSTREAMupstream==", None).await; for drop_again_before_rollback in [false, true] { let env = run_redirect_subprocess(tmp.path(), &server.uri()); @@ -1233,37 +1165,19 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi wired, "the digest is healed back — lock byte-identical to the first run's" ); - let ledger = read_ledger(tmp.path()); - let edits = ledger["edits"].as_array().unwrap(); - assert_eq!(edits.len(), 2, "first edit + the heal: {ledger:#}"); - assert_eq!( - edits[0]["original"], - serde_json::json!(bun_packages_line(&pristine, NAME)), - "{ledger:#}" - ); - assert_eq!(edits[1]["key"], NAME, "{ledger:#}"); - assert_eq!( - edits[1]["original"], - serde_json::json!(digestless), - "{ledger:#}" - ); - assert_eq!(edits[1]["new"], serde_json::json!(wired_line), "{ledger:#}"); + vlt_hosted_common::assert_no_ledger(tmp.path()); - // A third run over the healed lock is a no-op for the ledger. + // A third run over the healed lock is a no-op. let env = run_redirect_subprocess(tmp.path(), &server.uri()); assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); assert!(warning_codes(&env).is_empty(), "{env:#}"); - assert_eq!( - read_ledger(tmp.path())["edits"].as_array().unwrap().len(), - 2, - "a re-run over the healed lock must not append edits" - ); + assert_eq!(std::fs::read_to_string(&lock_path).unwrap(), wired); if drop_again_before_rollback { // Another `bun add` on Bun < 1.3.10: the digest is gone again. std::fs::write(&lock_path, wired.replace(&wired_line, &digestless)).unwrap(); } - let (code, env) = rollback_json(tmp.path()); + let (code, env) = rollback_json(tmp.path(), &server); assert_eq!( code, Some(0), @@ -1276,28 +1190,33 @@ async fn scan_redirect_heals_digestless_bun_tuple_and_rollback_restores_the_regi "rollback lands on the pristine registry line (digest dropped again: \ {drop_again_before_rollback})" ); - assert!( - !ledger_path.exists(), - "the emptied ledger is deleted after a full unwind" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } } // Native binary lockfiles are parsed and patched without invoking Bun. const INVALID_LOCKB_BYTES: &[u8] = b"\x00BUN-BINARY\xff\xfe\x00LOCK"; -/// `rollback --json --yes --offline` as a subprocess; returns (exit code, -/// parsed envelope). -fn rollback_json(cwd: &Path) -> (Option, serde_json::Value) { +/// `rollback --json --yes` as a subprocess, with the mock patch host +/// recognized (`--patch-server-url http://patch.test`, so lockfile +/// discovery finds the hosted pins) and the upstream restore's npm registry +/// pointed at `registry` (`SOCKET_NPM_REGISTRY`, see [`mock_npm_registry`]); +/// returns (exit code, parsed envelope). +fn rollback_json(cwd: &Path, registry: &MockServer) -> (Option, serde_json::Value) { let out = scrubbed_cli() .args([ "rollback", "--json", "--yes", - "--offline", + "--patch-server-url", + "http://patch.test", "--cwd", cwd.to_str().unwrap(), ]) + .env( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", registry.uri()), + ) .output() .expect("run socket-patch rollback"); let env_json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { @@ -1310,9 +1229,64 @@ fn rollback_json(cwd: &Path) -> (Option, serde_json::Value) { (out.status.code(), env_json) } -fn read_ledger(root: &Path) -> serde_json::Value { - let text = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); - serde_json::from_str(&text).expect("the ledger is JSON") +/// The npm registry's version document for `NAME@VERSION` under +/// `/npm-registry` — what rollback's upstream restore re-resolves a +/// hosted pin from — carrying `integrity`, plus (when given) the upstream +/// `tarball` served at the document's `dist.tarball` (a yarn berry restore +/// downloads it to recompute the zip checksum). +async fn mock_npm_registry(server: &MockServer, integrity: &str, tarball: Option>) { + let tarball_path = format!("/npm-registry/{NAME}/-/{NAME}-{VERSION}.tgz"); + Mock::given(method("GET")) + .and(path(format!("/npm-registry/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { + "tarball": format!("{}{tarball_path}", server.uri()), + "integrity": integrity, + "shasum": "0".repeat(40), + } + }))) + .mount(server) + .await; + if let Some(bytes) = tarball { + Mock::given(method("GET")) + .and(path(tarball_path)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(bytes)) + .mount(server) + .await; + } +} + +/// A small upstream npm tarball for `NAME@VERSION`. +fn upstream_tarball() -> Vec { + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(gz); + let package_json = format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#); + for (name, data) in [ + ("package/package.json", package_json.as_bytes()), + ("package/index.js", b"module.exports = 'upstream'\n".as_slice()), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, name, data).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// The `checksum: 10c0/` value of the target entry in a berry lock. +fn berry_checksum_of(lock: &str) -> String { + let at = lock + .find(&format!("\"{NAME}@npm:^{VERSION}\":")) + .unwrap_or_else(|| panic!("no target entry in {lock:?}")); + let rest = &lock[at..]; + let line = rest + .split('\n') + .find_map(|l| l.trim_end_matches('\r').trim().strip_prefix("checksum: 10c0/")) + .unwrap_or_else(|| panic!("no checksum in {rest:?}")); + line.to_string() } /// A child-only PATH with `bin_dir` first, joined with the OS separator. @@ -1620,12 +1594,15 @@ async fn no_redirectable_patch_leaves_bun_lockb_alone() { ); } -/// A corrupt redirect ledger refuses before any binary-lockfile edits. -/// The existing binary remains byte-identical and no Bun process starts. +/// A corrupt pre-v5 redirect ledger is IGNORED (v5 never reads it): the +/// binary-lockfile path runs exactly as without it — here a placeholder +/// `bun.lockb` the native codec rejects (exit 0, nothing redirected) — no +/// Bun process starts, no text lock appears, and the torn ledger is left +/// byte-identical in place (never quarantined). #[cfg(unix)] #[tokio::test] #[serial] -async fn corrupt_ledger_refuses_before_the_bun_lockb_edit() { +async fn corrupt_pre_v5_ledger_is_ignored_beside_a_bun_lockb() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -1648,7 +1625,6 @@ async fn corrupt_ledger_refuses_before_the_bun_lockb_edit() { .unwrap(); std::fs::write(tmp.path().join("bun.lockb"), b"BUN-BINARY-PLACEHOLDER").unwrap(); - // A torn ledger: parseable as neither the vendor nor the redirect shape. let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); std::fs::create_dir_all(ledger_path.parent().unwrap()).unwrap(); let corrupt_bytes = b"{\"mode\":\"hosted\",\"edits\":[{\"path\":\"bun.lo"; @@ -1669,38 +1645,32 @@ async fn corrupt_ledger_refuses_before_the_bun_lockb_edit() { unsafe { std::env::set_var("PATH", orig_path); } - assert_eq!(code, 1, "a corrupt ledger must flip the exit code"); + assert_eq!(code, 0, "a pre-v5 ledger never fails a hosted run"); assert!(!bin_dir.join("bun-was-spawned").exists()); assert_eq!( std::fs::read(tmp.path().join("bun.lockb")).ok().as_deref(), Some(b"BUN-BINARY-PLACEHOLDER".as_slice()), - "the binary lock must be byte-untouched: the refusal precedes the rewrite" - ); - assert!( - !tmp.path().join("bun.lock").exists(), - "no text lock may be created by a run that refused before redirecting" + "the unparseable binary lock stays byte-untouched" ); - // The malformed ledger is quarantined (never deleted), so recovery of the - // pre-redirect originals it may still hold stays possible. - let quarantined = tmp - .path() - .join(".socket/vendor/redirect-state.json.corrupt"); + assert!(!tmp.path().join("bun.lock").exists()); assert_eq!( - std::fs::read(&quarantined).unwrap(), + std::fs::read(&ledger_path).unwrap(), corrupt_bytes, - "the malformed ledger is moved aside verbatim" + "the pre-v5 ledger is left byte-identical in place" ); + assert!(!tmp + .path() + .join(".socket/vendor/redirect-state.json.corrupt") + .exists()); } -/// An unusable ledger is an ERROR, not a silent success: -/// `.socket/vendor/redirect-state.json` is the only revert path (and the VEX -/// record store). A DIRECTORY squatting on the ledger path makes it -/// unloadable, so the run must fail closed BEFORE rewriting anything — the -/// old flow rewrote the lockfile first and only then discovered the ledger -/// could not be persisted, leaving the repo redirected with no way back. +/// A DIRECTORY squatting on the pre-v5 ledger path used to make the run +/// fail closed (the ledger was the revert path). v5 hosted mode never reads +/// or writes that path, so the run succeeds, the lock is redirected, and +/// the squatting directory is left alone. #[tokio::test] #[serial] -async fn unwritable_ledger_fails_the_run() { +async fn directory_at_the_legacy_ledger_path_does_not_block_the_run() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; @@ -1708,33 +1678,27 @@ async fn unwritable_ledger_fails_the_run() { let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - // Occupy the ledger path with a DIRECTORY so the ledger cannot be loaded - // (or written). - std::fs::create_dir_all(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + let squatter = tmp.path().join(".socket/vendor/redirect-state.json"); + std::fs::create_dir_all(&squatter).unwrap(); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 1, "an unusable ledger must flip the exit code"); - // Fail-closed ordering: the ledger problem surfaces before any project - // file is touched, so the lockfile still points at the upstream registry. + assert_eq!(code, 0, "the legacy ledger path is not consulted"); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); - assert!( - !lock.contains(HOSTED_URL), - "an unusable ledger must abort before the lockfile rewrite; got:\n{lock}" - ); + assert!(lock.contains(HOSTED_URL), "the lock is redirected; got:\n{lock}"); + assert!(squatter.is_dir(), "the squatting directory is untouched"); } -/// Findings hosted-atomicity 1+2: a MID-RUN lockfile write failure (second of -/// two locks unwritable) must never leave the successfully-written first lock -/// redirected with no ledger record of its pre-redirect originals. The ledger -/// is persisted BEFORE the lockfile loop, so every planned edit's original is -/// durable even when a later write fails; the failed lock itself stays -/// byte-untouched (atomic stage+rename, no truncation). +/// A MID-RUN lockfile write failure (second of two locks unwritable) exits +/// 1; the first lock landed, the failed lock stays byte-untouched (atomic +/// stage+rename, no truncation), and no ledger is written (v5: a landed +/// hosted pin is undone by `rollback`'s upstream restore, which needs no +/// recorded originals). /// /// unix-only: a read-only directory does not block file creation on Windows. #[cfg(unix)] #[tokio::test] #[serial] -async fn partial_lockfile_write_failure_persists_ledger_originals() { +async fn partial_lockfile_write_failure_exits_1_and_writes_no_ledger() { use std::os::unix::fs::PermissionsExt; let server = MockServer::start().await; @@ -1764,19 +1728,7 @@ async fn partial_lockfile_write_failure_persists_ledger_originals() { common.contains(HOSTED_URL), "the common lock was written before the subspace failure; got:\n{common}" ); - // …so its pre-redirect originals MUST already be in the ledger: without - // them a revert is impossible, and a re-run cannot recapture them (the - // entry is already redirected and produces no new edit). - let ledger = std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")) - .expect("the ledger must be persisted before any lockfile is mutated"); - assert!( - ledger.contains("UPSTREAMupstream"), - "the ledger must record the pre-redirect original integrity: {ledger}" - ); - assert!( - ledger.contains("common/config/rush/pnpm-lock.yaml"), - "the ledger must record the edit for the lock that WAS written: {ledger}" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); // The failed lock is byte-untouched — no partial/truncated write. assert_eq!( @@ -1887,13 +1839,7 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { "rush nested-lock redirects must not create a root pnpm-workspace.yaml" ); - // repo-state.json present → the stale-hash warning fires. - let out = std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")) - .expect("a redirect ledger should be written"); - assert!( - out.contains(HOSTED_URL), - "the ledger records the redirect for revert: {out}" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// Run the built `socket-patch` binary as a subprocess against `api_url` @@ -2104,23 +2050,8 @@ packages: !after.contains(HOSTED_URL), "the hosted URL must never appear (it would confirm + attest): {after}" ); - // No ledger half-claims the purl either: an unconfirmed dep must fetch - // no record and record no edits. - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - if let Ok(text) = std::fs::read_to_string(&ledger_path) { - let ledger: serde_json::Value = - serde_json::from_str(&text).expect("the redirect ledger must be valid JSON"); - assert!( - ledger["records"].get(PURL).is_none(), - "an unconfirmed dep must not be recorded: {ledger}" - ); - let claimed = ledger["edits"] - .as_array() - .into_iter() - .flatten() - .any(|e| e["key"].as_str().is_some_and(|k| k.contains(NAME))); - assert!(!claimed, "no edit may claim the refused dep: {ledger}"); - } + // And nothing else half-claims the purl (v5 writes no ledger at all). + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// The rewriters' own warnings must reach HUMAN mode too, not just the @@ -2515,20 +2446,8 @@ async fn pnpm_lock_redirect_autoconfigures_trust_lockfile_and_says_so() { got: {detail}" ); - // The ledger records the workspace-trust edit (created ⇒ revert deletes). - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(pnpm.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert!( - ledger["edits"].as_array().unwrap().iter().any(|e| { - e["kind"] == "redirect_pnpm_workspace_trust" - && e["action"] == "created" - && e["path"] == "pnpm-workspace.yaml" - && e["key"] == "trustLockfile" - }), - "the ledger must record the created workspace-trust edit: {ledger}" - ); + // v5: the created workspace file is the record (no ledger edit). + vlt_hosted_common::assert_no_ledger(pnpm.path()); // npm twin: only a package-lock.json is rewritten → no pnpm warning and // no workspace file materializes. @@ -2578,14 +2497,7 @@ async fn pnpm_trust_opt_out_writes_nothing_and_keeps_manual_guidance() { serde_json::json!(["pnpm-lock.yaml"]), "only the lock may be rewritten under the opt-out: {env}" ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert!( - !ledger.to_string().contains("redirect_pnpm_workspace_trust"), - "the opt-out must record no workspace-trust edit: {ledger}" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); let detail = env["redirect"]["warnings"] .as_array() .unwrap() @@ -2652,14 +2564,7 @@ async fn pnpm_trust_respects_an_explicit_user_false() { detail.contains("--trust-lockfile"), "the warning must fall back to the per-run flag recovery; got: {detail}" ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert!( - !ledger.to_string().contains("redirect_pnpm_workspace_trust"), - "no workspace-trust edit may be recorded for a respected user setting: {ledger}" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// Two hardening pins on the trust-lockfile warning's host list, which lands @@ -3250,9 +3155,9 @@ async fn run_hosted_json_scan(tmp: &std::path::Path, server: &MockServer) -> std /// read-only lock FILE no longer fails this leg: the atomic stage+rename /// replaces it mode-preserved, like the vendored backend's writer. (Legs 1-2 /// — the discovery-detail and reference-resolve failures — are pinned by -/// `redirect_json_mode_failures_emit_error_envelope` above; leg 4 — the -/// ledger write — is pinned by the unix-only -/// `redirect_ledger_write_failure_leaves_project_files_untouched` below.) +/// `redirect_json_mode_failures_emit_error_envelope` above. The former leg 4 +/// — the ledger write — has no subject in v5: hosted mode writes no ledger, +/// see `readonly_socket_vendor_does_not_block_a_hosted_run` below.) /// /// unix-only: a read-only directory does not block file creation on Windows. #[cfg(unix)] @@ -3266,8 +3171,7 @@ async fn redirect_json_mode_write_failures_emit_error_envelope() { mock_reference(&server).await; mock_view(&server).await; let tmp = tempfile::tempdir().unwrap(); - // Rush project: the lock lives in a subdirectory, so obstructing it does - // not also block the (earlier) `.socket/vendor` ledger write at the root. + // Rush project: the lock lives in a subdirectory. write_rush_project(tmp.path(), false); let lock_dir = tmp.path().join("common/config/rush"); std::fs::set_permissions(&lock_dir, std::fs::Permissions::from_mode(0o555)).unwrap(); @@ -3276,26 +3180,22 @@ async fn redirect_json_mode_write_failures_emit_error_envelope() { assert_write_failure_envelope(&out, "lockfile-write failure"); } -/// Leg 4 of the four `--json` failure exits: the revert ledger cannot be -/// persisted — `.socket/vendor` is read-only, so the atomic writer's stage -/// file cannot be created. The ledger is written BEFORE the project files -/// (its recorded originals are the only revert path), so the failure must -/// also leave the lockfile untouched — not rewritten-but-unrevertable. +/// v5 hosted mode writes nothing under `.socket/`, so a read-only +/// `.socket/vendor` (which used to fail the run at the ledger write) no +/// longer matters: the run succeeds and the lockfile is redirected. /// -/// unix-only: the obstruction is a read-only DIRECTORY, and Windows ignores -/// FILE_ATTRIBUTE_READONLY on directories for file creation, so the stage -/// file would be created fine there. +/// unix-only: the obstruction is a read-only DIRECTORY (Windows ignores +/// FILE_ATTRIBUTE_READONLY on directories for file creation). #[cfg(unix)] #[tokio::test] #[serial] -async fn redirect_ledger_write_failure_leaves_project_files_untouched() { +async fn readonly_socket_vendor_does_not_block_a_hosted_run() { let server = MockServer::start().await; mock_discovery(&server).await; mock_reference(&server).await; mock_view(&server).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - let lock_before = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); let vendor_dir = tmp.path().join(".socket/vendor"); std::fs::create_dir_all(&vendor_dir).unwrap(); let mut perms = std::fs::metadata(&vendor_dir).unwrap().permissions(); @@ -3308,23 +3208,27 @@ async fn redirect_ledger_write_failure_leaves_project_files_untouched() { #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false); std::fs::set_permissions(&vendor_dir, perms).unwrap(); - assert_write_failure_envelope(&out, "ledger-write failure"); + let stdout = String::from_utf8_lossy(&out.stdout); assert_eq!( - std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), - lock_before, - "a failed ledger write must leave the project files untouched \ - (ledger-before-files ordering)" + out.status.code(), + Some(0), + "stdout=\n{stdout}\nstderr=\n{}", + String::from_utf8_lossy(&out.stderr) ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("parseable envelope"); + assert_eq!(v["redirect"]["redirected"], 1, "{v:#}"); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!(lock.contains(HOSTED_URL), "{lock}"); + vlt_hosted_common::assert_no_ledger(tmp.path()); } -/// A MALFORMED redirect ledger (torn write, truncation, bad hand-edit) must -/// abort a hosted run before anything is written. The old tolerant load -/// returned `None` for it, so `run_redirect` started a FRESH ledger and -/// overwrote the corrupt file — permanently destroying every previously -/// recorded pre-redirect original (the only revert path) with exit 0. +/// A MALFORMED pre-v5 redirect ledger (torn write, truncation, bad +/// hand-edit) is IGNORED by v5 hosted scan: never read for planning, never +/// quarantined, never an error. The run succeeds and redirects, the torn +/// bytes are left in place verbatim, and nothing about them is printed. #[tokio::test] #[serial] -async fn corrupt_ledger_fails_closed_and_preserves_the_bytes() { +async fn corrupt_pre_v5_ledger_is_ignored_and_left_untouched() { const TORN: &[u8] = b"{ \"version\": 1, \"mode\": \"hosted\", \"edits\": [ { \"path\": \"packa"; let server = MockServer::start().await; @@ -3333,80 +3237,36 @@ async fn corrupt_ledger_fails_closed_and_preserves_the_bytes() { mock_view(&server).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - let lock_before = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); let vendor_dir = tmp.path().join(".socket/vendor"); std::fs::create_dir_all(&vendor_dir).unwrap(); std::fs::write(vendor_dir.join("redirect-state.json"), TORN).unwrap(); - let out = scrubbed_cli() - .args([ - "scan", - "--redirect", - "--yes", - "--json", - "--cwd", - tmp.path().to_str().unwrap(), - "--api-url", - &server.uri(), - "--org", - ORG, - "--api-token", - "fake", - ]) - .output() - .expect("run socket-patch"); + let out = run_hosted_json_scan(tmp.path(), &server).await; let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!( - out.status.code(), - Some(1), - "a corrupt ledger must be a hard error, not a silent fresh start; \ - stdout=\n{stdout}" - ); - let v: serde_json::Value = - serde_json::from_str(&stdout).expect("--json stdout must stay parseable on failure"); - assert_eq!(v["status"], "error"); - let message = v["error"].as_str().unwrap_or_default(); - assert!( - message.contains("redirect-state.json"), - "error must name the ledger file: {message}" - ); - assert!( - message.contains("redirect-state.json.corrupt"), - "error must point at the moved-aside file: {message}" - ); - // The corruption is reported ONCE, as the engine's hard error: the - // read-only `updates[]` consult of the same file must not also print - // its advisory warning for a hosted run. let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!( - stderr.matches("is malformed").count(), - 1, - "the corrupt-ledger message must print exactly once; stderr=\n{stderr}" - ); - - // Nothing was rewritten, and the corrupt bytes survived verbatim in the - // quarantine file — never overwritten by a fresh ledger. - assert_eq!( - std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), - lock_before, - "the project must be untouched" + assert_eq!(out.status.code(), Some(0), "stdout=\n{stdout}\nstderr=\n{stderr}"); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("parseable envelope"); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(v["redirect"]["redirected"], 1, "{v:#}"); + assert!( + !stdout.contains("redirect-state.json") && !stderr.contains("malformed"), + "the legacy ledger is never mentioned; stdout=\n{stdout}\nstderr=\n{stderr}" ); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); + assert!(lock.contains(HOSTED_URL), "{lock}"); assert_eq!( - std::fs::read(vendor_dir.join("redirect-state.json.corrupt")).unwrap(), + std::fs::read(vendor_dir.join("redirect-state.json")).unwrap(), TORN, - "the corrupt ledger bytes must be preserved for recovery" - ); - assert!( - !vendor_dir.join("redirect-state.json").exists(), - "no fresh ledger may be written over the failure" + "the pre-v5 ledger bytes are left in place verbatim" ); + assert!(!vendor_dir.join("redirect-state.json.corrupt").exists()); } -/// `--dry-run` over a corrupt ledger reports the same hard error but moves -/// nothing: a dry run must not mutate the project, quarantine included. +/// `--dry-run` over a corrupt pre-v5 ledger: the preview succeeds, and +/// neither the ledger nor the lock is touched. #[tokio::test] #[serial] -async fn corrupt_ledger_dry_run_errors_without_moving_the_file() { +async fn corrupt_pre_v5_ledger_dry_run_succeeds_without_touching_it() { const TORN: &[u8] = b"{ not json"; let server = MockServer::start().await; @@ -3414,6 +3274,7 @@ async fn corrupt_ledger_dry_run_errors_without_moving_the_file() { mock_reference(&server).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); + let lock_before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); let vendor_dir = tmp.path().join(".socket/vendor"); std::fs::create_dir_all(&vendor_dir).unwrap(); std::fs::write(vendor_dir.join("redirect-state.json"), TORN).unwrap(); @@ -3437,65 +3298,57 @@ async fn corrupt_ledger_dry_run_errors_without_moving_the_file() { .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); - assert_eq!( - out.status.code(), - Some(1), - "dry-run must report the corruption a real run would refuse on; \ - stdout=\n{stdout}" - ); + assert_eq!(out.status.code(), Some(0), "stdout=\n{stdout}"); assert_eq!( std::fs::read(vendor_dir.join("redirect-state.json")).unwrap(), TORN, - "dry-run must not move or rewrite the malformed ledger" + "dry-run must not move or rewrite the pre-v5 ledger" ); - assert!( - !vendor_dir.join("redirect-state.json.corrupt").exists(), - "dry-run must not quarantine" + assert!(!vendor_dir.join("redirect-state.json.corrupt").exists()); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_before, + "dry-run leaves the lock untouched" ); } -/// Hosted mode records patches ONLY in the redirect ledger — it never -/// writes `.socket/manifest.json` — so `updates[]` (the documented CI -/// signal) must consult the ledger too, or a pure hosted project whose -/// redirected patch has been superseded reports `updates: []` forever. +/// Hosted mode records patches ONLY in the lockfile pins — it never writes +/// `.socket/manifest.json` (nor, in v5, a ledger) — so `updates[]` (the +/// documented CI signal) must consult the hosted pins, or a pure hosted +/// project whose redirected patch has been superseded reports +/// `updates: []` forever. #[tokio::test] #[serial] -async fn scan_updates_reports_superseding_patch_for_ledger_only_project() { +async fn scan_updates_reports_superseding_patch_for_a_lock_pinned_project() { const OLD_UUID: &str = "99999999-9999-4999-8999-999999999999"; let server = MockServer::start().await; - // Discovery offers ONLY the new uuid; the ledger records the old one. + // Discovery offers ONLY the new uuid; the lock pins the old one. mock_discovery(&server).await; let tmp = tempfile::tempdir().unwrap(); write_project(tmp.path()); - // Ledger-only persistence, exactly as a previous hosted run left it. - let mut ledger = socket_patch_core::patch::redirect::RedirectState::new(); - ledger.records.insert( - PURL.to_string(), - PatchRecord { - uuid: OLD_UUID.to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: "MIT".to_string(), - tier: "free".to_string(), - }, - ); - let vendor_dir = tmp.path().join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); + // The lock as a previous hosted run left it: pinned to OLD_UUID's + // hosted artifact (the only v5 hosted persistence). + let old_url = HOSTED_URL.replace(UUID, OLD_UUID); + let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); std::fs::write( - vendor_dir.join("redirect-state.json"), - format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()), + tmp.path().join("package-lock.json"), + lock.replace( + &format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + &old_url, + ), ) .unwrap(); - // Bare `scan --json` (hosted by default) — the nightly CI shape. + // Bare `scan --json` (hosted by default) — the nightly CI shape; the + // mock host counts as the patch server via `--patch-server-url`. let out = scrubbed_cli() .args([ "scan", "--json", + "--patch-server-url", + "http://patch.test", "--cwd", tmp.path().to_str().unwrap(), "--api-url", @@ -3514,7 +3367,7 @@ async fn scan_updates_reports_superseding_patch_for_ledger_only_project() { assert_eq!( updates.len(), 1, - "the ledger-recorded patch was superseded — updates[] must say so; \ + "the lock-pinned patch was superseded — updates[] must say so; \ stdout=\n{stdout}" ); assert_eq!(updates[0]["purl"], PURL); @@ -3716,7 +3569,7 @@ async fn mock_composer_api(server: &MockServer) { /// having done nothing: the rewriter wrote the hosted url with `\/`-escaped /// slashes while the post-rewrite confirmation probe searched only the raw and /// percent-encoded spellings, so a fully successful rewrite yielded -/// `redirected: 0`, no patch record in the ledger, and nothing for `vex` to +/// `redirected: 0`, no patch record fetched, and nothing for `vex` to /// attest. The rewriter now emits composer-native raw slashes and the probe /// asks the rewriter's own predicate, so the lock edit and the confirmation /// cannot disagree. Subprocess so the `--json` envelope can be read back. @@ -3760,18 +3613,17 @@ async fn composer_redirect_is_confirmed_and_recorded() { "dist.shasum must pin the patched artifact's sha1; got:\n{lock}" ); - // The confirmation is what drives the record fetch: no confirmation, no - // record, and `socket-patch vex` can never attest the patch. - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - ledger.contains(COMPOSER_PURL) && ledger.contains(GHSA), - "the ledger must carry the fetched patch record for the redirected purl: {ledger}" - ); - assert!( - ledger.contains("redirect_composer_dist"), - "the ledger must carry the revert edit for the lock rewrite: {ledger}" - ); + // The confirmation is what drives the record fetch (held in memory for + // the in-run VEX — v5 persists no ledger): no confirmation, no record. + let views = server + .received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().contains("/patches/view/")) + .count(); + assert_eq!(views, 1, "the confirmed redirect fetched its patch record"); + vlt_hosted_common::assert_no_ledger(tmp.path()); } /// Mount the full cargo hosted-mock set (discovery + reference + view) for @@ -4217,12 +4069,7 @@ async fn cargo_table_form_without_lock_is_pinned_and_attested() { !tmp.path().join("Cargo.lock").exists(), "no lockfile may be invented" ); - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - ledger.contains(CARGO_UUID) && ledger.contains("GHSA-carg-cccc-dddd"), - "the ledger must record the landed redirect: {ledger}" - ); + vlt_hosted_common::assert_no_ledger(tmp.path()); let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); let stmts = doc["statements"].as_array().unwrap(); diff --git a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs index 373455bee..f90893f39 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs @@ -11,20 +11,6 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::vlt_hosted_common::*; -fn ledger(root: &Path) -> Value { - serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap() -} - -fn vlt_edit_keys(root: &Path) -> Vec { - ledger(root)["edits"] - .as_array() - .into_iter() - .flatten() - .filter(|e| e["kind"] == "redirect_vlt_lock_node") - .map(|e| e["key"].as_str().unwrap().to_string()) - .collect() -} - fn skipped_reasons(doc: &Value) -> Vec { doc["redirect"]["skipped"] .as_array() @@ -57,8 +43,8 @@ async fn assert_rewrites(era: Era, expected_warnings: &[&str]) { ); assert_eq!(warning_codes(&doc), expected_warnings, "{doc:#}"); assert_eq!(warning_detail(&doc, ADVISORY), ADVISORY_NOTHING_STALE); - assert_eq!(vlt_edit_keys(tmp.path()), ["left-pad@1.3.0"]); - assert!(ledger(tmp.path())["records"][PURL].is_object()); + // v5: the lock pin is the whole record — no redirect ledger. + assert_no_ledger(tmp.path()); assert_eq!(artifact_requests(&server).await, 1); } @@ -104,15 +90,16 @@ async fn scan_redirect_refuses_vlt_lock_v2() { assert!(!ledger_path(tmp.path()).exists()); } +/// A re-run over the pinned lock is idempotent: still confirmed, the lock +/// byte-identical, and (v5) still no ledger. #[tokio::test] -async fn scan_redirect_vlt_rerun_noop_keeps_ledger() { +async fn scan_redirect_vlt_rerun_is_a_noop() { let server = MockServer::start().await; mock_all(&server).await; let tmp = tempfile::tempdir().unwrap(); write_vlt_project(tmp.path(), Era::V1); scan_hosted(tmp.path(), &server, &[], &[]); let lock = read(tmp.path(), "vlt-lock.json"); - let ledger_bytes = read(tmp.path(), ".socket/vendor/redirect-state.json"); let (_, doc) = scan_hosted(tmp.path(), &server, &[], &[]); @@ -122,11 +109,7 @@ async fn scan_redirect_vlt_rerun_noop_keeps_ledger() { "a pinned lock stays confirmed: {doc:#}" ); assert_eq!(read(tmp.path(), "vlt-lock.json"), lock); - assert_eq!( - read(tmp.path(), ".socket/vendor/redirect-state.json"), - ledger_bytes - ); - assert_eq!(vlt_edit_keys(tmp.path()), ["left-pad@1.3.0"]); + assert_no_ledger(tmp.path()); } #[tokio::test] @@ -146,14 +129,7 @@ async fn scan_redirect_vlt_crlf() { read(tmp.path(), "vlt-lock.json"), lock_with(Era::V1, &[pinned_node(TILDE_ID, &server), other]).replace('\n', "\r\n") ); - let original = ledger(tmp.path())["edits"][0]["original"] - .as_str() - .unwrap() - .to_string(); - assert!( - !original.contains('\r') && !original.ends_with(','), - "{original:?}" - ); + assert_no_ledger(tmp.path()); } #[tokio::test] @@ -179,10 +155,7 @@ async fn scan_redirect_vlt_peer_instances() { &[pinned_node(TILDE_ID, &server), pinned_node(peer, &server)] ) ); - assert_eq!( - vlt_edit_keys(tmp.path()), - ["left-pad@1.3.0", "left-pad@1.3.0~peer.2"] - ); + assert_no_ledger(tmp.path()); assert_eq!( artifact_requests(&server).await, 1, @@ -234,7 +207,7 @@ async fn scan_redirect_vlt_sibling_package_lock_vlt_installed_does_not_confirm_r ); assert!(warning_codes(&doc).contains(&"redirect_vlt_unsupported_lock_key".to_string())); assert_eq!(read(tmp.path(), "vlt-lock.json"), lock); - assert!(!ledger_path(tmp.path()).exists() || ledger(tmp.path())["records"][PURL].is_null()); + assert_no_ledger(tmp.path()); } // ── artifact preflight ─────────────────────────────────────────────────── @@ -625,7 +598,7 @@ async fn scan_redirect_vlt_not_driving_entry_not_found_sibling_confirms() { ); assert!(read(tmp.path(), "package-lock.json").contains(&artifact_url(&server))); assert_eq!(redirected(&doc), 1, "{doc:#}"); - assert!(ledger(tmp.path())["records"][PURL].is_object()); + assert_no_ledger(tmp.path()); } /// A dep the vlt rewriter refuses is confirmed by no lock, even when vlt @@ -656,7 +629,7 @@ async fn scan_redirect_vlt_not_driving_refused_dep_is_not_confirmed_by_the_sibli assert_eq!(read(tmp.path(), "vlt-lock.json"), lock); assert!(read(tmp.path(), "package-lock.json").contains(&artifact_url(&server))); assert_eq!(redirected(&doc), 0, "{doc:#}"); - assert!(!ledger_path(tmp.path()).exists() || ledger(tmp.path())["records"][PURL].is_null()); + assert_no_ledger(tmp.path()); } const VENDORED_UUID: &str = "11111111-2222-4333-8444-555555555555"; diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index da069fcb2..7eb92abf3 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -2,20 +2,21 @@ //! (discovery + reference + view) via wiremock, lays down a native `pdm.lock` //! (the committed backtest fixtures) with NO installed package — the lock-only //! fresh-checkout / CI shape — and asserts the lock is repointed at the hosted -//! wheel, the redirect ledger is written, the same-run `--vex` attests the -//! redirect, a re-scan is idempotent, and `rollback` restores every byte. It -//! also covers the PDM-specific relock convergence: `pdm lock` un-patches the -//! lock AND can reflow its line endings (CRLF → LF), so a re-scan must rebase -//! the ledger onto the relocked bytes — adopting the fresh `original` — for -//! `rollback` to still land on the relocked lock. The rewriter bytes themselves -//! are pinned by the core `utils::pdm_lock` tests; this covers the CLI wiring. +//! wheel, NO redirect ledger is written (v5), the same-run `--vex` attests the +//! redirect, a re-scan is idempotent, and `rollback` restores every byte by +//! re-resolving the upstream entry from a mocked PyPI JSON API +//! (`SOCKET_PYPI_JSON_API`). It also covers the PDM-specific relock +//! convergence: `pdm lock` un-patches the lock AND can reflow its line +//! endings (CRLF → LF); a re-scan plans from the relocked bytes and +//! `rollback` lands on the relocked lock. The rewriter bytes themselves are +//! pinned by the core `utils::pdm_lock` tests; this covers the CLI wiring. //! //! Every flow ends with the MANIFEST-LESS VEX step ([`assert_manifestless_vex`], //! the shared `vex_e2e_common` helper): a fresh copy of the committed state -//! (pyproject + lock + `.socket/`, never a manifest in hosted mode) attests the -//! redirect from the ledger offline, from the lock + patch API with the ledger -//! gone (`--patch-server-url` admits the fixture's non-Socket host), omits it -//! `record_unavailable` offline with no ledger (zero requests), and omits it +//! (pyproject + lock, never a manifest or ledger in v5 hosted mode) attests +//! the redirect from the lock + patch API (`--patch-server-url` admits the +//! fixture's non-Socket host), omits it `record_unavailable` offline (zero +//! requests), attests offline from a pre-v5 ledger's record, and omits it //! `redirect_unwired` once the lock is reverted — `--no-verify` included. use std::path::Path; @@ -32,15 +33,15 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; use vex_e2e_common::{ - assert_attested, assert_not_attested, binary, git_sha256, patch_view, run_vex, strip_ledgers, - strip_manifest, Marker, PatchApi, VexRun, + assert_attested, assert_not_attested, binary, git_sha256, patch_view, run_vex, strip_manifest, + Marker, PatchApi, VexRun, }; const ORG: &str = "test-org"; /// Discovery names the base purl (the lock inventory's spelling)… const PURL: &str = "pkg:pypi/urllib3@1.26.18"; -/// …while the patch record carries the API's artifact-qualified purl, which is -/// what the redirect ledger is keyed by. +/// …while the patch record carries the API's artifact-qualified purl (what a +/// pre-v5 redirect ledger was keyed by). const RECORD_PURL: &str = "pkg:pypi/urllib3@1.26.18?artifact_id=py2-py3-none-any-whl"; const UUID: &str = "e828efa5-5c6d-43f3-9909-03f5ac232b98"; const HOSTED_URL: &str = "http://patch.test/patch/pypi/urllib3/1.26.18/22222222-2222-4222-8222-222222222222/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl"; @@ -72,6 +73,83 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { } } +/// The urllib3 1.26.18 release files the PDM fixtures pin, as the PyPI JSON +/// API serves them (`GET /pypi/urllib3/1.26.18/json`). +async fn mock_pypi(server: &MockServer) { + let file = |filename: &str, sha: &str, size: u64, uploaded: &str| { + serde_json::json!({ + "filename": filename, + "url": format!("https://files.pythonhosted.org/packages/ab/cd/{filename}"), + "digests": { "sha256": sha }, + "size": size, + "upload_time_iso_8601": uploaded, + }) + }; + Mock::given(method("GET")) + .and(path("/pypi/urllib3/1.26.18/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "urls": [ + file( + "urllib3-1.26.18-py2.py3-none-any.whl", + "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + 143835, + "2023-10-17T17:46:21.184066Z", + ), + file( + "urllib3-1.26.18.tar.gz", + "f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0", + 305687, + "2023-10-17T17:46:24.000000Z", + ), + ] + }))) + .mount(server) + .await; +} + +/// In-process `rollback` of the hosted pin: the mock patch host is named by +/// `--patch-server-url` (so discovery finds the pin) and the upstream restore +/// re-resolves the release from the mocked PyPI JSON API. +async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { + mock_pypi(server).await; + std::env::set_var("SOCKET_PYPI_JSON_API", format!("{}/pypi", server.uri())); + let code = rollback::run(RollbackArgs { + targets: Vec::new(), + common: GlobalArgs { + patch_server_url: Some(PATCH_SERVER.to_string()), + ..global(cwd, server.uri()) + }, + one_off: false, + preserve_state: false, + }) + .await; + std::env::remove_var("SOCKET_PYPI_JSON_API"); + code +} + +/// A pre-v5 redirect ledger holding `record` under `purl` (no edits). +fn write_legacy_ledger(root: &Path, purl: &str, record: serde_json::Value) { + let dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("redirect-state.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "mode": "hosted", + "records": { purl: record }, + })) + .unwrap(), + ) + .unwrap(); +} + +fn assert_no_ledger(root: &Path) { + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger" + ); +} + fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { paths: Vec::new(), @@ -207,15 +285,13 @@ fn assert_manifestless_vex(root: &Path, pristine_lock: &str) { fn manifestless_vex_steps(root: &Path, pristine_lock: &str) { let vulns: &[(&str, &[&str])] = &[(GHSA, &["CVE-2025-66418"])]; - let api = PatchApi::start(vec![( - UUID.to_string(), - patch_view( - UUID, - RECORD_PURL, - &[("urllib3/response.py", &git_sha256(PATCHED))], - vulns, - ), - )]); + let view = patch_view( + UUID, + RECORD_PURL, + &[("urllib3/response.py", &git_sha256(PATCHED))], + vulns, + ); + let api = PatchApi::start(vec![(UUID.to_string(), view.clone())]); let online = || VexRun { patch_server_url: Some(PATCH_SERVER.to_string()), ..VexRun::online(&api) @@ -228,27 +304,12 @@ fn manifestless_vex_steps(root: &Path, pristine_lock: &str) { std::fs::copy(root.join("pyproject.toml"), copy.join("pyproject.toml")).unwrap(); copy_dir(&root.join(".socket"), ©.join(".socket")); strip_manifest(copy); + assert_no_ledger(copy); - // (1) ledger kept: attested offline from the ledger record (zero - // network), and online. Nothing is installed, so the basis is the - // lock's sha256 pin — which counts only once `--patch-server-url` makes - // the lock's url a discovered hosted reference. - let offline_ledger = VexRun { - offline: true, - patch_server_url: Some(PATCH_SERVER.to_string()), - ..VexRun::default() - }; - for (i, run) in [offline_ledger, online()].into_iter().enumerate() { - let out = run_vex(&binary(), copy, &run); - assert_eq!(out.code, Some(0), "{out}"); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); - if i == 0 { - api.assert_no_requests(); - } - } - - // (2) ledgers gone: the lock alone, the record from the patch API. - strip_ledgers(copy); + // (1) The lock alone, the record from the patch API. Nothing is + // installed, so the basis is the lock's sha256 pin — which counts only + // once `--patch-server-url` makes the lock's url a discovered hosted + // reference. let out = run_vex(&binary(), copy, &online()); assert_eq!(out.code, Some(0), "{out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); @@ -258,7 +319,7 @@ fn manifestless_vex_steps(root: &Path, pristine_lock: &str) { assert_eq!(out.code, Some(2), "{out}"); assert_eq!(out.envelope["error"]["code"], "manifest_not_found", "{out}"); - // (3) offline with no ledger: nothing local to attest from, no network. + // (2) offline with no local record: nothing to attest from, no network. let quiet = PatchApi::empty(); let offline = VexRun { offline: true, @@ -270,12 +331,20 @@ fn manifestless_vex_steps(root: &Path, pristine_lock: &str) { assert_not_attested(&out.envelope, PURL, "record_unavailable"); quiet.assert_no_requests(); - // (4) lock reverted to the registry, ledger kept: dead claim. + // (3) a pre-v5 ledger's record is an extra local record source: the + // same offline run now attests, still with zero requests. + write_legacy_ledger(copy, RECORD_PURL, legacy_record(&view)); + let out = run_vex(&binary(), copy, &offline); + assert_eq!(out.code, Some(0), "{out}"); + assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); + quiet.assert_no_requests(); + + // (4) lock reverted to the registry, that ledger kept: dead claim. let tmp2 = tempfile::tempdir().unwrap(); let reverted = tmp2.path(); write_project(reverted, pristine_lock); std::fs::copy(root.join("pyproject.toml"), reverted.join("pyproject.toml")).unwrap(); - copy_dir(&root.join(".socket"), &reverted.join(".socket")); + copy_dir(©.join(".socket"), &reverted.join(".socket")); strip_manifest(reverted); for no_verify in [false, true] { let out = run_vex( @@ -291,7 +360,25 @@ fn manifestless_vex_steps(root: &Path, pristine_lock: &str) { } } +/// A pre-v5 ledger record (the `PatchRecord` shape) from a view body. +fn legacy_record(view: &serde_json::Value) -> serde_json::Value { + let mut record = view.clone(); + let obj = record.as_object_mut().unwrap(); + obj.remove("purl"); + let exported = obj + .remove("publishedAt") + .unwrap_or_else(|| serde_json::json!("2024-01-01T00:00:00Z")); + obj.insert("exportedAt".to_string(), exported); + obj.entry("description").or_insert_with(|| serde_json::json!("x")); + obj.entry("license").or_insert_with(|| serde_json::json!("MIT")); + obj.entry("tier").or_insert_with(|| serde_json::json!("free")); + record +} + fn copy_dir(from: &Path, to: &Path) { + if !from.is_dir() { + return; + } std::fs::create_dir_all(to).unwrap(); for entry in std::fs::read_dir(from).unwrap().flatten() { let target = to.join(entry.file_name()); @@ -338,20 +425,11 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { PYPROJECT, "pyproject untouched" ); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); - assert!( - ledger["records"][RECORD_PURL].is_object(), - "ledger keyed by the artifact-qualified purl: {ledger}" - ); - assert_eq!( - ledger["edits"][0]["kind"].as_str(), - Some("redirect_pdm_lock_package"), - "{ledger}" - ); - // The redirect is attested from the ledger even though the base purl the - // run confirmed differs from the record's qualified purl only by its - // `?artifact_id=` qualifier (the shared qualifier-strip in vex). + assert_no_ledger(tmp.path()); + // The redirect is attested from this run's fetched record even though + // the base purl the run confirmed differs from the record's qualified + // purl only by its `?artifact_id=` qualifier (the shared qualifier-strip + // in vex). let vex: serde_json::Value = serde_json::from_str(&read(&vex_path)).unwrap(); let statements = vex["statements"].as_array().expect("statements"); assert_eq!(statements.len(), 1, "{vex}"); @@ -369,30 +447,15 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { // 3. The committed state, manifest-less, attests (and only while wired). assert_manifestless_vex(tmp.path(), LOCK); - // 4. rollback unwinds the redirect and drops the record. - let code = rollback::run(RollbackArgs { - targets: Vec::new(), - common: global(tmp.path(), server.uri()), - one_off: false, - preserve_state: false, - }) - .await; + // 4. rollback restores the upstream registry entry. + let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0, "rollback must succeed"); assert_eq!( read(&lock_path), LOCK, "rollback must restore the pristine lock byte for byte" ); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - if ledger_path.exists() { - let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - assert!( - ledger["records"] - .as_object() - .is_none_or(|records| records.is_empty()), - "no redirect record may survive rollback: {ledger}" - ); - } + assert_no_ledger(tmp.path()); } /// A PDM project whose `pyproject.toml` names `hatchling` as its build @@ -400,7 +463,7 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { /// for such a project and then yields to the lock without confirming any, so /// hosted confirmation must key off the pdm rewriter's own report BEFORE the /// hatch gate can veto it — otherwise the lock is rewritten but nothing is -/// recorded or attested. +/// confirmed or attested. #[tokio::test] #[serial] async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { @@ -423,26 +486,19 @@ async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { pyproject, "pyproject untouched" ); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); - assert!( - ledger["records"][RECORD_PURL].is_object(), - "the pdm redirect must be confirmed and recorded despite the hatch backend: {ledger}" - ); - assert_eq!( - ledger["edits"][0]["kind"].as_str(), - Some("redirect_pdm_lock_package"), - "{ledger}" - ); + assert_no_ledger(tmp.path()); + // Confirmed despite the hatch backend: the record was fetched. + let views = server + .received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().ends_with(&format!("/patches/view/{UUID}"))) + .count(); + assert_eq!(views, 1, "the pdm redirect must be confirmed despite the hatch backend"); assert_manifestless_vex(tmp.path(), LOCK); - let code = rollback::run(RollbackArgs { - targets: Vec::new(), - common: global(tmp.path(), server.uri()), - one_off: false, - preserve_state: false, - }) - .await; + let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0, "rollback must succeed"); assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock"); } @@ -462,33 +518,32 @@ async fn legacy_metadata_files_lock_redirects_both_fragments_and_warns() { assert_eq!(code, 0); let redirected = read(&lock_path); assert!(redirected.contains(HOSTED_URL), "{redirected}"); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); - assert_eq!( - ledger["edits"].as_array().unwrap().len(), - 2, - "package unit + [metadata.files] entry: {ledger}" + assert!( + redirected.contains(&format!("url = \"{HOSTED_URL}\"")), + "the package unit gains the hosted url: {redirected}" + ); + assert!( + redirected.contains(&format!( + "\"urllib3 1.26.18\" = [{{ file = \"urllib3-1.26.18-py2.py3-none-any.whl\", \ + hash = \"sha256:{}\" }}]", + sha256() + )), + "the [metadata.files] entry pins the patched wheel: {redirected}" ); + assert_no_ledger(tmp.path()); assert_manifestless_vex(tmp.path(), LOCK_LEGACY); // rollback restores both fragments byte for byte. - let code = rollback::run(RollbackArgs { - targets: Vec::new(), - common: global(tmp.path(), server.uri()), - one_off: false, - preserve_state: false, - }) - .await; + let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0); assert_eq!(read(&lock_path), LOCK_LEGACY, "byte-identical revert"); } /// `pdm lock` un-patches the lock (registry source restored) and — this is the /// PDM-specific hazard — can reflow its line endings (CRLF → LF). The re-scan -/// must rebase the ledger onto the relocked bytes, adopting the fresh -/// `original`, so `rollback` lands on the RELOCKED lock rather than restoring a -/// stale CRLF fragment into an LF file. Appending instead would leave a chain -/// whose older link matches nothing and make rollback refuse. +/// plans from the relocked bytes (v5 keeps no ledger to rebase), and +/// `rollback` lands on the RELOCKED lock: the upstream entry re-resolved in +/// the file's current (LF) line endings, never a stale CRLF fragment. #[tokio::test] #[serial] async fn relock_reflow_then_rescan_keeps_rollback_invertible() { @@ -511,45 +566,28 @@ async fn assert_relock_roundtrip(lock: &str, relocked: &str) { assert_eq!(run(hosted_args(tmp.path(), server.uri(), None)).await, 0); let redirected = read(&lock_path); assert!(redirected.contains(HOSTED_URL)); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - let before: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - let n_edits = before["edits"].as_array().unwrap().len(); + assert_no_ledger(tmp.path()); // The user runs `pdm lock`: the patch is gone and the file is LF now. assert!(!relocked.contains(HOSTED_URL), "relock un-patches the lock"); std::fs::write(&lock_path, relocked).unwrap(); - // Re-scan re-applies and rebases the ledger (no appended chain). + // Re-scan re-applies from the relocked bytes. assert_eq!(run(hosted_args(tmp.path(), server.uri(), None)).await, 0); let rescanned = read(&lock_path); assert!(rescanned.contains(HOSTED_URL), "the re-scan re-redirects"); - let after: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - let edits = after["edits"].as_array().unwrap(); - assert_eq!(edits.len(), n_edits, "rebased, not appended: {after}"); - for edit in edits { - let original = edit["original"].as_str().unwrap(); - assert!( - !original.contains(HOSTED_URL), - "originals describe the relocked registry lock: {original}" - ); - assert!( - rescanned.contains(edit["new"].as_str().unwrap()), - "new fragments describe the current lock: {after}" - ); - } - // The rebased ledger + re-redirected (relocked) lock attest; reverting - // to the relocked registry lock unwires it. + assert!( + !rescanned.contains('\r'), + "the re-scan keeps the relocked LF line endings" + ); + assert_no_ledger(tmp.path()); + // The re-redirected (relocked) lock attests; reverting to the relocked + // registry lock unwires it. assert_manifestless_vex(tmp.path(), relocked); // rollback lands on the relocked (LF, registry) lock — the user's `pdm // lock` is preserved, only the Socket patch is unwound. - let code = rollback::run(RollbackArgs { - targets: Vec::new(), - common: global(tmp.path(), server.uri()), - one_off: false, - preserve_state: false, - }) - .await; + let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0, "rollback after relock + re-scan must succeed"); assert_eq!( read(&lock_path), diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 43d789de1..b176e9a64 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -7,7 +7,9 @@ //! * the lock-only fresh-checkout shape (nothing installed) is discovered //! from `Pipfile.lock` alone, repointed with a `file` reference carrying //! the `#sha256=` fragment and a matching `hashes` entry, attested by the -//! same-run `--vex`, re-scanned idempotently and rolled back byte for byte; +//! same-run `--vex`, re-scanned idempotently and rolled back byte for byte +//! (v5: no redirect ledger is written; `rollback` re-resolves the upstream +//! entry from a mocked PyPI JSON API, `SOCKET_PYPI_JSON_API`); //! * `SOCKET_PIPENV_MAJOR=11` selects the legacy `path` reference shape the //! installer probe would otherwise need a real Pipenv 7–11 on PATH for; //! * a stale `Pipfile.lock` that does not pin the package does not veto @@ -16,8 +18,8 @@ //! out of the same-run attestation. //! //! Every flow ends with the manifest-less VEX steps (`vex_pipenv_pip_steps`) -//! over a copy of the committed state it produced: manifest deleted, -//! ledgers deleted too, `--offline` (`record_unavailable`, zero requests), +//! over a copy of the committed state it produced: no manifest and no ledger, +//! `--offline` (`record_unavailable`, zero requests), //! the lock reverted to the registry (`redirect_unwired`, `--no-verify` //! too) and `apply --vex` — and, for the warm venv, `not_applied` whatever //! the lock says. @@ -44,8 +46,7 @@ use vex_pipenv_pip_steps::{run_manifestless_steps, Records, Steps}; const ORG: &str = "test-org"; /// Discovery names the base purl (the lockfile supplement's spelling)… const PURL: &str = "pkg:pypi/urllib3@1.26.18"; -/// …while the patch record carries the API's artifact-qualified purl, which -/// is what the redirect ledger is keyed by. +/// …while the patch record carries the API's artifact-qualified purl. const RECORD_PURL: &str = "pkg:pypi/urllib3@1.26.18?artifact_id=py2-py3-none-any-whl"; const UUID: &str = "e828efa5-5c6d-43f3-9909-03f5ac232b98"; const HOSTED_URL: &str = "https://patch.socket.dev/patch/pypi/urllib3/1.26.18/22222222-2222-4222-8222-222222222222/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl"; @@ -272,15 +273,62 @@ fn urllib3_entry(lock: &str) -> serde_json::Value { value["default"]["urllib3"].clone() } -async fn roll_back(cwd: &Path, api_url: String) { +/// The urllib3 1.26.18 release files the Pipenv fixture pins, as the PyPI +/// JSON API serves them (`GET /pypi/urllib3/1.26.18/json`). +async fn mock_pypi(server: &MockServer) { + let file = |filename: &str, sha: &str, size: u64, uploaded: &str| { + serde_json::json!({ + "filename": filename, + "url": format!("https://files.pythonhosted.org/packages/ab/cd/{filename}"), + "digests": { "sha256": sha }, + "size": size, + "upload_time_iso_8601": uploaded, + }) + }; + Mock::given(method("GET")) + .and(path("/pypi/urllib3/1.26.18/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "urls": [ + file( + "urllib3-1.26.18-py2.py3-none-any.whl", + "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + 143835, + "2023-10-17T17:46:21.184066Z", + ), + file( + "urllib3-1.26.18.tar.gz", + "f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0", + 305687, + "2023-10-17T17:46:24.000000Z", + ), + ] + }))) + .mount(server) + .await; +} + +/// In-process `rollback`: the hosted pin (on patch.socket.dev) is restored +/// to its upstream entry, re-resolved from the mocked PyPI JSON API. +async fn roll_back(cwd: &Path, server: &MockServer) { + mock_pypi(server).await; + std::env::set_var("SOCKET_PYPI_JSON_API", format!("{}/pypi", server.uri())); let code = rollback::run(RollbackArgs { targets: Vec::new(), - common: global(cwd, api_url), + common: global(cwd, server.uri()), one_off: false, preserve_state: false, }) .await; + std::env::remove_var("SOCKET_PYPI_JSON_API"); assert_eq!(code, 0, "rollback must succeed"); + assert_no_ledger(cwd); +} + +fn assert_no_ledger(root: &Path) { + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger" + ); } #[tokio::test] @@ -309,7 +357,12 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { serde_json::json!([format!("sha256:{}", sha256())]), "{redirected}" ); - assert!(entry.get("version").is_none() && entry.get("index").is_none(), "{entry}"); + assert!(entry.get("version").is_none(), "{entry}"); + assert_eq!( + entry.get("index"), + urllib3_entry(LOCK).get("index"), + "Pipenv's own index is kept for rollback" + ); assert_eq!( entry["markers"], urllib3_entry(LOCK)["markers"], @@ -319,25 +372,10 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { let after: serde_json::Value = serde_json::from_str(&redirected).unwrap(); assert_eq!(after["_meta"], before["_meta"], "the Pipfile content hash stays"); assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE, "Pipfile untouched"); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))) - .unwrap(); - assert!( - ledger["records"][RECORD_PURL].is_object(), - "ledger keyed by the artifact-qualified purl: {ledger}" - ); - assert_eq!( - ledger["edits"][0]["kind"].as_str(), - Some("redirect_pipenv_entry"), - "{ledger}" - ); - assert_eq!( - ledger["edits"][0]["key"].as_str(), - Some(r#"["default","urllib3"]"#), - "{ledger}" - ); - // Attested from the ledger (assume_applied) although the base purl the - // run confirmed differs from the record's qualified purl. + assert_no_ledger(tmp.path()); + // Attested from this run's fetched record (keyed by RECORD_PURL, assume + // applied) although the base purl the run confirmed differs from the + // record's qualified purl. let vex: serde_json::Value = serde_json::from_str(&read(&vex_path)).unwrap(); let statements = vex["statements"].as_array().expect("statements"); assert_eq!(statements.len(), 1, "{vex}"); @@ -348,29 +386,16 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))) - .unwrap(); - assert_eq!(ledger["edits"].as_array().map(Vec::len), Some(1), "one edit, not two"); + assert_no_ledger(tmp.path()); // Manifest-less VEX over the committed state (the depscan / CI shape). manifestless_vex(tmp.path(), "pipenv lock-only", &|p: &Path| { std::fs::write(p.join("Pipfile.lock"), LOCK).unwrap(); }); - // 3. rollback unwinds the redirect and drops the record. - roll_back(tmp.path(), server.uri()).await; + // 3. rollback restores the upstream registry entry. + roll_back(tmp.path(), &server).await; assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock byte for byte"); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - if ledger_path.exists() { - let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - assert!( - ledger["records"] - .as_object() - .is_none_or(|records| records.is_empty()), - "no redirect record may survive rollback: {ledger}" - ); - } } #[tokio::test] @@ -400,7 +425,7 @@ async fn legacy_installer_major_selects_path_references() { std::fs::write(p.join("Pipfile.lock"), LOCK).unwrap(); }); - roll_back(tmp.path(), server.uri()).await; + roll_back(tmp.path(), &server).await; assert_eq!(read(&lock_path), LOCK); } @@ -416,7 +441,11 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { // installs from requirements.txt. let stale = LOCK.replace("\"urllib3\"", "\"six\"").replace("==1.26.18", "==1.16.0"); std::fs::write(tmp.path().join("Pipfile.lock"), &stale).unwrap(); - std::fs::write(tmp.path().join("requirements.txt"), "urllib3==1.26.18\n").unwrap(); + // An unpatched, unhashed sibling makes the file's hash mode derivable, + // so rollback can restore the hosted line (a file whose every line is a + // hosted pin is refused with the `git checkout` remedy instead). + const REQS: &str = "urllib3==1.26.18\nrequests==2.31.0\n"; + std::fs::write(tmp.path().join("requirements.txt"), REQS).unwrap(); let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); @@ -434,13 +463,13 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { // The requirements wiring attests manifest-less; the stale lock beside // it neither vetoes nor contributes. manifestless_vex(tmp.path(), "requirements past a stale lock", &|p: &Path| { - std::fs::write(p.join("requirements.txt"), "urllib3==1.26.18\n").unwrap(); + std::fs::write(p.join("requirements.txt"), REQS).unwrap(); }); - roll_back(tmp.path(), server.uri()).await; + roll_back(tmp.path(), &server).await; assert_eq!( read(&tmp.path().join("requirements.txt")), - "urllib3==1.26.18\n" + REQS ); assert_eq!(read(&tmp.path().join("Pipfile.lock")), stale); } @@ -468,7 +497,7 @@ async fn warm_venv_with_the_upstream_release_is_not_attested() { .then(|| serde_json::from_str::(&read(&vex_path)).unwrap()) .and_then(|v| v["statements"].as_array().map(Vec::len)) .unwrap_or(0); - assert_eq!(attested, 0, "a stale install must not be attested from the ledger"); + assert_eq!(attested, 0, "a stale install must not be attested from the fetched record"); assert_ne!(code, 0, "nothing to attest fails the embedded-VEX run"); assert_eq!( std::fs::read(site_packages(tmp.path()).join("urllib3").join("response.py")).unwrap(), @@ -476,38 +505,34 @@ async fn warm_venv_with_the_upstream_release_is_not_attested() { "the probe is read-only" ); + assert_no_ledger(tmp.path()); + // Manifest-less: the installed UPSTREAM copy is the evidence, whatever - // the lock and the ledger say — `not_applied`, with or without the - // ledger, online. + // the lock says — `not_applied`, online (the record from the API). std::thread::scope(|scope| { scope .spawn(|| { let api = vex_e2e_common::PatchApi::start(vec![(UUID.to_string(), view_body())]); - for strip_ledgers in [false, true] { - let scratch = tempfile::tempdir().unwrap(); - let p = scratch.path().join("proj"); - vex_pipenv_pip_steps::copy_tree(tmp.path(), &p); - vex_e2e_common::strip_manifest(&p); - if strip_ledgers { - vex_e2e_common::strip_ledgers(&p); - } - let out = vex_e2e_common::run_vex( - &vex_e2e_common::binary(), - &p, - &vex_e2e_common::VexRun { - product: Some(VEX_PRODUCT.into()), - ..vex_e2e_common::VexRun::online(&api) - }, - ); - assert_eq!(out.code, Some(1), "ledgers stripped={strip_ledgers}: {out}"); - vex_e2e_common::assert_absent(out.doc.as_ref(), PURL); - vex_e2e_common::assert_not_attested(&out.envelope, PURL, "not_applied"); - } + let scratch = tempfile::tempdir().unwrap(); + let p = scratch.path().join("proj"); + vex_pipenv_pip_steps::copy_tree(tmp.path(), &p); + vex_e2e_common::strip_manifest(&p); + let out = vex_e2e_common::run_vex( + &vex_e2e_common::binary(), + &p, + &vex_e2e_common::VexRun { + product: Some(VEX_PRODUCT.into()), + ..vex_e2e_common::VexRun::online(&api) + }, + ); + assert_eq!(out.code, Some(1), "{out}"); + vex_e2e_common::assert_absent(out.doc.as_ref(), PURL); + vex_e2e_common::assert_not_attested(&out.envelope, PURL, "not_applied"); }) .join() .unwrap_or_else(|e| std::panic::resume_unwind(e)); }); - roll_back(tmp.path(), server.uri()).await; + roll_back(tmp.path(), &server).await; assert_eq!(read(&lock_path), LOCK); } 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 9ceacd246..03da8fcd4 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -4,14 +4,16 @@ //! view) via wiremock, lays down a pnpm project whose only lockfile is a root //! `pnpm-lock.yaml`, runs the redirect, and asserts the patched package's //! `resolution:` was spliced to `{integrity: sha512-, tarball: -//! }` (the shape the shared golden `npm/pnpm` fixture pins) with a -//! `redirect_pnpm_resolution` edit recorded in the revert ledger. +//! }` (the shape the shared golden `npm/pnpm` fixture pins) — and, +//! v5, that no redirect ledger is written: `rollback` restores the upstream +//! entry from the (mocked) npm registry instead. //! //! `in_process_redirect.rs` covers pnpm ONLY through the Rush nested-lock //! path; these tests pin the plain single-project pnpm root-lock rewrite plus //! its idempotency and the `--vex` `(redirected)` attestation. use serial_test::serial; +use socket_patch_cli::commands::rollback::{self, RollbackArgs}; use socket_patch_cli::commands::scan::{run, ScanArgs, ScanMode}; use std::path::Path; @@ -30,6 +32,49 @@ const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; const GHSA: &str = "GHSA-rdir-pnpm-bbbb"; +fn assert_no_ledger(root: &Path) { + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger" + ); +} + +/// In-process `rollback` of the hosted pin: the mock patch host is named by +/// `--patch-server-url` (so discovery finds the pin) and the upstream restore +/// re-resolves `NAME@VERSION` from a mocked npm registry serving +/// `UPSTREAM_SHA512`. +async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { + Mock::given(method("GET")) + .and(path(format!("/npm-registry/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { + "tarball": format!("{}/npm-registry/{NAME}/-/{NAME}-{VERSION}.tgz", server.uri()), + "integrity": UPSTREAM_SHA512, + } + }))) + .mount(server) + .await; + std::env::set_var("SOCKET_NPM_REGISTRY", format!("{}/npm-registry", server.uri())); + let code = rollback::run(RollbackArgs { + targets: Vec::new(), + common: socket_patch_cli::args::GlobalArgs { + cwd: cwd.to_path_buf(), + json: true, + yes: true, + silent: true, + patch_server_url: Some("http://patch.test".to_string()), + ..socket_patch_cli::args::GlobalArgs::default() + }, + one_off: false, + preserve_state: false, + }) + .await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + code +} + /// `--mode hosted` (the documented spelling; the hidden `--redirect` boolean /// folds into it). fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { @@ -113,7 +158,7 @@ async fn mock_reference(server: &MockServer) { .await; } -/// `view/{uuid}` — the patch record persisted into the redirect ledger for VEX. +/// `view/{uuid}` — the patch record the in-run VEX attests from (in memory). async fn mock_view(server: &MockServer) { Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) @@ -187,9 +232,9 @@ snapshots: /// (a) The pnpm root-lock rewrite: the `resolution:` for the patched package /// gains the `tarball:` key pointing at the hosted patch and its integrity -/// becomes the patched sha512, the upstream integrity is gone, a -/// `redirect_pnpm_resolution` edit lands in the ledger, and a second run adds -/// zero edits (idempotent). +/// becomes the patched sha512, the upstream integrity is gone, no ledger is +/// written, a second run is a byte-stable no-op, and `rollback` restores the +/// pristine lock AND removes the auto-created pnpm-workspace.yaml. #[tokio::test] #[serial] async fn hosted_rewrites_pnpm_root_lock_resolution() { @@ -199,6 +244,7 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { let tmp = tempfile::tempdir().unwrap(); write_pnpm_project(tmp.path()); + let pristine_lock = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); let code = run(hosted_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "scan --mode hosted should succeed for pnpm"); @@ -227,27 +273,12 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { "the importer/snapshot keys must be preserved; got:\n{lock}" ); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - let first: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); - let edits = first["edits"].as_array().unwrap(); - assert!( - edits - .iter() - .any(|e| e["kind"] == "redirect_pnpm_resolution" - && e["key"] == format!("{NAME}@{VERSION}")), - "the ledger must record a redirect_pnpm_resolution edit: {first}" - ); - // The ORIGINAL upstream integrity is preserved for revert. - assert!( - first.to_string().contains("UPSTREAMupstream"), - "the ledger must preserve the original upstream integrity for revert: {first}" - ); + assert_no_ledger(tmp.path()); // Zero-touch trust config: a rewritten root v9 lock auto-creates // pnpm-workspace.yaml with the root-only scaffold + `trustLockfile: true` // (pnpm >=11 rejects the redirected lock without it; 9/10 ignore the - // key), and the ledger records the created-file edit for revert. + // key). let ws_path = tmp.path().join("pnpm-workspace.yaml"); let ws = std::fs::read_to_string(&ws_path) .expect("the redirect must auto-create pnpm-workspace.yaml"); @@ -255,28 +286,12 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { ws, "packages:\n - '.'\ntrustLockfile: true\n", "created workspace file must be the scaffold + trust key" ); - assert!( - edits.iter().any(|e| { - e["kind"] == "redirect_pnpm_workspace_trust" - && e["action"] == "created" - && e["path"] == "pnpm-workspace.yaml" - && e["key"] == "trustLockfile" - }), - "the ledger must record the workspace-trust creation: {first}" - ); - // Idempotency: a second run rewrites nothing new — an already-redirected - // resolution must not append duplicate edits (which would poison a revert), - // and the auto-created workspace file must stay byte-stable. + // Idempotency: a second run rewrites nothing — the lock and the + // auto-created workspace file stay byte-stable. + let pristine = pristine_lock; let code = run(hosted_args(tmp.path(), server.uri())).await; assert_eq!(code, 0, "second scan --mode hosted should succeed"); - let second: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); - assert_eq!( - edits.len(), - second["edits"].as_array().unwrap().len(), - "a pnpm re-run must not append duplicate edits: {second}" - ); let lock_after_rerun = std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(); assert_eq!( lock, lock_after_rerun, @@ -287,13 +302,31 @@ async fn hosted_rewrites_pnpm_root_lock_resolution() { ws, "the re-run must leave pnpm-workspace.yaml byte-stable" ); + assert_no_ledger(tmp.path()); + + // rollback: the upstream entry comes back from the registry, and the + // trust key the run added (here: the whole scaffold file) goes with it. + let code = rollback_hosted(tmp.path(), &server).await; + assert_eq!(code, 0, "rollback must restore the pnpm pin"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")).unwrap(), + pristine, + "rollback restores the pristine lock byte for byte" + ); + assert!( + !ws_path.exists(), + "the auto-created pnpm-workspace.yaml is removed with the pin" + ); + assert_no_ledger(tmp.path()); } /// MERGE case: a pre-existing pnpm-workspace.yaml (comments, multi-glob /// packages, catalog — none of it ours) gains EXACTLY one appended /// `trustLockfile: true` line after its last non-empty line; every other -/// byte survives verbatim, and the ledger records the `added` (not -/// `created`) action so a revert removes just that line. +/// byte survives verbatim. v5 keeps no ledger record of that edit, so +/// `rollback` cannot prove the line is ours: it restores the lock entry and +/// leaves the user's file (trust line included) alone, warning +/// `pnpm_trust_lockfile_left` instead of guessing. #[tokio::test] #[serial] async fn hosted_merges_trust_key_into_existing_workspace_yaml_byte_exactly() { @@ -316,17 +349,20 @@ async fn hosted_merges_trust_key_into_existing_workspace_yaml_byte_exactly() { "# team workspace\npackages:\n - '.'\n - 'tools/*'\n\ncatalog:\n react: ^18.0.0\ntrustLockfile: true\n", "the merge must preserve every user byte and append exactly one line" ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); + assert_no_ledger(tmp.path()); + + let code = rollback_hosted(tmp.path(), &server).await; + assert_eq!(code, 0, "rollback must restore the pnpm pin"); assert!( - ledger["edits"].as_array().unwrap().iter().any(|e| { - e["kind"] == "redirect_pnpm_workspace_trust" - && e["action"] == "added" - && e["path"] == "pnpm-workspace.yaml" - }), - "the ledger must record the merged (added) trust edit: {ledger}" + !std::fs::read_to_string(tmp.path().join("pnpm-lock.yaml")) + .unwrap() + .contains(HOSTED_URL), + "the pin is restored upstream" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap(), + ws, + "a user-authored workspace file is never edited by the restore" ); } @@ -370,7 +406,7 @@ async fn hosted_dry_run_writes_neither_lock_nor_workspace_trust() { /// plain `/name@version:` key stays redirectable, but the trustLockfile /// auto-config must NOT fire — pnpm 7/8 have neither the >=11 policy nor the /// flag, so writing trust config for them would be pure noise. No -/// pnpm-workspace.yaml appears and the ledger carries no workspace-trust +/// pnpm-workspace.yaml appears (and no ledger is written) — no workspace-trust /// edit. #[tokio::test] #[serial] @@ -433,11 +469,7 @@ packages: !root.join("pnpm-workspace.yaml").exists(), "a legacy v6 lock must not trigger the trustLockfile auto-config" ); - let ledger = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - !ledger.contains("redirect_pnpm_workspace_trust"), - "no workspace-trust edit may be recorded for a legacy lock: {ledger}" - ); + assert_no_ledger(root); } /// (a2) SCOPED package: pnpm lockfileVersion 9 single-quotes `packages:` keys @@ -563,24 +595,12 @@ snapshots: "the upstream integrity must be replaced; got:\n{lock}" ); - let ledger: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - assert!( - ledger["edits"] - .as_array() - .unwrap() - .iter() - .any(|e| e["kind"] == "redirect_pnpm_resolution" - && e["key"] == format!("{SCOPED_NAME}@{VERSION}")), - "the ledger must record the scoped redirect edit: {ledger}" - ); + assert_no_ledger(root); } /// (b) `scan --mode hosted --vex`: the redirected pnpm patch is attested with /// the `(redirected)` provenance marker (bytes are remote until install, so -/// this is the NO-VERIFY attestation built from the ledger record — the same +/// this is the NO-VERIFY attestation built from this run's fetched record — the same /// contract `scan_redirect_vex_emits_redirected_attestation` pins for npm). #[tokio::test] #[serial] @@ -604,13 +624,8 @@ async fn hosted_pnpm_vex_emits_redirected_attestation() { let code = run(args).await; assert_eq!(code, 0, "scan --mode hosted --vex should succeed for pnpm"); - // The ledger embeds the patch record (so a post-install `vex` can verify). - let ledger = - std::fs::read_to_string(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); - assert!( - ledger.contains("\"records\"") && ledger.contains(GHSA) && ledger.contains(PURL), - "ledger must embed the patch record + vulnerability: {ledger}" - ); + // The record reached the attestation in memory: nothing persisted. + assert_no_ledger(tmp.path()); let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); @@ -635,18 +650,18 @@ async fn hosted_pnpm_vex_emits_redirected_attestation() { /// manifest (hosted mode never writes one), the hosted URL sits on the /// operator's patch server (`--patch-server-url http://patch.test`). /// -/// * not installed, ledger kept: the ledger record + the lock's -/// integrity-pinned wiring attest `(redirected)`; -/// * ledgers deleted: the lockfile reference + the patch API record attest, -/// first from the pin, then hash-verified against an installed copy; -/// * `--offline`, no ledgers: `record_unavailable`, zero requests; -/// * lock reverted, ledger restored: `redirect_unwired`, `--no-verify` too. +/// * not installed, no ledger (v5): the lockfile reference + the patch API +/// record attest `(redirected)` from the integrity pin, then +/// hash-verified against an installed copy; +/// * `--offline`, no local record: `record_unavailable`, zero requests; +/// * a pre-v5 ledger carrying the record serves the offline run; +/// * lock reverted, that ledger kept: `redirect_unwired`, `--no-verify` too. #[tokio::test] #[serial] -async fn hosted_pnpm_manifestless_vex_from_lockfile_ledger_and_api() { +async fn hosted_pnpm_manifestless_vex_from_lockfile_legacy_ledger_and_api() { use vex_e2e_common::{ assert_absent, assert_attested, assert_not_attested, git_sha256, patch_view, run_vex, - strip_ledgers, strip_manifest, Marker, PatchApi, VexRun, + strip_manifest, Marker, PatchApi, VexRun, }; const PATCHED: &[u8] = b"/* patched */\nmodule.exports = 1;\n"; let vulns: &[(&str, &[&str])] = &[(GHSA, &["CVE-2024-9"])]; @@ -683,30 +698,22 @@ async fn hosted_pnpm_manifestless_vex_from_lockfile_ledger_and_api() { std::thread::scope(|s| { s.spawn(|| { let bin = vex_e2e_common::binary(); - let api = PatchApi::start(vec![( - UUID.to_string(), - patch_view( - UUID, - PURL, - &[("package/index.js", &git_sha256(PATCHED))], - vulns, - ), - )]); + let view = patch_view( + UUID, + PURL, + &[("package/index.js", &git_sha256(PATCHED))], + vulns, + ); + let api = PatchApi::start(vec![(UUID.to_string(), view.clone())]); let online = |no_verify| VexRun { patch_server_url: Some("http://patch.test".to_string()), no_verify, ..VexRun::online(&api) }; strip_manifest(root); + assert_no_ledger(root); std::fs::remove_dir_all(root.join("node_modules")).unwrap(); let out = run_vex(&bin, root, &online(false)); - assert_eq!(out.code, Some(0), "[{lock_name}] ledger kept: {out}"); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); - - let ledger = root.join(".socket/vendor/redirect-state.json"); - let ledger_bytes = std::fs::read(&ledger).unwrap(); - strip_ledgers(root); - let out = run_vex(&bin, root, &online(false)); assert_eq!(out.code, Some(0), "[{lock_name}] ledger-less: {out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); assert!(api.view_requests(UUID) >= 1); @@ -735,7 +742,34 @@ async fn hosted_pnpm_manifestless_vex_from_lockfile_ledger_and_api() { assert_not_attested(&out.envelope, PURL, "record_unavailable"); assert_eq!(api.request_count(), seen); - std::fs::write(&ledger, &ledger_bytes).unwrap(); + // A pre-v5 ledger's record is an extra local record source. + let mut record = view.clone(); + let obj = record.as_object_mut().unwrap(); + obj.remove("purl"); + let exported = obj.remove("publishedAt").unwrap(); + obj.insert("exportedAt".to_string(), exported); + let ledger = root.join(".socket/vendor/redirect-state.json"); + std::fs::create_dir_all(ledger.parent().unwrap()).unwrap(); + std::fs::write( + &ledger, + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, "mode": "hosted", "records": { PURL: record }, + })) + .unwrap(), + ) + .unwrap(); + let out = run_vex( + &bin, + root, + &VexRun { + patch_server_url: Some("http://patch.test".to_string()), + ..VexRun::offline() + }, + ); + assert_eq!(out.code, Some(0), "[{lock_name}] legacy ledger, offline: {out}"); + assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); + assert_eq!(api.request_count(), seen); + std::fs::write(root.join(lock_name), &pristine).unwrap(); for no_verify in [false, true] { let out = run_vex(&bin, root, &online(no_verify)); diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index b26c7af3a..9ba3c5a9a 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -2,10 +2,12 @@ //! API (discovery + reference + view) via wiremock, lays down a native //! `poetry.lock` (the committed Poetry 2.4.3 fixture) with NO installed //! package — the lock-only fresh-checkout / CI shape — and asserts the lock is -//! repointed at the hosted wheel, the redirect ledger is written, the same-run -//! `--vex` attests the redirect, a re-scan is idempotent, and `rollback` -//! restores every byte. The rewriter bytes themselves are pinned by the core -//! `poetry_hosted` tests; this covers the CLI wiring around them. +//! repointed at the hosted wheel, NO redirect ledger is written (v5), the +//! same-run `--vex` attests the redirect, a re-scan is idempotent, and +//! `rollback` restores every byte by re-resolving the upstream entry from a +//! mocked PyPI JSON API (`SOCKET_PYPI_JSON_API`). The rewriter bytes +//! themselves are pinned by the core `poetry_hosted` tests; this covers the +//! CLI wiring around them. use std::path::Path; @@ -24,8 +26,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; const ORG: &str = "test-org"; /// Discovery names the base purl (the lockfile supplement's spelling)… const PURL: &str = "pkg:pypi/urllib3@1.26.18"; -/// …while the patch record carries the API's artifact-qualified purl, which -/// is what the redirect ledger is keyed by. +/// …while the patch record carries the API's artifact-qualified purl (what a +/// pre-v5 redirect ledger was keyed by). const RECORD_PURL: &str = "pkg:pypi/urllib3@1.26.18?artifact_id=py2-py3-none-any-whl"; const UUID: &str = "e828efa5-5c6d-43f3-9909-03f5ac232b98"; const HOSTED_URL: &str = "http://patch.test/patch/pypi/urllib3/1.26.18/22222222-2222-4222-8222-222222222222/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl"; @@ -56,6 +58,67 @@ fn global(cwd: &Path, api_url: String) -> GlobalArgs { } } +/// The urllib3 1.26.18 release files the Poetry fixtures pin, as the PyPI +/// JSON API serves them (`GET /pypi/urllib3/1.26.18/json`). +async fn mock_pypi(server: &MockServer) { + let file = |filename: &str, sha: &str, size: u64, uploaded: &str| { + serde_json::json!({ + "filename": filename, + "url": format!("https://files.pythonhosted.org/packages/ab/cd/{filename}"), + "digests": { "sha256": sha }, + "size": size, + "upload_time_iso_8601": uploaded, + }) + }; + Mock::given(method("GET")) + .and(path("/pypi/urllib3/1.26.18/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "urls": [ + file( + "urllib3-1.26.18-py2.py3-none-any.whl", + "34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + 143835, + "2023-10-17T17:46:21.184066Z", + ), + file( + "urllib3-1.26.18.tar.gz", + "f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0", + 305687, + "2023-10-17T17:46:24.000000Z", + ), + ] + }))) + .mount(server) + .await; +} + +/// In-process `rollback` of the hosted pin: the mock patch host is named by +/// `--patch-server-url` (so discovery finds the pin) and the upstream restore +/// re-resolves the release from the mocked PyPI JSON API. +async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { + mock_pypi(server).await; + std::env::set_var("SOCKET_PYPI_JSON_API", format!("{}/pypi", server.uri())); + let code = rollback::run(RollbackArgs { + targets: Vec::new(), + common: GlobalArgs { + patch_server_url: Some("http://patch.test".to_string()), + ..global(cwd, server.uri()) + }, + one_off: false, + preserve_state: false, + }) + .await; + std::env::remove_var("SOCKET_PYPI_JSON_API"); + code +} + +fn assert_no_ledger(root: &Path) { + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger" + ); +} + fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { ScanArgs { paths: Vec::new(), @@ -200,22 +263,10 @@ async fn lock_only_poetry_project_redirects_attests_rescans_and_rolls_back() { PYPROJECT, "pyproject untouched" ); - let ledger: serde_json::Value = serde_json::from_str(&read( - &tmp.path().join(".socket/vendor/redirect-state.json"), - )) - .unwrap(); - assert!( - ledger["records"][RECORD_PURL].is_object(), - "ledger keyed by the artifact-qualified purl: {ledger}" - ); - assert_eq!( - ledger["edits"][0]["kind"].as_str(), - Some("redirect_poetry_lock_package"), - "{ledger}" - ); - // The redirect is attested from the ledger (assume_applied) even though - // the base purl the run confirmed differs from the record's qualified - // purl only by its `?artifact_id=` qualifier. + assert_no_ledger(tmp.path()); + // The redirect is attested from this run's fetched record (assume + // applied) even though the base purl the run confirmed differs from the + // record's qualified purl only by its `?artifact_id=` qualifier. let vex: serde_json::Value = serde_json::from_str(&read(&vex_path)).unwrap(); let statements = vex["statements"].as_array().expect("statements"); assert_eq!(statements.len(), 1, "{vex}"); @@ -234,30 +285,15 @@ async fn lock_only_poetry_project_redirects_attests_rescans_and_rolls_back() { "re-scan must not touch the lock" ); - // 3. rollback unwinds the redirect and drops the record. - let code = rollback::run(RollbackArgs { - targets: Vec::new(), - common: global(tmp.path(), server.uri()), - one_off: false, - preserve_state: false, - }) - .await; + // 3. rollback restores the upstream registry entry. + let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0, "rollback must succeed"); assert_eq!( read(&lock_path), LOCK, "rollback must restore the pristine lock byte for byte" ); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - if ledger_path.exists() { - let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - assert!( - ledger["records"] - .as_object() - .is_none_or(|records| records.is_empty()), - "no redirect record may survive rollback: {ledger}" - ); - } + assert_no_ledger(tmp.path()); } /// What `poetry lock --no-update` on Poetry 1.1 / 1.2 does to a redirected @@ -285,9 +321,9 @@ fn simulate_poetry_1x_relock(lock: &str) -> String { } /// Relock → re-scan → rollback must still land on the pristine lock. The -/// re-scan REBASES the ledger's edits (pristine → freshly written) instead of -/// appending edits recorded against the relocked text, whose older links -/// would match nothing and make rollback (and remove) refuse forever. +/// re-scan plans from the relocked text (v5 keeps no ledger chain to rebase) +/// and rollback re-resolves the upstream entry from the registry, so the +/// relock never strands the unwind. #[tokio::test] #[serial] async fn relock_then_rescan_keeps_rollback_invertible() { @@ -308,13 +344,8 @@ async fn assert_relock_roundtrip(lock: &str) { assert_eq!(run(hosted_args(tmp.path(), server.uri(), None)).await, 0); let redirected = read(&lock_path); - let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); - let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - assert_eq!( - ledger["edits"].as_array().unwrap().len(), - 2, - "package + metadata fragments" - ); + assert!(redirected.contains(HOSTED_URL), "{redirected}"); + assert_no_ledger(tmp.path()); let relocked = simulate_poetry_1x_relock(&redirected); assert_ne!(relocked, redirected); @@ -328,29 +359,9 @@ async fn assert_relock_roundtrip(lock: &str) { rescanned, relocked, "the re-scan must restore the package files entry" ); - let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); - let edits = ledger["edits"].as_array().unwrap(); - assert_eq!(edits.len(), 2, "rebased, not appended: {ledger}"); - for edit in edits { - let original = edit["original"].as_str().unwrap(); - assert!( - !original.contains(HOSTED_URL), - "originals stay pristine: {original}" - ); - let new = edit["new"].as_str().unwrap(); - assert!( - rescanned.contains(new), - "new fragments describe the current lock" - ); - } + assert_no_ledger(tmp.path()); - let code = rollback::run(RollbackArgs { - targets: Vec::new(), - common: global(tmp.path(), server.uri()), - one_off: false, - preserve_state: false, - }) - .await; + let code = rollback_hosted(tmp.path(), &server).await; assert_eq!(code, 0, "rollback after relock + re-scan must succeed"); assert_eq!( read(&lock_path), @@ -456,7 +467,9 @@ async fn stale_python_install_warns_and_cannot_attest_even_on_rescan() { assert_eq!(json["error"]["code"], "no_applicable_patches", "{json}"); assert!(!vex.exists(), "stale bytes cannot produce a VEX file"); } - // A failed fresh record fetch must not bypass the persisted evidence. + // A failed fresh record fetch leaves the probe no evidence (v5 keeps + // no persisted record): the run still succeeds, says the record + // could not be fetched, and never attests. Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) .respond_with(ResponseTemplate::new(404)) @@ -464,12 +477,9 @@ async fn stale_python_install_warns_and_cannot_attest_even_on_rescan() { .mount(&server) .await; let out = scan_output(tmp.path(), &server, &[]).await; - assert!( - out.status.success(), - "{}", - String::from_utf8_lossy(&out.stderr) - ); - assert!(String::from_utf8_lossy(&out.stderr).contains("redirect_pypi_stale_install")); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{stderr}"); + assert!(stderr.contains("record_fetch_failed"), "{stderr}"); assert_eq!( std::fs::read(installed).unwrap(), bytes, @@ -575,15 +585,14 @@ fn manifestless_vex( } /// After `scan --mode hosted` wired the lock-only checkout, VEX needs no -/// manifest (hosted never writes one) and — with the patch API reachable — -/// no ledger either: -/// 1. manifest absent, ledger kept → attests offline from the ledger; -/// 2. ledger deleted → attests from the lock's sha256 pin + the API -/// record, and after an install only when the installed tree hashes to -/// the patch (`not_applied` for the upstream bytes); `apply --vex` -/// agrees; -/// 3. `--offline` without the ledger → `record_unavailable`, no request; -/// 4. the lock reverted with the ledger kept → `redirect_unwired`, also +/// manifest and no ledger (v5 hosted writes neither): +/// 1. attests from the lock's sha256 pin + the API record, and after an +/// install only when the installed tree hashes to the patch +/// (`not_applied` for the upstream bytes); `apply --vex` agrees; +/// 2. `--offline` with no local record → `record_unavailable`, no request; +/// 3. a pre-v5 ledger carrying the record is an extra local record +/// source: the same offline run attests with no request; +/// 4. the lock reverted with that ledger kept → `redirect_unwired`, also /// under `--no-verify`; and the self-hosted origin only counts when /// `--patch-server-url` names it. #[test] @@ -598,8 +607,8 @@ fn manifestless_vex_after_hosted_redirect() { let root = tmp.path(); assert_eq!(rt.block_on(run(hosted_args(root, server.uri(), None))), 0); assert!(!root.join(".socket/manifest.json").exists()); + assert_no_ledger(root); let ledger_path = root.join(".socket/vendor/redirect-state.json"); - let ledger = std::fs::read(&ledger_path).unwrap(); let redirected = read(&root.join("poetry.lock")); let mut view = vex_e2e_common::patch_view( @@ -613,17 +622,10 @@ fn manifestless_vex_after_hosted_redirect() { ); view["files"]["urllib3/response.py"]["beforeHash"] = compute_git_sha256_from_bytes(UPSTREAM).into(); - let api = PatchApi::start(vec![(UUID.into(), view)]); + let api = PatchApi::start(vec![(UUID.into(), view.clone())]); let vulns: &[(&str, &[&str])] = &[(GHSA, &["CVE-2025-66418"])]; - // 1. the ledger alone, offline. - let out = manifestless_vex(root, &api, true, &[]); - assert_eq!(out.code, Some(0), "{out}"); - assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); - api.assert_no_requests(); - - // 2. no ledger: lock pin + API record. - vex_e2e_common::strip_ledgers(root); + // 1. lock pin + API record. let out = manifestless_vex(root, &api, false, &[]); assert_eq!(out.code, Some(0), "{out}"); assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); @@ -658,7 +660,7 @@ fn manifestless_vex_after_hosted_redirect() { assert_not_attested(&out.envelope, PURL, "not_applied"); std::fs::write(&installed, PATCHED).unwrap(); - // 3. offline without the ledger. + // 2. offline with no local record. let seen = api.request_count(); let out = manifestless_vex(root, &api, true, &[]); assert_eq!(out.code, Some(1), "{out}"); @@ -666,8 +668,29 @@ fn manifestless_vex_after_hosted_redirect() { assert!(out.doc.is_none()); assert_eq!(api.request_count(), seen, "--offline made a request"); - // 4. reverted lock, ledger kept. - std::fs::write(&ledger_path, &ledger).unwrap(); + // 3. a pre-v5 ledger's record serves the offline run. + let mut record = view; + let obj = record.as_object_mut().unwrap(); + obj.remove("purl"); + let exported = obj.remove("publishedAt").unwrap(); + obj.insert("exportedAt".to_string(), exported); + std::fs::create_dir_all(ledger_path.parent().unwrap()).unwrap(); + std::fs::write( + &ledger_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "version": 1, + "mode": "hosted", + "records": { RECORD_PURL: record }, + })) + .unwrap(), + ) + .unwrap(); + let out = manifestless_vex(root, &api, true, &[]); + assert_eq!(out.code, Some(0), "{out}"); + assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); + assert_eq!(api.request_count(), seen, "--offline made a request"); + + // 4. reverted lock, that ledger kept. std::fs::write(root.join("poetry.lock"), LOCK).unwrap(); for extra in [&[][..], &["--no-verify"][..]] { for offline in [true, 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 index 3fcd5a245..5f6072e01 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1,14 +1,22 @@ -//! In-process rollback tests for HOSTED-mode state (the redirect ledger). +//! In-process rollback tests for HOSTED-mode state. +//! +//! v5 hosted mode keeps no ledger: the hosted pins ARE the lockfile entries +//! (discovered on `--patch-server-url`'s origin for these mock-host URLs), +//! and rollback restores each in-scope pin to its DEFAULT UPSTREAM registry +//! entry, re-resolved from the (wiremocked) registry through the +//! `SOCKET_NPM_REGISTRY` base override. A refused pin (offline, registry +//! failure) is left untouched and reported. A pre-v5 ledger is never +//! replayed; it is retired once no hosted pin remains. //! //! 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. +//! byte-compare the lockfiles against their pristine snapshots. The other +//! fixtures hand-write hosted yarn.lock entries (and, for the migration +//! tests, a pre-v5 ledger through the exported +//! `socket_patch_core::patch::redirect` types). //! //! Convention split (the same one `in_process_redirect.rs` documents): //! in-process `rollback::run(RollbackArgs)` for exit codes + on-disk @@ -51,12 +59,12 @@ const HOSTED_URL: &str = "http://patch.test/patch/npm/in-proc-redirect/1.0.0/222 const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; const GHSA: &str = "GHSA-rbhr-aaaa-bbbb"; -// ── the hand-written two-record ledger fixture ────────────────────────────── +// ── the hand-written hosted yarn.lock pins ────────────────────────────────── 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 IO_PURL: &str = "pkg:npm/is-odd@3.0.1"; +const IO_HOSTED_URL: &str = "http://patch.test/patch/npm/is-odd/3.0.1/66666666-6666-4666-8666-666666666666/99999999-9999-4999-8999-999999999999/is-odd-3.0.1.tgz"; const GEM_UPSTREAM_REMOTE: &str = "https://rubygems.org/"; const GEM_PATCH_REMOTE: &str = "http://patch.test/gems/t0k3nt0k3n/"; @@ -98,6 +106,7 @@ async fn rollback_in_process(cwd: &Path, targets: Vec, preserve_state: b json: true, yes: true, silent: true, + patch_server_url: Some("http://patch.test".to_string()), ..socket_patch_cli::args::GlobalArgs::default() }, one_off: false, @@ -111,6 +120,49 @@ async fn rollback_in_process(cwd: &Path, targets: Vec, preserve_state: b code } +/// Serve the npm registry's version document for the real-flow fixture's +/// package, so the upstream restore can re-resolve its pristine entry. +async fn mock_npm_registry(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!("/npm-registry/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { + "tarball": format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + "integrity": "sha512-UPSTREAMupstream==", + } + }))) + .mount(server) + .await; +} + +/// Bare in-process rollback that may reach the (mocked) npm registry: the +/// upstream restore re-resolves each hosted pin's registry entry. +async fn rollback_online(cwd: &Path, server: &MockServer) -> i32 { + std::env::set_var( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", server.uri()), + ); + let args = RollbackArgs { + targets: Vec::new(), + common: socket_patch_cli::args::GlobalArgs { + cwd: cwd.to_path_buf(), + manifest_path: ".socket/manifest.json".to_string(), + json: true, + yes: true, + silent: true, + patch_server_url: Some("http://patch.test".to_string()), + ..socket_patch_cli::args::GlobalArgs::default() + }, + one_off: false, + preserve_state: false, + }; + let code = rollback_run(args).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + 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 @@ -151,7 +203,8 @@ fn scrubbed_cli() -> std::process::Command { cmd } -/// Run `rollback --json --yes --offline [extra]` as a scrubbed subprocess +/// Run `rollback --json --yes --offline [extra]` (the mock patch host +/// recognized as hosted) 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) { @@ -161,6 +214,39 @@ fn run_rollback_subprocess(cwd: &Path, extra: &[&str]) -> (i32, Value) { "--json", "--yes", "--offline", + "--patch-server-url", + "http://patch.test", + "--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) +} + +/// [`run_rollback_subprocess`] ONLINE: no `--offline`, the npm registry +/// pointed at `server`'s `/npm-registry` (see [`mock_yarn_registry`] / +/// [`mock_npm_registry`]), and the mock patch host recognized as hosted. +fn run_rollback_subprocess_online(cwd: &Path, server: &MockServer, extra: &[&str]) -> (i32, Value) { + let out = scrubbed_cli() + .env( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", server.uri()), + ) + .args([ + "rollback", + "--json", + "--yes", + "--patch-server-url", + "http://patch.test", "--cwd", cwd.to_str().unwrap(), ]) @@ -323,8 +409,7 @@ 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}`). +/// A full camelCase patch record for the hand-written pre-v5 ledgers. fn patch_record(uuid: &str, ghsa: &str) -> PatchRecord { let mut files = HashMap::new(); files.insert( @@ -355,41 +440,76 @@ fn patch_record(uuid: &str, ghsa: &str) -> PatchRecord { } } -/// 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) { +/// Serialize a PRE-V5 hosted ledger (v5 never writes one) through the real +/// core writer (real schema: version, mode "hosted", edits[FileEdit], +/// records{purl: record}) — what an older release left on disk. +async fn write_legacy_ledger(root: &Path, edits: Vec) { let mut state = RedirectState::new(); state.edits = edits; - for (purl, record) in records { - state.records.insert(purl.to_string(), record); - } + state.records.insert( + LP_PURL.to_string(), + patch_record(LP_UUID, "GHSA-lpad-aaaa-bbbb"), + ); 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. +/// Serve the npm registry's version document for `name@version` under +/// `/npm-registry` (the `SOCKET_NPM_REGISTRY` base `rollback_online` and +/// `run_rollback_subprocess_online` set) in the shape a yarn-classic +/// restore turns back into [`yarn_upstream_block`]. +async fn mock_yarn_registry(server: &MockServer, name: &str, version: &str) { + Mock::given(method("GET")) + .and(path(format!("/npm-registry/{name}/{version}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": name, + "version": version, + "dist": { + "tarball": format!("https://registry.yarnpkg.com/{name}/-/{name}-{version}.tgz"), + "shasum": "aaaa", + "integrity": "sha512-UPSTREAMupstream==", + } + }))) + .mount(server) + .await; +} + +// ── yarn-classic fragments ────────────────────────────────────────────────── +// The hosted wiring is the lock entry itself; the upstream block is exactly +// what the restore re-derives from `mock_yarn_registry`'s document. -fn yarn_block(resolved: &str, integrity: &str) -> String { +fn yarn_block_for(name: &str, version: &str, resolved: &str, integrity: &str) -> String { format!( - "left-pad@1.2.3:\n version \"1.2.3\"\n resolved \"{resolved}\"\n integrity {integrity}" + "{name}@{version}:\n version \"{version}\"\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", +fn yarn_block(resolved: &str, integrity: &str) -> String { + yarn_block_for("left-pad", "1.2.3", resolved, integrity) +} + +fn yarn_upstream_block(name: &str, version: &str) -> String { + yarn_block_for( + name, + version, + &format!("https://registry.yarnpkg.com/{name}/-/{name}-{version}.tgz#aaaa"), "sha512-UPSTREAMupstream==", ) } +fn yarn_original_block() -> String { + yarn_upstream_block("left-pad", "1.2.3") +} + fn yarn_redirected_block() -> String { yarn_block(LP_HOSTED_URL, "sha512-PATCHEDpatched==") } +fn io_redirected_block() -> String { + yarn_block_for("is-odd", "3.0.1", IO_HOSTED_URL, "sha512-PATCHEDio==") +} + fn yarn_lock_content(block: &str) -> String { format!( "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ @@ -397,6 +517,7 @@ fn yarn_lock_content(block: &str) -> String { ) } +/// The yarn-classic edit a pre-v5 ledger recorded for the left-pad pin. fn yarn_classic_edit() -> FileEdit { FileEdit { path: "yarn.lock".to_string(), @@ -408,10 +529,7 @@ fn yarn_classic_edit() -> FileEdit { } } -// ── 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. +// ── other pre-v5 ledger edits (never replayed) ────────────────────────────── fn gemfile_lock_content(remote: &str) -> String { format!( @@ -431,45 +549,40 @@ fn gem_source_edit() -> FileEdit { } } -/// 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) { +/// An edit kind no release understands (a ledger from a newer build). +fn future_lock_edit() -> FileEdit { + FileEdit { + path: "future.lock".to_string(), + kind: "redirect_future_lock_entry".to_string(), + action: "rewritten".to_string(), + key: Some("left-pad@1.2.3".to_string()), + original: Some(Value::String( + "left-pad@1.2.3 sha512-UPSTREAMupstream==".to_string(), + )), + new: Some(Value::String(format!("left-pad@1.2.3 {LP_HOSTED_URL}"))), + } +} + +/// Single-pin npm fixture: a yarn.lock hosted-wired to the mock patch host. +fn write_single_npm_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) { +/// Two-pin fixture: left-pad then is-odd, both hosted in one yarn.lock. +fn write_two_pin_fixture(root: &Path) { std::fs::write( root.join("yarn.lock"), - yarn_lock_content(&yarn_redirected_block()), + yarn_lock_content(&format!( + "{}\n\n{}", + yarn_redirected_block(), + io_redirected_block() + )), ) .unwrap(); - write_hosted_ledger( - root, - vec![(LP_PURL, patch_record(LP_UUID, "GHSA-lpad-aaaa-bbbb"))], - vec![yarn_classic_edit()], - ) - .await; } // --------------------------------------------------------------------------- @@ -477,9 +590,9 @@ async fn write_single_npm_fixture(root: &Path) { // --------------------------------------------------------------------------- /// 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. +/// rewritten, NO ledger written) → bare in-process rollback restoring the +/// upstream entry from the mocked registry → exit 0, lock byte-identical to +/// pristine, and nothing materialized under `.socket/` as a side effect. #[tokio::test] #[serial] async fn npm_hosted_round_trip() { @@ -501,11 +614,12 @@ async fn npm_hosted_round_trip() { ); 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" + !ledger_path(tmp.path()).exists(), + "scan --mode hosted keeps no redirect ledger: the lockfile is the record" ); - let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + mock_npm_registry(&server).await; + let code = rollback_online(tmp.path(), &server).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(); @@ -513,27 +627,17 @@ async fn npm_hosted_round_trip() { 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" - ); assert!( !tmp.path().join(".socket").exists(), - "a fully unwound hosted project keeps no .socket/ residue: the ledger's \ - vendor/ dir is pruned with it and the lock guard removes apply.lock and \ + "a fully restored hosted project keeps no .socket/ residue: no manifest \ + or ledger is materialized, and the lock guard removes apply.lock and \ the emptied directory" ); } -/// 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. +/// Dry-run twin of the round trip: a hosted npm dry run resolves the +/// upstream entry exactly like a wet run (the registry IS asked), exits 0, +/// and mutates NOTHING (no ledger is ever written either). #[tokio::test] #[serial] async fn npm_hosted_dry_run_previews_cleanly() { @@ -547,40 +651,50 @@ async fn npm_hosted_dry_run_previews_cleanly() { 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(); + mock_npm_registry(&server).await; + std::env::set_var( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", server.uri()), + ); 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, + patch_server_url: Some("http://patch.test".to_string()), ..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_NPM_REGISTRY"); 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!(code, 0, "a hosted npm dry run must preview cleanly"); + let registry_hits = server + .received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().starts_with("/npm-registry/")) + .count(); + assert!( + registry_hits >= 1, + "a dry run resolves the upstream entry like a wet run" ); 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" + assert!( + !tmp.path().join(".socket").exists(), + "dry run must write no ledger (or any .socket/ state)" ); } @@ -600,7 +714,8 @@ async fn npm_hosted_round_trip_envelope() { 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(), &[]); + mock_npm_registry(&server).await; + let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); assert_eq!(code, 0, "bare rollback should exit 0: {envelope}"); assert_eq!(envelope["status"], "success", "{envelope}"); assert_eq!( @@ -617,7 +732,7 @@ async fn npm_hosted_round_trip_envelope() { assert_eq!( envelope["manifest"]["removedEntries"], serde_json::json!([]), - "hosted state lives in the ledger, not the manifest: {envelope}" + "hosted state lives in the lockfile, not the manifest: {envelope}" ); assert!( warning_codes(&envelope).contains(&"reinstall_required".to_string()), @@ -626,7 +741,10 @@ async fn npm_hosted_round_trip_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"); + assert!( + !ledger_path(tmp.path()).exists(), + "no ledger is ever written" + ); } // --------------------------------------------------------------------------- @@ -636,8 +754,9 @@ async fn npm_hosted_round_trip_envelope() { /// 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. +/// byte-for-byte to the upstream `name==version` line (the file is +/// unhashed, so no registry lookup is needed and the run may stay offline), +/// no ledger ever written, exit 0. #[tokio::test] #[serial] async fn pypi_requirements_hosted_round_trip() { @@ -726,10 +845,9 @@ async fn pypi_requirements_hosted_round_trip() { 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}" + !ledger_path(tmp.path()).exists(), + "get --mode hosted keeps no ledger: requirements.txt is the record" ); // Manifest-less VEX over the committed hosted state (an EMPTY in-project @@ -767,10 +885,6 @@ async fn pypi_requirements_hosted_round_trip() { 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" @@ -807,249 +921,193 @@ async fn pypi_requirements_hosted_round_trip() { } // --------------------------------------------------------------------------- -// 3. scoped rollback of an unsupported ecosystem fails closed +// 3. scoped rollback restores only the named pins // --------------------------------------------------------------------------- -/// 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. +/// Two hosted pins in one yarn.lock, scoped to ONE: only the named pin is +/// restored to its upstream registry entry (each pin restores on its own; +/// there is no whole-state replay), the other stays hosted byte-for-byte, +/// and a pre-v5 ledger beside them is kept while a pin remains. The +/// unscoped follow-up restores the other pin and then retires the ledger. #[tokio::test] #[serial] -async fn scoped_unsupported_ecosystem_fails_closed() { +async fn scoped_rollback_restores_only_the_named_pin() { + let server = MockServer::start().await; + mock_yarn_registry(&server, "left-pad", "1.2.3").await; + mock_yarn_registry(&server, "is-odd", "3.0.1").await; let tmp = tempfile::tempdir().unwrap(); - write_two_record_fixture(tmp.path()).await; + write_two_pin_fixture(tmp.path()); + write_legacy_ledger(tmp.path(), vec![yarn_classic_edit()]).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}" - ); + let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[LP_PURL]); + assert_eq!(code, 0, "{envelope}"); + assert_eq!(envelope["status"], "success", "{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" + serde_json::json!([LP_PURL]), + "only the named pin is restored: {envelope}" ); + assert_eq!(envelope["hosted"]["failed"], serde_json::json!([])); 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" - ); -} - -/// A ledger written by a newer socket-patch carries a hosted edit kind this -/// release has no revert for (`redirect_future_lock_entry`). A scoped -/// rollback of the purl it names must refuse with nothing written, and an -/// unscoped one must keep the record while that edit survives. -async fn write_unknown_kind_ledger_fixture(root: &Path, with_gem: bool) -> String { - let future_new = format!("left-pad@1.2.3 {LP_HOSTED_URL}"); - let future_lock = format!("{future_new}\n"); - std::fs::write(root.join("future.lock"), &future_lock).unwrap(); - std::fs::write( - root.join("yarn.lock"), - yarn_lock_content(&yarn_redirected_block()), - ) - .unwrap(); - let mut records = vec![(LP_PURL, patch_record(LP_UUID, "GHSA-lpad-aaaa-bbbb"))]; - let mut edits = vec![yarn_classic_edit()]; - if with_gem { - std::fs::write( - root.join("Gemfile.lock"), - gemfile_lock_content(GEM_PATCH_REMOTE), - ) - .unwrap(); - records.push((GEM_PURL, patch_record(GEM_UUID, "GHSA-gems-cccc-dddd"))); - edits.push(gem_source_edit()); - } - edits.push(FileEdit { - path: "future.lock".to_string(), - kind: "redirect_future_lock_entry".to_string(), - action: "rewritten".to_string(), - key: Some("left-pad@1.2.3".to_string()), - original: Some(Value::String( - "left-pad@1.2.3 sha512-UPSTREAMupstream==".to_string(), + yarn_lock_content(&format!( + "{}\n\n{}", + yarn_original_block(), + io_redirected_block() )), - new: Some(Value::String(future_new)), - }); - write_hosted_ledger(root, records, edits).await; - future_lock -} - -fn ledger_edit_kinds(root: &Path) -> Vec { - let ledger: Value = serde_json::from_slice(&std::fs::read(ledger_path(root)).unwrap()).unwrap(); - ledger["edits"] - .as_array() - .unwrap() - .iter() - .map(|e| e["kind"].as_str().unwrap().to_string()) - .collect() -} - -#[tokio::test] -#[serial] -async fn scoped_rollback_refuses_a_purl_named_by_an_unknown_edit_kind() { - let tmp = tempfile::tempdir().unwrap(); - let future_lock = write_unknown_kind_ledger_fixture(tmp.path(), true).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 (code, envelope) = run_rollback_subprocess(tmp.path(), &[LP_PURL]); - assert_eq!(code, 1, "{envelope}"); - assert_eq!(envelope["status"], "partial_failure", "{envelope}"); - assert_eq!( - envelope["hosted"]["reverted"], - serde_json::json!([]), - "{envelope}" - ); - let failed = envelope["hosted"]["failed"].as_array().unwrap(); - assert_eq!(failed.len(), 1, "{envelope}"); - assert_eq!(failed[0]["purl"], LP_PURL); - assert!( - failed[0]["error"].as_str().unwrap().contains( - "redirect_future_lock_entry edit this socket-patch release does not understand" - ), - "{envelope}" + "the out-of-scope pin must stay hosted" ); assert_eq!( std::fs::read(ledger_path(tmp.path())).unwrap(), - ledger_before + ledger_before, + "a pre-v5 ledger is kept while a hosted pin remains" ); + + let code = rollback_online(tmp.path(), &server).await; + assert_eq!(code, 0, "the unscoped follow-up restores the rest"); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), - yarn_before + yarn_lock_content(&format!( + "{}\n\n{}", + yarn_original_block(), + yarn_upstream_block("is-odd", "3.0.1") + )) ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("future.lock")).unwrap(), - future_lock + assert!( + !ledger_path(tmp.path()).exists(), + "with no hosted pin left the pre-v5 ledger is retired" ); } +/// A ledger written by an OLDER (or newer) socket-patch may carry edits of +/// kinds this release never replays — an unknown kind, a gem source edit. +/// v5 never replays a ledger at all: the yarn pin is restored from the +/// registry (not from the ledger's recorded original), the files the +/// ledger's other edits name are left byte-identical, and the ledger is +/// retired once no hosted pin remains. Scoped and unscoped alike. #[tokio::test] #[serial] -async fn unscoped_rollback_holds_the_record_beside_an_unknown_edit_kind() { - let tmp = tempfile::tempdir().unwrap(); - let future_lock = write_unknown_kind_ledger_fixture(tmp.path(), true).await; +async fn legacy_ledger_edits_of_any_kind_are_never_replayed() { + for scoped in [true, false] { + let server = MockServer::start().await; + mock_yarn_registry(&server, "left-pad", "1.2.3").await; + let tmp = tempfile::tempdir().unwrap(); + write_single_npm_fixture(tmp.path()); + let future_lock = format!("left-pad@1.2.3 {LP_HOSTED_URL}\n"); + std::fs::write(tmp.path().join("future.lock"), &future_lock).unwrap(); + let gem_lock = gemfile_lock_content(GEM_PATCH_REMOTE); + std::fs::write(tmp.path().join("Gemfile.lock"), &gem_lock).unwrap(); + write_legacy_ledger( + tmp.path(), + vec![ + // Its recorded original disagrees with the registry: a + // replay would write it, the restore must not. + FileEdit { + original: Some(Value::String(yarn_block( + "https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.3.tgz#bbbb", + "sha512-LEDGERledger==", + ))), + ..yarn_classic_edit() + }, + gem_source_edit(), + future_lock_edit(), + ], + ) + .await; - let code = rollback_in_process(tmp.path(), Vec::new(), false).await; - assert_eq!( - code, 1, - "an unknown edit kind must fail the rollback closed" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("future.lock")).unwrap(), - future_lock - ); - let ledger: Value = - serde_json::from_slice(&std::fs::read(ledger_path(tmp.path())).unwrap()).unwrap(); - assert!(ledger["records"].get(LP_PURL).is_some(), "{ledger}"); - assert!(ledger["records"].get(GEM_PURL).is_some(), "{ledger}"); - // The groups this release understands still unwind on disk; their - // records wait for the unknown group to clear. - assert_eq!( - ledger_edit_kinds(tmp.path()), - ["redirect_future_lock_entry"] - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), - yarn_lock_content(&yarn_original_block()) - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(), - gemfile_lock_content(GEM_UPSTREAM_REMOTE) - ); + let targets: &[&str] = if scoped { &[LP_PURL] } else { &[] }; + let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, targets); + assert_eq!(code, 0, "scoped={scoped}: {envelope}"); + assert_eq!( + envelope["hosted"]["reverted"], + serde_json::json!([LP_PURL]), + "scoped={scoped}: {envelope}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&yarn_original_block()), + "scoped={scoped}: the entry comes back from the registry, not the ledger" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("future.lock")).unwrap(), + future_lock, + "scoped={scoped}: an unknown-kind ledger edit is never replayed" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(), + gem_lock, + "scoped={scoped}: a ledger-only gem edit is never replayed" + ); + assert!( + !ledger_path(tmp.path()).exists(), + "scoped={scoped}: the pre-v5 ledger is retired once no hosted pin remains" + ); + } } -/// With the purl as the only hosted record the scope covers the whole -/// ledger: the per-purl claim refuses, then the replay still unwinds -/// yarn.lock but holds the record and the unknown edit. +// --------------------------------------------------------------------------- +// 4. a refused pin fails closed on its own +// --------------------------------------------------------------------------- + +/// The registry answers for one pin and not the other: the answered pin is +/// restored, the other is REFUSED with the `git checkout` remedy +/// (`hosted.failed`, `partial_failure`, exit 1) and its block stays hosted +/// byte-for-byte. An `--offline` run refuses every pin and writes nothing. #[tokio::test] #[serial] -async fn scoped_rollback_of_the_only_record_holds_it_beside_an_unknown_edit_kind() { - let tmp = tempfile::tempdir().unwrap(); - let future_lock = write_unknown_kind_ledger_fixture(tmp.path(), false).await; +async fn a_refused_pin_fails_closed_beside_a_restored_one() { + let server = MockServer::start().await; + mock_yarn_registry(&server, "is-odd", "3.0.1").await; - let (code, envelope) = run_rollback_subprocess(tmp.path(), &[LP_PURL]); + // Offline: both refused, nothing written. + let tmp = tempfile::tempdir().unwrap(); + write_two_pin_fixture(tmp.path()); + let wired = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + let (code, envelope) = run_rollback_subprocess(tmp.path(), &[]); assert_eq!(code, 1, "{envelope}"); - assert_eq!( - envelope["hosted"]["reverted"], - serde_json::json!([]), - "{envelope}" - ); + assert_eq!(envelope["status"], "partial_failure", "{envelope}"); + assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([])); let failed: Vec<&str> = envelope["hosted"]["failed"] .as_array() .unwrap() .iter() .map(|f| f["purl"].as_str().unwrap()) .collect(); - assert_eq!(failed, [LP_PURL, "group:unknown"], "{envelope}"); - assert_eq!( - std::fs::read_to_string(tmp.path().join("future.lock")).unwrap(), - future_lock + assert_eq!(failed, [IO_PURL, LP_PURL], "{envelope}"); + assert!( + envelope["hosted"]["failed"][0]["error"] + .as_str() + .is_some_and(|e| e.contains("this run is offline") + && e.contains( + "restore it from version control instead (`git checkout -- yarn.lock`)" + )), + "{envelope}" ); assert_eq!( std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), - yarn_lock_content(&yarn_original_block()) - ); - let ledger: Value = - serde_json::from_slice(&std::fs::read(ledger_path(tmp.path())).unwrap()).unwrap(); - assert!(ledger["records"].get(LP_PURL).is_some(), "{ledger}"); - assert_eq!( - ledger_edit_kinds(tmp.path()), - ["redirect_future_lock_entry"] + wired, + "an offline run writes nothing" ); -} - -// --------------------------------------------------------------------------- -// 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"); + // Online, left-pad unanswered (404): is-odd restores on its own. + let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); + assert_eq!(code, 1, "{envelope}"); + assert_eq!(envelope["status"], "partial_failure", "{envelope}"); + assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([IO_PURL])); 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)" + envelope["hosted"]["failed"][0]["purl"], LP_PURL, + "{envelope}" ); + assert_eq!(envelope["hosted"]["unsupported"], serde_json::json!([])); 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" + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&format!( + "{}\n\n{}", + yarn_redirected_block(), + yarn_upstream_block("is-odd", "3.0.1") + )), + "the refused pin stays hosted, the other is restored" ); } @@ -1057,18 +1115,22 @@ async fn unscoped_replays_unsupported_ecosystems() { // 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 not fatal when a ledger holds work. A TRULY empty directory keeps the legacy "Manifest not found" -/// exit-1 error. +/// A hosted-only project (a wired lock, NO manifest, NO ledger) rolls back +/// fine — a missing manifest is not fatal when the lockfiles pin hosted +/// patches. A project whose only state is a stale pre-v5 ledger retires it +/// and exits 0. 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. + // Hosted-only: restores and exits 0. + let server = MockServer::start().await; + mock_yarn_registry(&server, "left-pad", "1.2.3").await; let tmp = tempfile::tempdir().unwrap(); - write_single_npm_fixture(tmp.path()).await; - assert!(!tmp.path().join(".socket/manifest.json").exists()); + write_single_npm_fixture(tmp.path()); + assert!(!tmp.path().join(".socket").exists()); - let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + let code = rollback_online(tmp.path(), &server).await; assert_eq!( code, 0, "a manifest-less hosted-only project must roll back fine" @@ -1076,15 +1138,22 @@ async fn hosted_only_project_without_manifest() { 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" + "the hosted pin must be restored to its upstream entry" ); - assert!(!ledger_path(tmp.path()).exists(), "ledger must be deleted"); assert!( !tmp.path().join(".socket").exists(), - "no manifest may be materialized, and the emptied .socket/ (ledger and \ - vendor/ pruned, apply.lock removed) must be gone" + "no manifest or ledger may be materialized, and apply.lock is removed" ); + // Only a stale pre-v5 ledger: retired, exit 0. + let stale = tempfile::tempdir().unwrap(); + write_legacy_ledger(stale.path(), vec![yarn_classic_edit()]).await; + let (code, envelope) = run_rollback_subprocess(stale.path(), &[]); + assert_eq!(code, 0, "{envelope}"); + assert_eq!(envelope["status"], "success", "{envelope}"); + assert_eq!(envelope["legacyRedirectLedgerRemoved"], true, "{envelope}"); + assert!(!ledger_path(stale.path()).exists()); + // Truly empty: all three stores absent keeps the legacy error. let empty = tempfile::tempdir().unwrap(); let (code, envelope) = run_rollback_subprocess(empty.path(), &[]); @@ -1103,20 +1172,23 @@ async fn hosted_only_project_without_manifest() { } // --------------------------------------------------------------------------- -// 6. --preserve-state still unwinds hosted state +// 6. --preserve-state still restores hosted pins // --------------------------------------------------------------------------- -/// Hosted redirects have no preservable local state: a `--preserve-state` -/// run still unwinds the wiring and drops the ledger records, surfacing the +/// Hosted pins have no preservable local state: a `--preserve-state` run +/// still restores the upstream entry, 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 server = MockServer::start().await; + mock_yarn_registry(&server, "left-pad", "1.2.3").await; let tmp = tempfile::tempdir().unwrap(); - write_single_npm_fixture(tmp.path()).await; + write_single_npm_fixture(tmp.path()); - let (code, envelope) = run_rollback_subprocess(tmp.path(), &["--preserve-state"]); + let (code, envelope) = + run_rollback_subprocess_online(tmp.path(), &server, &["--preserve-state"]); assert_eq!( code, 0, "preserve-state hosted rollback exits 0: {envelope}" @@ -1125,11 +1197,11 @@ async fn preserve_state_still_unwinds_hosted() { assert_eq!( envelope["hosted"]["reverted"], serde_json::json!([LP_PURL]), - "the wiring must still be unwound under --preserve-state: {envelope}" + "the pin must still be restored 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}" + "restoring hosted pins under --preserve-state must be surfaced: {envelope}" ); assert_eq!( envelope["manifest"]["preserved"], true, @@ -1143,31 +1215,26 @@ async fn preserve_state_still_unwinds_hosted() { 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" + "the hosted pin must be restored on disk" ); assert!( !tmp.path().join(".socket").exists(), - "with nothing preservable, the emptied .socket/ is gone too" + "with nothing preservable, no .socket/ is left behind" ); } /// Manifest-less VEX across the hosted npm round trip. Before the rollback -/// a checkout of the committed state (package.json, the redirected lock, -/// `.socket/`; nothing installable — the host is fictional — so the lock pin -/// is the basis) attests `(redirected)` with the ledger, and without it from -/// lockfile discovery + the patch API. After the bare rollback the restored -/// lock names no patch and the ledger is gone: nothing attests, online or -/// `--no-verify`, and the patch API is never asked. +/// a checkout of the committed state (package.json, the redirected lock; +/// nothing installable — the host is fictional — so the lock pin is the +/// basis; no ledger exists) attests `(redirected)` from lockfile discovery + +/// the patch API. After the bare rollback the restored lock names no patch: +/// nothing attests, online or `--no-verify`, and the patch API is never +/// asked. #[tokio::test] #[serial] async fn npm_hosted_round_trip_manifest_less_vex() { use vex_e2e_common::{ - assert_absent, assert_attested, patch_view, run_vex, strip_ledgers, Marker, PatchApi, - VexRun, + assert_absent, assert_attested, patch_view, run_vex, Marker, PatchApi, VexRun, }; let server = MockServer::start().await; mock_discovery(&server).await; @@ -1203,8 +1270,13 @@ async fn npm_hosted_round_trip_manifest_less_vex() { dir }; let wired = checkout("wired"); + assert!( + !wired.join(".socket").exists(), + "hosted mode commits no .socket/ state" + ); - let code = rollback_in_process(tmp.path(), Vec::new(), false).await; + mock_npm_registry(&server).await; + let code = rollback_online(tmp.path(), &server).await; assert_eq!(code, 0, "bare rollback"); let reverted = checkout("reverted"); @@ -1225,17 +1297,7 @@ async fn npm_hosted_round_trip_manifest_less_vex() { ..VexRun::online(&api) }; let out = run_vex(&vex_e2e_common::binary(), &wired, &online()); - assert_eq!(out.code, Some(0), "wired, ledger:\n{out}"); - assert_attested( - out.doc(), - PURL, - UUID, - Marker::Redirected, - &[(GHSA, &["CVE-2024-9"])], - ); - strip_ledgers(&wired); - let out = run_vex(&vex_e2e_common::binary(), &wired, &online()); - assert_eq!(out.code, Some(0), "wired, no ledger:\n{out}"); + assert_eq!(out.code, Some(0), "wired:\n{out}"); assert_attested( out.doc(), PURL, diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs index 7075ba262..0695f6fdf 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs @@ -2,20 +2,51 @@ //! then `rollback` / `remove` restoring the registry pins slot by slot //! (after vlt re-laid the line) and invalidating the patched installed //! copies so the next install extracts the registry bytes. +//! +//! v5 hosted mode keeps no ledger: the pins are read from `vlt-lock.json` +//! (on the mock server's origin, recognized through `--patch-server-url`) +//! and each is restored to the upstream registry entry re-resolved from the +//! mock npm registry (`SOCKET_NPM_REGISTRY`). use std::path::Path; use serde_json::Value; -use wiremock::MockServer; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::vlt_hosted_common::*; const RESTORED: &str = "restored registry pins for 1 packages; removed the patched installed \ copies, so node_modules is incomplete until you run `vlt install` (or `vlt ci`)"; +/// Serve the npm registry's version document for left-pad@1.3.0 (the +/// registry entry `registry_node` pins) under `/npm-registry`. +async fn mock_registry(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!("/npm-registry/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": NAME, + "version": VERSION, + "dist": { "tarball": REGISTRY_URL, "integrity": UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +/// The node line the upstream restore writes back for `id`: slot [2] is the +/// registry's `dist.integrity`, and — this lock recording no +/// `options.registries` and no other default-registry node to read the +/// convention from — a tilde-era node gets no resolved URL (slot [3]), per +/// the restore's documented convention. (The URL `registry_node` carries is +/// what the rewrite discarded; it is not derivable from the lock.) +fn restored_node(id: &str) -> String { + format!("\"{id}\": [0,\"{NAME}\",\"{UPSTREAM_SHA512}\"]") +} + async fn hosted_vlt_project(root: &Path) -> MockServer { let server = MockServer::start().await; mock_all(&server).await; + mock_registry(&server).await; write_vlt_project(root, Era::V1); let (_, doc) = scan_hosted(root, &server, &[], &[]); assert_eq!(redirected(&doc), 1, "{doc:#}"); @@ -23,6 +54,7 @@ async fn hosted_vlt_project(root: &Path) -> MockServer { read(root, "vlt-lock.json"), vlt_lock(Era::V1, &[pinned_node(TILDE_ID, &server)]) ); + assert!(!ledger_path(root).exists(), "hosted mode keeps no ledger"); server } @@ -33,12 +65,25 @@ fn vlt_install_patched(root: &Path, server: &MockServer) { write_hidden_lock(root, &[pinned_node(TILDE_ID, server)]); } -fn run_verb(root: &Path, verb: &str, extra: &[&str]) -> (i32, Value) { +/// ` [extra] --yes --json` online against `server` (registry + +/// patch host); `(exit code, envelope, stderr)`. +fn run_verb_raw( + root: &Path, + server: &MockServer, + verb: &str, + extra: &[&str], +) -> (i32, Value, String) { let cwd = root.to_str().unwrap().to_string(); + let uri = server.uri(); + let registry = format!("{uri}/npm-registry"); let mut args = vec![verb]; args.extend_from_slice(extra); - args.extend_from_slice(&["--yes", "--offline", "--cwd", &cwd]); - let (code, doc, stderr) = run_json(root, &args, &[]); + args.extend_from_slice(&["--yes", "--patch-server-url", &uri, "--cwd", &cwd]); + run_json(root, &args, &[("SOCKET_NPM_REGISTRY", registry.as_str())]) +} + +fn run_verb(root: &Path, server: &MockServer, verb: &str, extra: &[&str]) -> (i32, Value) { + let (code, doc, stderr) = run_verb_raw(root, server, verb, extra); assert_eq!(code, 0, "{verb} must succeed: {doc:#}\n{stderr}"); (code, doc) } @@ -58,22 +103,23 @@ async fn vlt_hosted_round_trip() { for scoped in [true, false] { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let pristine = vlt_lock(Era::V1, &[registry_node(TILDE_ID)]); + let pristine = vlt_lock(Era::V1, &[restored_node(TILDE_ID)]); let server = hosted_vlt_project(root).await; vlt_install_patched(root, &server); let targets: &[&str] = if scoped { &[PURL] } else { &[] }; - let (_, doc) = run_verb(root, "rollback", targets); + let (_, doc) = run_verb(root, &server, "rollback", targets); assert_eq!(read(root, "vlt-lock.json"), pristine, "scoped={scoped}"); + assert_eq!( + doc["hosted"]["reverted"], + serde_json::json!([PURL]), + "{doc:#}" + ); assert_eq!(advisory_details(&doc), [RESTORED], "{doc:#}"); assert!(!store_dir(root, TILDE_ID).exists()); assert!(!root.join("node_modules/.vlt-lock.json").exists()); - assert!( - !ledger_path(root).exists() - || !read(root, ".socket/vendor/redirect-state.json") - .contains("redirect_vlt_lock_node") - ); + assert!(!ledger_path(root).exists()); } } @@ -84,31 +130,49 @@ async fn vlt_hosted_remove_restores_and_emits_the_advisory() { let server = hosted_vlt_project(root).await; vlt_install_patched(root, &server); - let (_, doc) = run_verb(root, "remove", &[PURL]); + let (_, doc) = run_verb(root, &server, "remove", &[PURL]); assert_eq!( read(root, "vlt-lock.json"), - vlt_lock(Era::V1, &[registry_node(TILDE_ID)]) + vlt_lock(Era::V1, &[restored_node(TILDE_ID)]) ); assert!(doc.to_string().contains(ADVISORY), "{doc:#}"); assert!(!store_dir(root, TILDE_ID).exists()); } +/// Once vlt itself re-locked the package back onto the registry (a `vlt +/// update`, or a relock to another version, optional or not), no lockfile +/// pins a hosted patch any more — and v5 hosted mode keeps no ledger that +/// could remember the patched store copy. Rollback therefore has no hosted +/// state to act on: the plain "Manifest not found" exit 1, the lock and the +/// installed copies untouched (`vlt install` / `vlt ci` owns the store). #[tokio::test] -async fn vlt_rollback_after_vlt_update_invalidates_patched_store() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let _server = hosted_vlt_project(root).await; - let registry = vlt_lock(Era::V1, &[registry_node(TILDE_ID)]); - std::fs::write(root.join("vlt-lock.json"), ®istry).unwrap(); - install_store(root, TILDE_ID, PATCHED); - write_hidden_lock(root, &[registry_node(TILDE_ID)]); +async fn vlt_rollback_after_a_relock_has_no_hosted_state_left() { + for (flags, version) in [(0, "1.3.0"), (0, "1.3.1"), (1, "1.3.1")] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let server = hosted_vlt_project(root).await; + let relocked = vlt_lock( + Era::V1, + &[with_flags(®istry_node(TILDE_ID), flags).replace("1.3.0", version)], + ); + std::fs::write(root.join("vlt-lock.json"), &relocked).unwrap(); + install_store(root, TILDE_ID, PATCHED); + write_hidden_lock(root, &[registry_node(TILDE_ID)]); - let (_, doc) = run_verb(root, "rollback", &[]); + let (code, doc, stderr) = run_verb_raw(root, &server, "rollback", &[]); - assert_eq!(read(root, "vlt-lock.json"), registry); - assert_eq!(advisory_details(&doc), [RESTORED], "{doc:#}"); - assert!(!store_dir(root, TILDE_ID).exists()); + let what = format!("flags={flags} version={version}"); + assert_eq!(code, 1, "{what}: {doc:#}\n{stderr}"); + assert_eq!(doc["error"], "Manifest not found", "{what}: {doc:#}"); + assert_eq!(read(root, "vlt-lock.json"), relocked, "{what}"); + assert!( + store_dir(root, TILDE_ID).join("index.js").exists(), + "{what}" + ); + assert!(root.join("node_modules/.vlt-lock.json").exists(), "{what}"); + assert!(!root.join(".socket").exists(), "{what}"); + } } #[tokio::test] @@ -123,11 +187,11 @@ async fn vlt_rollback_after_comma_move() { ) .unwrap(); - let (_, doc) = run_verb(root, "rollback", &[PURL]); + let (_, doc) = run_verb(root, &server, "rollback", &[PURL]); assert_eq!( read(root, "vlt-lock.json"), - vlt_lock(Era::V1, &[registry_node(TILDE_ID), sibling]) + vlt_lock(Era::V1, &[restored_node(TILDE_ID), sibling]) ); assert!( advisory_details(&doc).is_empty(), @@ -143,13 +207,13 @@ async fn vlt_rollback_after_e0_flag_change() { let relaid = pinned_node(TILDE_ID, &server).replacen("[0,", "[1,", 1); std::fs::write(root.join("vlt-lock.json"), vlt_lock(Era::V1, &[relaid])).unwrap(); - run_verb(root, "rollback", &[]); + run_verb(root, &server, "rollback", &[]); assert_eq!( read(root, "vlt-lock.json"), vlt_lock( Era::V1, - &[registry_node(TILDE_ID).replacen("[0,", "[1,", 1)] + &[restored_node(TILDE_ID).replacen("[0,", "[1,", 1)] ) ); } @@ -161,7 +225,7 @@ async fn vlt_rollback_honors_no_vlt_install_cleanup() { let server = hosted_vlt_project(root).await; vlt_install_patched(root, &server); - let (_, doc) = run_verb(root, "rollback", &["--no-vlt-install-cleanup"]); + let (_, doc) = run_verb(root, &server, "rollback", &["--no-vlt-install-cleanup"]); assert_eq!( advisory_details(&doc), @@ -179,11 +243,11 @@ async fn vlt_rollback_honors_no_vlt_install_cleanup() { async fn vlt_rollback_of_a_pristine_tree_keeps_it() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let _server = hosted_vlt_project(root).await; + let server = hosted_vlt_project(root).await; install_store(root, TILDE_ID, PRISTINE); write_hidden_lock(root, &[registry_node(TILDE_ID)]); - let (_, doc) = run_verb(root, "rollback", &[]); + let (_, doc) = run_verb(root, &server, "rollback", &[]); assert!(advisory_details(&doc).is_empty(), "{doc:#}"); assert!(store_dir(root, TILDE_ID).join("index.js").exists()); @@ -201,6 +265,7 @@ async fn hosted_optional_project(root: &Path, flags: u8) -> MockServer { async fn hosted_flagged_project(root: &Path, nodes: &[(&str, u8)]) -> MockServer { let server = MockServer::start().await; mock_all(&server).await; + mock_registry(&server).await; write_vlt_project(root, Era::V1); let registry: Vec = nodes .iter() @@ -233,14 +298,14 @@ async fn vlt_rollback_keeps_a_patched_optional_copy() { for (verb, flags) in [("rollback", 1), ("rollback", 3), ("remove", 1)] { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let _server = hosted_optional_project(root, flags).await; + let server = hosted_optional_project(root, flags).await; let targets: &[&str] = if verb == "remove" { &[PURL] } else { &[] }; - let (_, doc) = run_verb(root, verb, targets); + let (_, doc) = run_verb(root, &server, verb, targets); assert_eq!( read(root, "vlt-lock.json"), - vlt_lock(Era::V1, &[with_flags(®istry_node(TILDE_ID), flags)]), + vlt_lock(Era::V1, &[with_flags(&restored_node(TILDE_ID), flags)]), "{verb} flags={flags}" ); assert_eq!( @@ -279,18 +344,18 @@ async fn vlt_rollback_removes_the_prod_copy_keeps_the_optional_one() { ] { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let _server = hosted_flagged_project(root, &nodes).await; + let server = hosted_flagged_project(root, &nodes).await; let expected = if cleaned { &removed } else { &skipped }; - let (_, doc) = run_verb(root, verb, extra); + let (_, doc) = run_verb(root, &server, verb, extra); assert_eq!( read(root, "vlt-lock.json"), vlt_lock( Era::V1, &[ - registry_node(TILDE_ID), - with_flags(®istry_node(optional), 1) + restored_node(TILDE_ID), + with_flags(&restored_node(optional), 1) ] ), "{verb} {extra:?}" @@ -318,64 +383,18 @@ async fn vlt_rollback_removes_the_prod_copy_keeps_the_optional_one() { } } -/// Once vlt re-locked the package, the healed DepID is gone from the lock, -/// so the heal goes by the flags the ledger recorded for it. -#[tokio::test] -async fn vlt_rollback_after_relock_goes_by_the_recorded_flags() { - for flags in [0, 1] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let _server = hosted_optional_project(root, flags).await; - let relocked = vlt_lock( - Era::V1, - &[with_flags(®istry_node(TILDE_ID), flags).replace("1.3.0", "1.3.1")], - ); - std::fs::write(root.join("vlt-lock.json"), &relocked).unwrap(); - write_hidden_lock(root, &[]); - - let (_, doc) = run_verb(root, "rollback", &[]); - - assert_eq!(read(root, "vlt-lock.json"), relocked); - let (advisory, kept) = if flags == 0 { - (RESTORED.to_string(), false) - } else { - (optional_kept(1, 1), true) - }; - assert_eq!(advisory_details(&doc), [advisory], "flags={flags}: {doc:#}"); - assert_eq!( - store_dir(root, TILDE_ID).join("index.js").exists(), - kept, - "flags={flags}" - ); - } -} - const OTHER: &str = "right-pad"; const OTHER_UUID: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; +const OTHER_PURL: &str = "pkg:npm/right-pad@1.3.0"; fn as_other(text: &str) -> String { text.replace(NAME, OTHER).replace(UUID, OTHER_UUID) } -/// Add a second hosted vlt package to the ledger and the lock by renaming -/// left-pad's recorded edit, record and pinned node. +/// Add a second hosted vlt package (right-pad, its own patch uuid) to the +/// lock by renaming left-pad's pinned node. The lock pin is the whole +/// hosted state; the mock registry does NOT serve right-pad. fn add_second_hosted_package(root: &Path, server: &MockServer) -> String { - let path = ledger_path(root); - let mut ledger: Value = serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")) - .expect("the hosted run wrote a ledger"); - let edits: Vec = ledger["edits"] - .as_array() - .unwrap() - .iter() - .filter(|e| e["kind"] == "redirect_vlt_lock_node") - .map(|e| serde_json::from_str(&as_other(&e.to_string())).unwrap()) - .collect(); - assert_eq!(edits.len(), 1); - ledger["edits"].as_array_mut().unwrap().extend(edits); - let record: Value = - serde_json::from_str(&as_other(&ledger["records"][PURL].to_string())).unwrap(); - ledger["records"][as_other(PURL)] = record; - std::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()).unwrap(); let other_pinned = as_other(&pinned_node(TILDE_ID, server)); std::fs::write( root.join("vlt-lock.json"), @@ -395,15 +414,13 @@ fn other_store_dir(root: &Path) -> std::path::PathBuf { .join(OTHER) } -/// A scoped rollback of one of two hosted vlt packages takes the per-purl -/// path (not a whole-ledger replay): only that package's pin is restored -/// and only its patched store entry (plus the hidden lock) is removed. -#[tokio::test] -async fn vlt_scoped_rollback_of_one_of_two_heals_only_that_package() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let server = hosted_vlt_project(root).await; - let other_pinned = add_second_hosted_package(root, &server); +/// Both packages installed patched, with the hidden lock recording both +/// pins. +fn install_both_patched( + root: &Path, + server: &MockServer, + other_pinned: &str, +) -> std::path::PathBuf { install_store(root, TILDE_ID, PATCHED); let other = other_store_dir(root); std::fs::create_dir_all(&other).unwrap(); @@ -415,14 +432,32 @@ async fn vlt_scoped_rollback_of_one_of_two_heals_only_that_package() { std::fs::write(other.join("index.js"), PATCHED).unwrap(); write_hidden_lock( root, - &[pinned_node(TILDE_ID, &server), other_pinned.clone()], + &[pinned_node(TILDE_ID, server), other_pinned.to_string()], ); + other +} - let (_, doc) = run_verb(root, "rollback", &[PURL]); +/// A scoped rollback of one of two hosted vlt packages restores only that +/// package's pin and removes only its patched store entry (plus the hidden +/// lock); the other package stays pinned and installed. +#[tokio::test] +async fn vlt_scoped_rollback_of_one_of_two_heals_only_that_package() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let server = hosted_vlt_project(root).await; + let other_pinned = add_second_hosted_package(root, &server); + let other = install_both_patched(root, &server, &other_pinned); + + let (_, doc) = run_verb(root, &server, "rollback", &[PURL]); assert_eq!( read(root, "vlt-lock.json"), - vlt_lock(Era::V1, &[registry_node(TILDE_ID), other_pinned]) + vlt_lock(Era::V1, &[restored_node(TILDE_ID), other_pinned]) + ); + assert_eq!( + doc["hosted"]["reverted"], + serde_json::json!([PURL]), + "{doc:#}" ); assert_eq!(advisory_details(&doc), [RESTORED], "{doc:#}"); assert!(!store_dir(root, TILDE_ID).exists()); @@ -431,22 +466,20 @@ async fn vlt_scoped_rollback_of_one_of_two_heals_only_that_package() { other.join("index.js").exists(), "the other package's copy stays" ); - let ledger = read(root, ".socket/vendor/redirect-state.json"); - assert!(ledger.contains(OTHER_UUID), "{ledger}"); } -/// Hosted rollback reads `vlt-lock.json` before any revert (to find the -/// store copies to heal). A FIFO planted at that path must fail the read at -/// once through the FIFO-safe opener, never block the process in open(2) -/// waiting for a writer, and the rollback then fails closed: the ledger -/// keeps the record and the FIFO is left as it was. +/// Hosted rollback reads `vlt-lock.json` (to find the pins and the store +/// copies to heal). A FIFO planted at that path must fail the read at once +/// through the FIFO-safe opener, never block the process in open(2) +/// waiting for a writer; the run then fails closed and the FIFO is left as +/// it was. #[cfg(unix)] #[tokio::test] async fn vlt_hosted_rollback_fails_fast_on_a_fifo_lock() { use std::os::unix::fs::FileTypeExt as _; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let _server = hosted_vlt_project(root).await; + let server = hosted_vlt_project(root).await; let lock = root.join("vlt-lock.json"); std::fs::remove_file(&lock).unwrap(); let status = std::process::Command::new("mkfifo") @@ -454,11 +487,20 @@ async fn vlt_hosted_rollback_fails_fast_on_a_fifo_lock() { .status() .unwrap(); assert!(status.success()); - let ledger_before = read(root, ".socket/vendor/redirect-state.json"); let cwd = root.to_str().unwrap().to_string(); + let uri = server.uri(); let mut child = scrubbed_cli() - .args(["rollback", "--json", "--yes", "--offline", "--cwd", &cwd]) + .env("SOCKET_NPM_REGISTRY", format!("{uri}/npm-registry")) + .args([ + "rollback", + "--json", + "--yes", + "--patch-server-url", + &uri, + "--cwd", + &cwd, + ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() @@ -486,50 +528,48 @@ async fn vlt_hosted_rollback_fails_fast_on_a_fifo_lock() { .unwrap() .file_type() .is_fifo()); - assert_eq!( - read(root, ".socket/vendor/redirect-state.json"), - ledger_before, - "nothing is half-reverted" - ); + assert!(!ledger_path(root).exists(), "nothing is written"); } -/// Replay groups commit on their own: when an unscoped rollback's -/// package-lock.json group refuses (drifted since the redirect) while the -/// vlt group restores the registry pins, the patched store copy the -/// restored vlt-lock.json no longer names is still removed. The heal keys -/// off the vlt group's own outcome, not off any group's refusal. +/// Each pin restores or refuses on its own: when an unscoped rollback's +/// right-pad pin is refused (the registry does not answer for it) while +/// the left-pad pin is restored, left-pad's patched store copy — which the +/// restored vlt-lock.json no longer names — is still removed. The heal +/// follows the restored pins, not the run's overall outcome. #[tokio::test] -async fn vlt_heal_follows_the_vlt_group_when_another_group_refuses() { +async fn vlt_heal_follows_the_restored_pin_when_another_pin_refuses() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - let server = MockServer::start().await; - mock_all(&server).await; - write_vlt_project(root, Era::V1); - std::fs::write(root.join("package-lock.json"), package_lock()).unwrap(); - let (_, doc) = scan_hosted(root, &server, &["--no-npm-allow-remote-config"], &[]); - assert_eq!(redirected(&doc), 1, "the scan redirects both locks"); - vlt_install_patched(root, &server); - let drifted = read(root, "package-lock.json") - .replace(&artifact_url(&server), "https://example.invalid/left-pad-1.3.0.tgz"); - std::fs::write(root.join("package-lock.json"), &drifted).unwrap(); + let server = hosted_vlt_project(root).await; + let other_pinned = add_second_hosted_package(root, &server); + let other = install_both_patched(root, &server, &other_pinned); - let cwd = root.to_str().unwrap().to_string(); - let (code, doc, _) = run_json( - root, - &["rollback", "--yes", "--offline", "--cwd", &cwd], - &[], - ); + let (code, doc, _) = run_verb_raw(root, &server, "rollback", &[]); - assert_ne!(code, 0, "the refused package-lock.json group fails the run"); + assert_ne!(code, 0, "the refused right-pad pin fails the run"); + assert_eq!(doc["status"], "partial_failure", "{doc:#}"); + assert_eq!( + doc["hosted"]["reverted"], + serde_json::json!([PURL]), + "{doc:#}" + ); + assert_eq!(doc["hosted"]["failed"][0]["purl"], OTHER_PURL, "{doc:#}"); assert_eq!( read(root, "vlt-lock.json"), - vlt_lock(Era::V1, &[registry_node(TILDE_ID)]), - "the vlt group restored the registry pins" + vlt_lock(Era::V1, &[restored_node(TILDE_ID), other_pinned]), + "left-pad restored, the refused right-pad pin untouched" ); - assert_eq!(read(root, "package-lock.json"), drifted, "the refused group wrote nothing"); assert!( !store_dir(root, TILDE_ID).exists(), - "the patched store copy is removed for the restored pins" + "the patched store copy is removed for the restored pin" + ); + assert!( + other.join("index.js").exists(), + "the refused package's copy stays" + ); + assert_eq!( + advisory_details(&doc), + [RESTORED], + "the heal advisory is reported" ); - assert_eq!(advisory_details(&doc), [RESTORED], "the heal advisory is reported"); } diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index e07677186..15c8aaf88 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1111,17 +1111,130 @@ fn hosted_scan_cli(root: &Path, api_url: &str) -> (i32, Value) { (code, env) } +/// A single-package npm tarball (`package/package.json` + `package/index.js`) +/// — the pristine bytes a mock registry serves for the upstream restore. +fn npm_tgz(name: &str, version: &str, index: &[u8]) -> Vec { + let enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(enc); + let manifest = format!(r#"{{"name":"{name}","version":"{version}"}}"#); + for (path, bytes) in [ + ("package/package.json", manifest.as_bytes()), + ("package/index.js", index), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_cksum(); + builder.append_data(&mut header, path, bytes).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +/// `sha512-` SRI of `bytes`. +fn sri_sha512(bytes: &[u8]) -> String { + use base64::Engine as _; + use sha2::Sha512; + format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)) + ) +} + +/// Mount the npm registry surface the v5 upstream restore reads +/// (`SOCKET_NPM_REGISTRY`): `GET //` (the version document) +/// and the tarball it names. Returns the dist `(tarball, integrity)`. +async fn mount_npm_registry( + server: &wiremock::MockServer, + name: &str, + version: &str, + tgz: Vec, +) -> (String, String) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, ResponseTemplate}; + let tarball_path = format!("/{name}/-/{name}-{version}.tgz"); + let tarball = format!("{}{tarball_path}", server.uri()); + let integrity = sri_sha512(&tgz); + Mock::given(method("GET")) + .and(path(format!("/{name}/{version}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": name, + "version": version, + "dist": { "tarball": tarball, "integrity": integrity } + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(tarball_path)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(tgz)) + .mount(server) + .await; + (tarball, integrity) +} + +/// The env that points a run's hosted recognition (`SOCKET_PATCH_SERVER_URL`) +/// and upstream restore (`SOCKET_NPM_REGISTRY`) at the mocks, anonymous. +fn online_env(registry: &str, patch_server: &str) -> [(&'static str, String); 3] { + [ + ("SOCKET_NO_API_TOKEN", "1".to_string()), + ("SOCKET_NPM_REGISTRY", registry.to_string()), + ("SOCKET_PATCH_SERVER_URL", patch_server.to_string()), + ] +} + +/// `vendor --json --cwd ` ONLINE against the mock registry +/// / patch server (the takeover's upstream restore needs the registry). +fn vendor_online_cli( + cwd: &Path, + registry: &str, + patch_server: &str, + extra: &[&str], +) -> (i32, Value) { + let mut args = vec!["vendor", "--json", "--cwd", cwd.to_str().unwrap()]; + args.extend_from_slice(extra); + let env = online_env(registry, patch_server); + let env: Vec<(&str, &str)> = env.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let (code, stdout, stderr) = run_cli(cwd, &args, &env); + let envelope: Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("vendor --json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, envelope) +} + +/// `lock` with its (single) berry `checksum: 10c0/` value swapped for +/// the fixture's placeholder, plus the value found — the upstream restore +/// re-derives the checksum from the registry tarball, so a byte comparison +/// against the pristine fixture compares everything else. +fn split_berry_checksum(lock: &str) -> (String, String) { + let re = regex::Regex::new(r"checksum: (10c0/[0-9a-f]{128})").unwrap(); + let found = re + .captures(lock) + .map(|c| c[1].to_string()) + .unwrap_or_else(|| panic!("no 10c0 checksum in {lock:?}")); + let normalized = re + .replace(lock, format!("checksum: 10c0/{}", "3".repeat(128)).as_str()) + .into_owned(); + (normalized, found) +} + /// Mode takeovers on the Windows shapes, both directions, hermetic. Hosted → -/// vendored: `vendor` reverts the CRLF hosted redirect (the ledger's CRLF -/// fragments) before wiring, and `vendor --revert` then lands on the -/// pristine CRLF + BOM pair. Vendored → hosted: `scan --mode hosted` -/// reverts the vendored pair first (package.json back byte-exact, BOM and -/// CRLF included), redirects the CRLF lock, and `rollback` restores the -/// pristine lock. +/// vendored: `vendor` restores the CRLF hosted pin's upstream registry +/// entry (v5: re-resolved from the registry, no ledger) before wiring, and +/// `vendor --revert` then lands on the upstream CRLF + BOM pair — not the +/// hosted URL. Vendored → hosted: `scan --mode hosted` reverts the vendored +/// pair first (package.json back byte-exact, BOM and CRLF included), +/// redirects the CRLF lock, and `rollback` restores the upstream lock. #[tokio::test] async fn berry_crlf_takeovers_round_trip_both_directions() { let server = wiremock::MockServer::start().await; let hosted_url = mount_berry_hosted_api(&server).await; + mount_npm_registry( + &server, + "left-pad", + "1.3.0", + npm_tgz("left-pad", "1.3.0", ORIG_INDEX), + ) + .await; let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url); let (pkg, lock) = ( windows_shape(BERRY_WIN_PKG, true), @@ -1148,19 +1261,19 @@ async fn berry_crlf_takeovers_round_trip_both_directions() { let hosted_lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); assert!(hosted_lock.contains(&encoded), "{hosted_lock:?}"); assert_crlf(root, "hosted"); + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" + ); - let (code, env) = vendor_cli(root, &[]); - assert_eq!(code, 0, "vendor over the hosted redirect: {env:#}"); + let (code, env) = vendor_online_cli(root, &server.uri(), &server.uri(), &[]); + assert_eq!(code, 0, "vendor over the hosted pin: {env:#}"); assert_eq!(env["summary"]["applied"], 1, "{env:#}"); assert!( env.to_string() .contains("vendor_takeover_reverted_redirect"), "the takeover is surfaced: {env:#}" ); - assert!( - !root.join(".socket/vendor/redirect-state.json").exists(), - "the superseded redirect ledger is dropped" - ); let vendored_lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); assert!( !vendored_lock.contains("__archiveUrl") && vendored_lock.contains(".socket/vendor/npm/"), @@ -1169,9 +1282,16 @@ async fn berry_crlf_takeovers_round_trip_both_directions() { assert_crlf(root, "hosted→vendored"); let (code, env) = vendor_cli(root, &["--revert"]); assert_eq!(code, 0, "revert: {env:#}"); + let reverted = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); + let (normalized, upstream_checksum) = split_berry_checksum(&reverted); assert_eq!( - std::fs::read_to_string(root.join("yarn.lock")).unwrap(), - lock + normalized, lock, + "revert lands on the upstream registry entry (CRLF kept), not the hosted URL" + ); + assert_ne!( + upstream_checksum, + format!("10c0/{}", "7".repeat(128)), + "the hosted grant's checksum is gone" ); assert_eq!( std::fs::read_to_string(root.join("package.json")).unwrap(), @@ -1203,23 +1323,26 @@ async fn berry_crlf_takeovers_round_trip_both_directions() { "fully hosted: {hosted_lock:?}" ); assert_crlf(root, "vendored→hosted"); + let env_pairs = online_env(&server.uri(), &server.uri()); + let env_pairs: Vec<(&str, &str)> = env_pairs.iter().map(|(k, v)| (*k, v.as_str())).collect(); let (code, stdout, stderr) = run_cli( root, &[ "rollback", "--json", "--yes", - "--offline", "--cwd", root.to_str().unwrap(), ], - &[], + &env_pairs, ); assert_eq!(code, 0, "rollback: {stdout}\n{stderr}"); + let rolled_back = std::fs::read_to_string(root.join("yarn.lock")).unwrap(); + let (normalized, checksum) = split_berry_checksum(&rolled_back); + assert_eq!(normalized, lock, "rollback restores the upstream CRLF lock"); assert_eq!( - std::fs::read_to_string(root.join("yarn.lock")).unwrap(), - lock, - "rollback restores the pristine CRLF lock" + checksum, upstream_checksum, + "both unwinds re-derive the same upstream checksum" ); assert_eq!( std::fs::read_to_string(root.join("package.json")).unwrap(), @@ -1424,7 +1547,7 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { assert_eq!( berry_wiring_snapshot(root), before, - "{ctx}: the hosted lock edits and redirect ledger stay byte-identical" + "{ctx}: the hosted lock edits stay byte-identical" ); } } @@ -3178,23 +3301,45 @@ snapshots: std::fs::write(socket.join("blobs").join(after_hash), PATCHED_INDEX).unwrap(); } - fn takeover_warnings(envelope: &Value) -> Vec<&str> { - envelope["warnings"] - .as_array() - .map(|w| { - w.iter() - .filter(|e| e["code"] == "vendor_supersedes_redirect") - .map(|e| e["detail"].as_str().unwrap_or("")) - .collect() - }) - .unwrap_or_default() + /// The hosted URLs above live on this origin: only + /// `https://patch.socket.dev` and the configured patch-server origin + /// count as hosted, so every post-scan run passes it. + const PATCH_ORIGIN: &str = "http://patch.test"; + + /// The npm registry's version document for the fixture package, as the + /// v5 upstream restore reads it (`SOCKET_NPM_REGISTRY`): it hands back + /// the pristine integrity the fixture lock started with. + async fn mock_registry(server: &MockServer) -> String { + let registry = format!("{}/registry", server.uri()); + Mock::given(method("GET")) + .and(path(format!("/registry/{CONV_NAME}/{CONV_VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": CONV_NAME, + "version": CONV_VERSION, + "dist": { + "tarball": format!( + "https://registry.npmjs.org/{CONV_NAME}/-/{CONV_NAME}-{CONV_VERSION}.tgz" + ), + "integrity": UPSTREAM_SHA512 + } + }))) + .mount(server) + .await; + registry } + /// Hosted → vendored on a pnpm project, v5: the hosted run writes ONLY + /// the lock (no ledger); `vendor` over the hosted pin restores the + /// upstream registry entry first (`vendor_takeover_reverted_redirect`), + /// so the vendor ledger records the PRISTINE registry fragment as the + /// original; a re-vendor is a quiet no-op; `vendor --revert` + /// byte-restores the registry lock — never the hosted splice. #[tokio::test] #[serial] - async fn hosted_then_vendor_takeover_pre_reverts_redirect_and_round_trips() { + async fn hosted_then_vendor_takeover_restores_upstream_and_round_trips() { let server = MockServer::start().await; mock_hosted_api(&server).await; + let registry = mock_registry(&server).await; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); @@ -3202,7 +3347,7 @@ snapshots: let pristine_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); // 1. Hosted redirect: the lock's resolution is spliced to the hosted - // tarball and the redirect ledger claims the purl. + // tarball; no ledger is written. let code = scan_run(hosted_args(root, server.uri())).await; assert_eq!(code, 0, "scan --mode hosted must succeed"); let hosted_lock_text = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); @@ -3211,19 +3356,12 @@ snapshots: "hosted splice missing:\n{hosted_lock_text}" ); let ledger_path = root.join(".socket/vendor/redirect-state.json"); - let ledger: Value = - serde_json::from_str(&std::fs::read_to_string(&ledger_path).unwrap()).unwrap(); - assert!( - ledger["records"].get(CONV_PURL).is_some(), - "hosted run must record the purl: {ledger}" - ); + assert!(!ledger_path.exists(), "hosted mode writes no ledger"); - // 2. Vendor over the hosted-redirected lock (offline, staged blob). - // The takeover PRE-REVERTS the hosted edits first — surfaced as - // the `vendor_takeover_reverted_redirect` advisory — then vendors - // from the clean registry baseline. + // 2. Vendor over the hosted lock (staged blob, online only for the + // registry lookup of the restore). seed_manifest_and_blob(root); - let (code, env1) = vendor_cli(root, &[]); + let (code, env1) = vendor_online_cli(root, ®istry, PATCH_ORIGIN, &[]); assert_eq!( code, 0, "vendor over the hosted lock must succeed: {env1:#}" @@ -3231,16 +3369,6 @@ snapshots: find_event(&env1, "applied", None); find_event(&env1, "skipped", Some("vendor_takeover_reverted_redirect")); - // The pre-revert leaves nothing to supersede, so the - // vendor_supersedes_redirect warning must not fire — not on this run - // and not on any later one. - assert!( - takeover_warnings(&env1).is_empty(), - "the pre-revert must preempt vendor_supersedes_redirect: {env1:#}" - ); - - // The lock is FULLY vendored: local wiring present, no hosted - // residue. let vendored_lock_text = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); assert!( !vendored_lock_text.contains(HOSTED_URL), @@ -3250,43 +3378,11 @@ snapshots: vendored_lock_text.contains(".socket/vendor/"), "the vendored wiring must be present:\n{vendored_lock_text}" ); - - // The redirect ledger no longer carries the purl's halves: the - // takeover dropped its `records` entry and its - // `redirect_pnpm_resolution` edits (a residual non-package edit like - // the workspace-trust one may remain — it is the hosted flow's own - // config surface). - match std::fs::read_to_string(&ledger_path) { - Ok(text) => { - let after: Value = serde_json::from_str(&text).unwrap(); - assert!( - after["records"].get(CONV_PURL).is_none(), - "the superseded record must be dropped: {after}" - ); - let leftover: Vec<&Value> = after["edits"] - .as_array() - .map(|edits| { - edits - .iter() - .filter(|e| e["key"].as_str().is_some_and(|k| k.contains(CONV_NAME))) - .collect() - }) - .unwrap_or_default(); - assert!( - leftover.is_empty(), - "the superseded package edits must be dropped: {after}" - ); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // Fully emptied ledgers are deleted — also a valid outcome. - } - Err(e) => panic!("unreadable redirect ledger: {e}"), - } + assert!(!ledger_path.exists(), "still no hosted ledger"); // The vendor ledger's wiring `original` embeds the PRISTINE registry - // fragment the pre-revert restored — never the grant-tokenized - // hosted splice (which would make `--revert` restore an expiring - // hosted URL with no CLI path back to registry state). + // fragment the restore produced — never the grant-tokenized hosted + // splice. let state: Value = serde_json::from_str( &std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(), ) @@ -3301,25 +3397,19 @@ snapshots: "vendor wiring must NOT record the hosted fragment: {state:#}" ); - // 3. Re-vendor: an `already_vendored` no-op with NO takeover event - // and NO supersede warning (pre-fix the stale ledger re-fired the - // warning on every run). - let (code, env2) = vendor_cli(root, &[]); + // 3. Re-vendor: an `already_vendored` no-op with NO takeover event — + // the lock no longer pins anything hosted. + let (code, env2) = vendor_online_cli(root, ®istry, PATCH_ORIGIN, &[]); assert_eq!(code, 0, "re-vendor must succeed: {env2:#}"); find_event(&env2, "skipped", Some("already_vendored")); - assert!( - takeover_warnings(&env2).is_empty(), - "a reconciled ledger must not re-fire the warning: {env2:#}" - ); assert!( events(&env2) .iter() .all(|e| e["errorCode"] != "vendor_takeover_reverted_redirect"), - "a reconciled ledger must not re-fire the takeover: {env2:#}" + "nothing hosted is left to take over: {env2:#}" ); - // 4. `vendor --revert` restores the REGISTRY lock byte-exactly — the - // pre-redirect resolution, not the hosted splice. + // 4. `vendor --revert` restores the REGISTRY lock byte-exactly. let (code, renv) = vendor_cli(root, &["--revert"]); assert_eq!(code, 0, "revert must succeed: {renv:#}"); assert_eq!( @@ -3329,6 +3419,37 @@ snapshots: ); } + /// Offline, the hosted pin's upstream entry cannot be re-resolved: the + /// takeover REFUSES the purl (`redirect_revert_failed`, checkout remedy) + /// and leaves the hosted lock exactly as found. + #[tokio::test] + #[serial] + async fn offline_vendor_over_hosted_pin_is_refused() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root); + assert_eq!(scan_run(hosted_args(root, server.uri())).await, 0); + let hosted_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap(); + + seed_manifest_and_blob(root); + let (code, env) = vendor_cli(root, &["--patch-server-url", PATCH_ORIGIN]); + assert_eq!(code, 1, "{env:#}"); + let failed = find_event(&env, "failed", Some("redirect_revert_failed")); + assert!( + failed + .to_string() + .contains("git checkout -- pnpm-lock.yaml"), + "the refusal names the checkout remedy: {failed:#}" + ); + assert_eq!( + std::fs::read(root.join("pnpm-lock.yaml")).unwrap(), + hosted_lock, + "a refused takeover writes nothing" + ); + assert!(!root.join(".socket/vendor/state.json").exists()); + } /// package-lock.json twin of [`write_pnpm_project`]: a lockfileVersion 3 /// lock resolving the package from the registry. fn write_package_lock_project(root: &Path) { @@ -3365,8 +3486,8 @@ snapshots: /// Hosted → vendored takeover on a package-lock project: the hosted run /// auto-configures `allow-remote=all` in a NEW `.npmrc` (npm >= 12 - /// refuses the hosted tarball otherwise); the vendor takeover reverts - /// the last package-lock redirect and, in the same transaction, deletes + /// refuses the hosted tarball otherwise); the vendor takeover restores + /// the last hosted pin to its upstream registry entry and, in the same transaction, deletes /// the `.npmrc` it created — vendored `file:` specs never need it (npm /// gates them by `allow-file`, default `all`). #[tokio::test] @@ -3374,6 +3495,7 @@ snapshots: async fn hosted_then_vendor_takeover_removes_the_npmrc_allow_remote_config() { let server = MockServer::start().await; mock_hosted_api(&server).await; + let registry = mock_registry(&server).await; let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_package_lock_project(root); @@ -3390,7 +3512,7 @@ snapshots: ); seed_manifest_and_blob(root); - let (code, env) = vendor_cli(root, &[]); + let (code, env) = vendor_online_cli(root, ®istry, PATCH_ORIGIN, &[]); assert_eq!(code, 0, "vendor over the hosted lock must succeed: {env:#}"); find_event(&env, "applied", None); find_event(&env, "skipped", Some("vendor_takeover_reverted_redirect")); @@ -3405,7 +3527,7 @@ snapshots: ); assert!( !root.join(".socket/vendor/redirect-state.json").exists(), - "the emptied redirect ledger is deleted" + "no hosted ledger exists at any point" ); } } diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 4662a33a1..9ff7df128 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -10,23 +10,29 @@ //! from the compatibility matrix captures, and the packages tuple grammar //! is identical on versions 0/1/2). //! +//! v5: hosted mode keeps no ledger — the hosted state is the lock's URL +//! 3-tuple alone. Every unwind of a hosted pin (the vendor takeover, +//! `rollback`, `remove`) restores the registry 4-tuple, re-resolving the +//! integrity from the npm registry: here one shared wiremock mirror +//! ([`registry_uri`], `SOCKET_NPM_REGISTRY`) serving the pristine +//! integrities, with the `http://patch.test` origin named hosted via +//! `SOCKET_PATCH_SERVER_URL`. +//! //! Scenarios: -//! 1. `scan --mode hosted` → `scan --mode vendored`: the takeover reverts -//! the hosted line, drops the redirect-ledger record, vendors from the -//! pristine registry line (the vendor ledger's `original` is the -//! REGISTRY tuple, never the hosted URL), and `vendor --revert` +//! 1. `scan --mode hosted` → `scan --mode vendored`: the takeover restores +//! the registry line, vendors from it (the vendor ledger's `original` +//! is the REGISTRY tuple, never the hosted URL), and `vendor --revert` //! restores the pristine bytes. -//! 2. `vendor --dry-run` over the live hosted redirect previews the -//! takeover (`vendor_would_revert_redirect`) with no false +//! 2. `vendor --dry-run` over the live hosted pin previews the takeover +//! (`vendor_would_revert_redirect`) with no false //! `vendor_lock_entry_not_found` follow-up and no writes; the wet //! `vendor` then completes it. -//! 3. Two hosted records: a SCOPED `rollback ` (per-purl path, the -//! whole-ledger replay is not eligible) unwinds only the targeted line -//! and record; the sibling stays hosted. -//! 4. Same ledger, `remove `. +//! 3. Two hosted pins: a SCOPED `rollback ` restores only the +//! targeted line; the sibling stays hosted. +//! 4. Same project, `remove `. //! 5. A hosted-wired lockfileVersion-1 WORKSPACE lock (hosted accepts it, //! the vendored backend refuses it): `vendor` — dry and wet — refuses -//! `vendor_bun_workspace_unsupported` BEFORE the takeover reverts +//! `vendor_bun_workspace_unsupported` BEFORE the takeover restores //! anything, so the hosted wiring survives byte-for-byte; the v2 twin //! still takes over. //! @@ -68,7 +74,6 @@ const CVE: &str = "CVE-2026-5555"; /// The second hosted record of the scoped-unwind scenarios. const OTHER_NAME: &str = "other"; const OTHER_PURL: &str = "pkg:npm/other@1.0.0"; -const OTHER_UUID: &str = "0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d"; const OTHER_HOSTED_URL: &str = "http://patch.test/patch/npm/other/1.0.0/55555555-5555-4555-8555-555555555555/0a1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/other-1.0.0.tgz"; /// The registry 4-tuple lines exactly as bun 1.4.2 emits them (matrix @@ -165,9 +170,8 @@ fn patch_record(uuid: &str) -> Value { }) } -/// `.socket/manifest.json` + the after-hash blob, so `vendor --offline` -/// runs fully offline (hosted mode writes no manifest — its ledger is its -/// store). +/// `.socket/manifest.json` + the after-hash blob, so `vendor` needs no API +/// (hosted mode writes no manifest). fn seed_manifest_and_blob(root: &Path) { let socket = root.join(".socket"); std::fs::create_dir_all(socket.join("blobs")).unwrap(); @@ -282,9 +286,63 @@ fn manifestless_vex( // ───────────────────────── subprocess runner ───────────────────────── +/// The `sha512-…` integrity of a registry 4-tuple line (its last string). +fn line_integrity(line: &str) -> String { + line.rsplit('"') + .nth(1) + .filter(|s| s.starts_with("sha512-")) + .unwrap_or_else(|| panic!("no integrity in {line}")) + .to_string() +} + +/// One npm registry mirror shared by every test in this binary: the +/// version documents of `left-pad@1.3.0` and `other@1.0.0` carrying the +/// integrities of the pristine registry lines, which is all the v5 upstream +/// restore of a bun.lock entry reads. It runs on its own thread + runtime +/// for the life of the process (the per-test runtimes come and go). +fn registry_uri() -> &'static str { + static URI: std::sync::OnceLock = std::sync::OnceLock::new(); + URI.get_or_init(|| { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("registry mirror runtime"); + rt.block_on(async move { + let server = MockServer::start().await; + for (name, version, line) in [ + (NAME, VERSION, LEFT_PAD_REGISTRY_LINE), + (OTHER_NAME, "1.0.0", OTHER_REGISTRY_LINE), + ] { + Mock::given(method("GET")) + .and(path(format!("/{name}/{version}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": name, + "version": version, + "dist": { + "tarball": format!( + "https://registry.npmjs.org/{name}/-/{name}-{version}.tgz" + ), + "integrity": line_integrity(line) + } + }))) + .mount(&server) + .await; + } + tx.send(server.uri()).expect("hand back the mirror uri"); + std::future::pending::<()>().await; + }); + }); + rx.recv().expect("registry mirror started") + }) +} + /// Run the built `socket-patch` binary with every ambient `SOCKET_*` var /// scrubbed (except the hermetic `SOCKET_NO_CONFIG`) and telemetry -/// hard-disabled. Returns `(exit_code, stdout, stderr)`. +/// hard-disabled; `http://patch.test` counts as the patch server +/// (`SOCKET_PATCH_SERVER_URL`) and the registry is the shared mirror +/// (`SOCKET_NPM_REGISTRY`). Returns `(exit_code, stdout, stderr)`. 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); @@ -293,7 +351,9 @@ fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { cmd.env_remove(key); } } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_PATCH_SERVER_URL", "http://patch.test") + .env("SOCKET_NPM_REGISTRY", registry_uri()); let out = cmd.output().expect("spawn socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -336,14 +396,10 @@ fn scan_mode(cwd: &Path, api_url: &str, mode: &str, extra: &[&str]) -> (i32, Val run_json(cwd, &args) } +/// `vendor --json` — online: a takeover's upstream restore reads the +/// registry mirror. fn vendor_cli(cwd: &Path, extra: &[&str]) -> (i32, Value) { - let mut args = vec![ - "vendor", - "--json", - "--offline", - "--cwd", - cwd.to_str().unwrap(), - ]; + let mut args = vec!["vendor", "--json", "--cwd", cwd.to_str().unwrap()]; args.extend_from_slice(extra); run_json(cwd, &args) } @@ -376,29 +432,14 @@ fn vendored_rel_tgz() -> String { } /// Assertions shared by the takeover scenarios once the wet vendored run -/// has happened: the redirect ledger no longer claims the purl, bun.lock -/// carries the local tuple and no hosted residue, and the vendor ledger's -/// recorded `original` is the PRISTINE registry line. +/// has happened: no hosted ledger exists, bun.lock carries the local tuple +/// and no hosted residue, and the vendor ledger's recorded `original` is +/// the PRISTINE registry line (the takeover's upstream restore). fn assert_pure_vendored(root: &Path) { - match std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")) { - Ok(text) => { - let ledger: Value = serde_json::from_str(&text).unwrap(); - assert!( - ledger["records"].get(PURL).is_none(), - "the superseded redirect record must be dropped: {ledger:#}" - ); - assert!( - ledger["edits"] - .as_array() - .is_none_or(|edits| edits.iter().all(|e| e["key"] != NAME)), - "the superseded bun.lock edit must be dropped: {ledger:#}" - ); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // An emptied ledger is deleted — the expected outcome here. - } - Err(e) => panic!("unreadable redirect ledger: {e}"), - } + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "no hosted ledger may exist" + ); let lock = read(root, "bun.lock"); assert!( @@ -444,9 +485,7 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]); let pristine = std::fs::read(root.join("bun.lock")).unwrap(); - // A: hosted redirect — registry 4-tuple → URL 3-tuple, ledger claims - // the purl with one `redirect_bun_lock_package` edit whose original - // is the registry line. + // A: hosted redirect — registry 4-tuple → URL 3-tuple; no ledger. let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]); assert_eq!(code, 0, "scan --mode hosted must succeed: {env:#}"); assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); @@ -456,18 +495,9 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512), "hosted URL 3-tuple written:\n{hosted_lock}" ); - let ledger: Value = - serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); assert!( - ledger["records"].get(PURL).is_some(), - "hosted run must record the purl: {ledger:#}\nhosted envelope: {env:#}" - ); - let edit = &ledger["edits"][0]; - assert_eq!(edit["kind"], "redirect_bun_lock_package", "{ledger:#}"); - assert_eq!( - edit["original"], - json!(LEFT_PAD_REGISTRY_LINE), - "{ledger:#}" + !root.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger: {env:#}" ); let scratch = tempfile::tempdir().unwrap(); manifestless_vex( @@ -493,8 +523,8 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() { "a dry run must not create the vendor ledger" ); - // B: vendored scan over the LIVE hosted redirect — the takeover. Used - // to exit 1 with `redirect_revert_failed` ("cannot replay yet"). + // B: vendored scan over the LIVE hosted pin — the takeover restores the + // registry line first, then vendors. let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]); assert_eq!( code, 0, @@ -695,15 +725,13 @@ async fn bun_digestless_vendored_line_is_taken_over_by_scan_hosted_and_rolls_bac !root.join(vendored_rel_tgz()).exists(), "the vendored artifact must be removed by the takeover" ); - let ledger: Value = - serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); - assert_eq!( - ledger["edits"][0]["original"], - json!(LEFT_PAD_REGISTRY_LINE), - "the hosted ledger records the PRISTINE registry line as its original: {ledger:#}" + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" ); - // Unscoped rollback of the hosted wiring lands on the pristine lock. + // Unscoped rollback of the hosted pin restores its upstream entry: the + // pristine lock. let (code, env) = run_json( root, &[ @@ -774,14 +802,15 @@ async fn bun_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() assert_eq!(code, 0, "{env:#}"); let hosted_lock = std::fs::read(root.join("bun.lock")).unwrap(); let ledger_path = root.join(".socket/vendor/redirect-state.json"); - let hosted_ledger = std::fs::read(&ledger_path).unwrap(); + assert!(!ledger_path.exists(), "hosted mode writes no ledger"); - // The manifest record `vendor` acts on (offline: the staged blob). + // The manifest record `vendor` acts on (the staged blob). seed_manifest_and_blob(root); - // Dry run: the takeover is PROBED (write-free) and previewed; the - // backend preview does not run against the still-hosted lock, so no - // false `vendor_lock_entry_not_found` and no refusal. Nothing written. + // Dry run: the takeover's upstream restore is resolved write-free and + // previewed; the backend preview does not run against the still-hosted + // lock, so no false `vendor_lock_entry_not_found` and no refusal. + // Nothing written. let (code, env) = vendor_cli(root, &["--dry-run"]); assert_eq!(code, 0, "vendor --dry-run must succeed: {env:#}"); let advisory = find_event(&env, "skipped", Some("vendor_would_revert_redirect")); @@ -797,11 +826,7 @@ async fn bun_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() hosted_lock, "a dry run must not touch bun.lock" ); - assert_eq!( - std::fs::read(&ledger_path).unwrap(), - hosted_ledger, - "a dry run must not touch the redirect ledger" - ); + assert!(!ledger_path.exists(), "a dry run writes no hosted ledger"); assert!( !root.join(".socket/vendor/state.json").exists(), "a dry run must not create the vendor ledger" @@ -820,10 +845,9 @@ async fn bun_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() // 3./4. scoped rollback / remove of ONE of two hosted bun records // ───────────────────────────────────────────────────────────────────── -/// A hosted-live bun project with TWO redirect records, written exactly as -/// the hosted flow leaves them (ledger edits = verbatim lines, lock = the -/// URL 3-tuples). Two records make a scoped unwind of one purl ineligible -/// for the whole-ledger replay, so it takes the per-purl revert. +/// A hosted-live bun project with TWO hosted pins, written exactly as the +/// v5 hosted flow leaves it: the lock's URL 3-tuples and nothing else (no +/// ledger). fn write_two_record_hosted_project(root: &Path) -> String { let pristine = pristine_lock_two(); write_bun_project(root, &pristine, &[(NAME, VERSION), (OTHER_NAME, "1.0.0")]); @@ -839,43 +863,11 @@ fn write_two_record_hosted_project(root: &Path) -> String { .replace(OTHER_REGISTRY_LINE, &other_hosted); assert_ne!(hosted, pristine); std::fs::write(root.join("bun.lock"), &hosted).unwrap(); - let ledger = json!({ - "version": 1, - "mode": "hosted", - "edits": [ - { - "path": "bun.lock", - "kind": "redirect_bun_lock_package", - "action": "rewritten", - "key": NAME, - "original": LEFT_PAD_REGISTRY_LINE, - "new": left_pad_hosted, - }, - { - "path": "bun.lock", - "kind": "redirect_bun_lock_package", - "action": "rewritten", - "key": OTHER_NAME, - "original": OTHER_REGISTRY_LINE, - "new": other_hosted, - } - ], - "records": { - PURL: patch_record(UUID), - OTHER_PURL: patch_record(OTHER_UUID), - } - }); - std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); - std::fs::write( - root.join(".socket/vendor/redirect-state.json"), - serde_json::to_vec_pretty(&ledger).unwrap(), - ) - .unwrap(); pristine } /// After unwinding ONLY `left-pad`: its line is the registry tuple, `other` -/// is still hosted, and the ledger keeps exactly `other`'s record + edit. +/// is still hosted, and no ledger exists. fn assert_only_left_pad_unwound(root: &Path, pristine: &str) { let lock = read(root, "bun.lock"); assert_eq!( @@ -887,15 +879,10 @@ fn assert_only_left_pad_unwound(root: &Path, pristine: &str) { lock_line(&lock, OTHER_NAME).contains(OTHER_HOSTED_URL), "other must stay hosted:\n{lock}" ); - let ledger: Value = - serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); assert!( - ledger["records"].get(PURL).is_none() && ledger["records"].get(OTHER_PURL).is_some(), - "{ledger:#}" + !root.join(".socket/vendor/redirect-state.json").exists(), + "no hosted ledger may exist" ); - let edits = ledger["edits"].as_array().unwrap(); - assert_eq!(edits.len(), 1, "{ledger:#}"); - assert_eq!(edits[0]["key"], OTHER_NAME, "{ledger:#}"); } #[test] @@ -904,8 +891,8 @@ fn bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl() { let root = tmp.path(); let pristine = write_two_record_hosted_project(root); - // Scoped rollback: per-purl path (two records ⇒ the replay is not - // eligible). Used to exit 1 with hosted.failed = ["cannot replay yet"]. + // Scoped rollback: only left-pad's pin is restored to its upstream + // registry line; `other` stays hosted. let (code, env) = run_json( root, &[ @@ -923,8 +910,7 @@ fn bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl() { assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); assert_only_left_pad_unwound(root, &pristine); - // The last record out: covers every record ⇒ whole-ledger replay; - // pristine lock, ledger deleted. + // The last pin out: pristine lock, no ledger anywhere. let (code, env) = run_json( root, &[ @@ -940,7 +926,7 @@ fn bun_scoped_rollback_of_one_of_two_hosted_records_unwinds_only_that_purl() { assert_eq!(read(root, "bun.lock"), pristine, "pristine lock restored"); assert!( !root.join(".socket/vendor/redirect-state.json").exists(), - "emptied ledger deleted" + "no hosted ledger" ); } @@ -977,8 +963,8 @@ fn bun_scoped_remove_of_one_of_two_hosted_records_unwinds_only_that_purl() { // no path to resolve); the vendored backend refuses every pre-v2 workspace // lock (`vendor_bun_workspace_unsupported`). The Bun preflight runs inside // the engine loop before the takeover block, so a refused `vendor` (and its -// dry run) never reverts the hosted line or drops the redirect-ledger -// record first — which would leave the project unpatched in BOTH modes. +// dry run) never restores the hosted line to upstream first — which would +// leave the project unpatched in BOTH modes. const WS_CODE: &str = "vendor_bun_workspace_unsupported"; @@ -1047,7 +1033,6 @@ fn bun_vendor_silent_refusal_keeps_error_diagnosis() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let lock = write_hosted_workspace_project(root, 1); - let ledger = std::fs::read(root.join(".socket/vendor/redirect-state.json")).unwrap(); for dry_run in [true, false] { let mut args = vec![ "vendor", @@ -1066,7 +1051,7 @@ fn bun_vendor_silent_refusal_keeps_error_diagnosis() { stderr.contains("Cannot vendor") && stderr.contains("lockfileVersion-1"), "{stderr}" ); - assert_hosted_wiring_intact(root, &lock, &ledger); + assert_hosted_wiring_intact(root, &lock); } } @@ -1080,8 +1065,9 @@ fn pristine_workspace_lock(version: u64) -> String { ) } -/// A hosted-live WORKSPACE bun project with ONE redirect record, written -/// exactly as `scan --mode hosted` leaves it, plus the manifest record and +/// A hosted-live WORKSPACE bun project with ONE hosted pin, written +/// exactly as `scan --mode hosted` leaves it (no ledger), plus the manifest +/// record and /// blob a default-mode `get`/`scan` adds — the shape the plain `vendor` /// command acts on (a hosted-only project is a `noManifest` no-op). /// Returns the hosted lock text. @@ -1104,49 +1090,22 @@ fn write_hosted_workspace_project(root: &Path, version: u64) -> String { let hosted = pristine.replace(LEFT_PAD_REGISTRY_LINE, &left_pad_hosted); assert_ne!(hosted, pristine, "the hosted splice must hit"); std::fs::write(root.join("bun.lock"), &hosted).unwrap(); - let ledger = json!({ - "version": 1, - "mode": "hosted", - "edits": [{ - "path": "bun.lock", - "kind": "redirect_bun_lock_package", - "action": "rewritten", - "key": NAME, - "original": LEFT_PAD_REGISTRY_LINE, - "new": left_pad_hosted, - }], - "records": { PURL: patch_record(UUID) }, - }); - std::fs::create_dir_all(root.join(".socket/vendor")).unwrap(); - std::fs::write( - root.join(".socket/vendor/redirect-state.json"), - serde_json::to_vec_pretty(&ledger).unwrap(), - ) - .unwrap(); seed_manifest_and_blob(root); hosted } -/// Every byte of the hosted wiring must survive a refused run: the lock, -/// the redirect ledger (record + edit), and no vendor ledger or artifact. -fn assert_hosted_wiring_intact(root: &Path, hosted_lock: &str, hosted_ledger: &[u8]) { +/// Every byte of the hosted wiring must survive a refused run: the lock +/// (the only hosted state), and no vendor ledger or artifact. +fn assert_hosted_wiring_intact(root: &Path, hosted_lock: &str) { assert_eq!( read(root, "bun.lock"), hosted_lock, "bun.lock must stay byte-identical to the hosted lock" ); - let ledger_path = root.join(".socket/vendor/redirect-state.json"); - assert_eq!( - std::fs::read(&ledger_path).unwrap(), - hosted_ledger, - "the redirect ledger must stay byte-identical" + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "no hosted ledger may appear" ); - let ledger: Value = - serde_json::from_str(&read(root, ".socket/vendor/redirect-state.json")).unwrap(); - assert!(ledger["records"].get(PURL).is_some(), "{ledger:#}"); - let edits = ledger["edits"].as_array().unwrap(); - assert_eq!(edits.len(), 1, "{ledger:#}"); - assert_eq!(edits[0]["key"], NAME, "{ledger:#}"); assert!( !root.join(".socket/vendor/state.json").exists(), "a refused run must not create the vendor ledger" @@ -1162,7 +1121,6 @@ fn bun_vendor_over_hosted_v1_workspace_lock_refuses_before_unhosting() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let hosted_lock = write_hosted_workspace_project(root, 1); - let hosted_ledger = std::fs::read(root.join(".socket/vendor/redirect-state.json")).unwrap(); // Dry run: previews the REFUSAL, not the takeover, with the wet run's // exit code — and writes nothing. @@ -1179,12 +1137,12 @@ fn bun_vendor_over_hosted_v1_workspace_lock_refuses_before_unhosting() { assert_no_event_code(&env, "vendor_would_revert_redirect"); assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); assert_no_event_code(&env, "redirect_revert_failed"); - assert_hosted_wiring_intact(root, &hosted_lock, &hosted_ledger); + assert_hosted_wiring_intact(root, &hosted_lock); - // Wet run: the same refusal, BEFORE any revert — hosted wiring intact. + // Wet run: the same refusal, BEFORE any restore — hosted wiring intact. // Used to: `skipped vendor_takeover_reverted_redirect` then `failed // vendor_bun_workspace_unsupported`, registry tuple back in the lock, - // redirect-state.json deleted, `.socket/vendor/` empty. + // `.socket/vendor/` empty. let (code, env) = vendor_cli(root, &[]); assert_eq!(code, 1, "the wet run refuses: {env:#}"); assert_eq!(env["status"], "partialFailure", "{env:#}"); @@ -1201,7 +1159,7 @@ fn bun_vendor_over_hosted_v1_workspace_lock_refuses_before_unhosting() { assert_no_event_code(&env, "vendor_takeover_reverted_redirect"); assert_no_event_code(&env, "vendor_would_revert_redirect"); assert_no_event_code(&env, "redirect_revert_failed"); - assert_hosted_wiring_intact(root, &hosted_lock, &hosted_ledger); + assert_hosted_wiring_intact(root, &hosted_lock); // The manifest record survives too (the recovery path — a networked // `scan --mode hosted` — needs nothing this run could have dropped). diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs index d1715cf90..347c36e25 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover/vlt.rs @@ -1,13 +1,15 @@ //! vlt mode takeovers (DESIGN §4.10) through the built binary: //! //! 1. hosted → vendored by `scan --mode vendored` and by `vendor`: the -//! takeover restores the pristine registry node from the redirect -//! ledger, the vendor ledger records REGISTRY originals (never the +//! takeover restores the pristine registry node (v5: re-resolved from +//! the registry — a wiremock mirror here — with no ledger), the vendor +//! ledger records REGISTRY originals (never the //! hosted URL), the store copy vlt installed from the hosted pin is //! invalidated, and `vendor --revert` lands on the registry lock; //! 2. vendored → hosted by `scan --mode hosted` and `get --mode //! hosted`: the vlt revert restores node, edges and package.json before -//! the hosted rewrite, and `rollback` lands on the registry lock; +//! the hosted rewrite, and `rollback` (the upstream restore) lands on +//! the registry lock; //! 3. refusals fire BEFORE the other mode is reverted: the complete vlt //! vendored preflight in front of a hosted revert, and the hosted //! artifact preflight in front of a vendored revert. @@ -22,9 +24,15 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::vlt_hosted_common as hosted; use hosted::{ORG, PATCHED, PRISTINE, PURL, TILDE_ID, UUID}; +/// The registry lock as vlt rc.33+ writes it: `options.registries` recorded +/// and the registry node carrying its tarball URL in slot [3]. The v5 +/// upstream restore re-derives slot [3] from the lock's own convention +/// (see core `patch::redirect::upstream::vlt`), which the recorded +/// `registries` pins — so the unwinds below land on these exact bytes. fn registry_lock() -> String { format!( - "{{\n \"lockfileVersion\": 1,\n \"options\": {{}},\n \"nodes\": {{\n {}\n }},\n \ + "{{\n \"lockfileVersion\": 1,\n \"options\": {{\n \"registries\": {{\n \ + \"npm\": \"https://registry.npmjs.org/\"\n }}\n }},\n \"nodes\": {{\n {}\n }},\n \ \"edges\": {{\n \"file~_d left-pad\": \"prod 1.3.0 {TILDE_ID}\"\n }}\n}}\n", hosted::registry_node(TILDE_ID) ) @@ -72,6 +80,42 @@ async fn mock_api(server: &MockServer) { hosted::mock_discovery(server).await; hosted::mock_reference(server).await; hosted::mock_artifact(server).await; + mock_registry(server).await; +} + +/// The npm registry's version document the v5 upstream restore reads for +/// the hosted node (`SOCKET_NPM_REGISTRY` = `/registry`): the +/// pristine node's integrity and tarball. +async fn mock_registry(server: &MockServer) { + Mock::given(method("GET")) + .and(path(format!( + "/registry/{}/{}", + hosted::NAME, + hosted::VERSION + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": hosted::NAME, + "version": hosted::VERSION, + "dist": { "tarball": hosted::REGISTRY_URL, "integrity": hosted::UPSTREAM_SHA512 } + }))) + .mount(server) + .await; +} + +/// The env naming `server` the patch server (its artifact URLs are hosted +/// pins — v5 keeps no ledger to vouch for them) and its `/registry` the npm +/// registry the upstream restore reads. +fn online_env(server: &MockServer) -> [(&'static str, String); 2] { + [ + ("SOCKET_PATCH_SERVER_URL", server.uri()), + ("SOCKET_NPM_REGISTRY", format!("{}/registry", server.uri())), + ] +} + +fn run_online(root: &Path, argv: &[&str], server: &MockServer) -> (i32, Value, String) { + let env = online_env(server); + let env: Vec<(&str, &str)> = env.iter().map(|(k, v)| (*k, v.as_str())).collect(); + hosted::run_json(root, argv, &env) } fn args<'a>(root: &'a str, uri: &'a str, head: &[&'a str]) -> Vec<&'a str> { @@ -95,9 +139,10 @@ fn scan(root: &Path, server: &MockServer, mode: &str, extra: &[&str]) -> (i32, V let uri = server.uri(); let mut argv = args(&cwd, &uri, &["scan", "--mode", mode]); argv.extend_from_slice(extra); - hosted::run_json(root, &argv, &[]) + run_online(root, &argv, server) } +/// `vendor --offline`: nothing hosted to restore. fn vendor(root: &Path, extra: &[&str]) -> (i32, Value, String) { let cwd = root.to_str().unwrap().to_string(); let mut argv = vec!["vendor", "--offline", "--cwd", &cwd]; @@ -105,6 +150,15 @@ fn vendor(root: &Path, extra: &[&str]) -> (i32, Value, String) { hosted::run_json(root, &argv, &[]) } +/// `vendor` over a hosted pin on `server`: online, the takeover's upstream +/// restore reads the registry mirror. +fn vendor_online(root: &Path, server: &MockServer, extra: &[&str]) -> (i32, Value, String) { + let cwd = root.to_str().unwrap().to_string(); + let mut argv = vec!["vendor", "--cwd", &cwd]; + argv.extend_from_slice(extra); + run_online(root, &argv, server) +} + fn all_codes(env: &Value) -> Vec { let mut out = Vec::new(); fn walk(v: &Value, out: &mut Vec) { @@ -154,10 +208,12 @@ fn rel() -> String { format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0/node_modules/left-pad") } -fn redirect_ledger(root: &Path) -> Option { - std::fs::read(hosted::ledger_path(root)) - .ok() - .map(|b| serde_json::from_slice(&b).unwrap()) +/// v5 hosted mode keeps no ledger: none may ever appear. +fn assert_no_redirect_ledger(root: &Path) { + assert!( + !hosted::ledger_path(root).exists(), + "no hosted ledger may exist" + ); } fn vendor_entry(root: &Path) -> Option { @@ -202,13 +258,7 @@ fn assert_vendored_from_registry(root: &Path) { hosted::registry_node(TILDE_ID), "the vendor ledger records the REGISTRY node: {node:#}" ); - let ledger = redirect_ledger(root); - assert!( - ledger.as_ref().is_none_or(|l| l["records"] - .as_object() - .is_none_or(|r| !r.contains_key(PURL))), - "the redirect record is dropped: {ledger:#?}" - ); + assert_no_redirect_ledger(root); assert!( !hosted::store_dir(root, TILDE_ID).exists(), "the hosted store copy is invalidated" @@ -257,9 +307,9 @@ async fn vlt_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() mock_api(&server).await; let hosted_lock = hosted_project(root, &server).await; seed_manifest(root); - let ledger = std::fs::read(hosted::ledger_path(root)).unwrap(); + assert_no_redirect_ledger(root); - let (code, env, stderr) = vendor(root, &["--dry-run"]); + let (code, env, stderr) = vendor_online(root, &server, &["--dry-run"]); assert_eq!(code, 0, "{env:#}\n{stderr}"); let codes = all_codes(&env); assert!( @@ -271,13 +321,13 @@ async fn vlt_vendor_dry_run_previews_the_takeover_then_wet_vendor_completes_it() "the hosted node is registry-shaped: {env:#}" ); assert_eq!(hosted::read(root, "vlt-lock.json"), hosted_lock); - assert_eq!(std::fs::read(hosted::ledger_path(root)).unwrap(), ledger); + assert_no_redirect_ledger(root); assert!( hosted::store_dir(root, TILDE_ID).exists(), "a dry run heals nothing" ); - let (code, env, stderr) = vendor(root, &[]); + let (code, env, stderr) = vendor_online(root, &server, &[]); assert_eq!(code, 0, "{env:#}\n{stderr}"); assert!( all_codes(&env).contains(&"vendor_takeover_reverted_redirect".to_string()), @@ -306,7 +356,7 @@ async fn vendored_then_hosted(driver: &str) { let cwd = root.to_str().unwrap().to_string(); let uri = server.uri(); let argv = args(&cwd, &uri, &["get", UUID, "--mode", "hosted"]); - hosted::run_json(root, &argv, &[]) + run_online(root, &argv, &server) }; assert_eq!(code, 0, "[{driver}] {env:#}\n{stderr}"); let lock = hosted::read(root, "vlt-lock.json"); @@ -331,8 +381,11 @@ async fn vendored_then_hosted(driver: &str) { !root.join(format!(".socket/vendor/npm/{UUID}")).exists(), "[{driver}] the vendored artifact is removed" ); + assert_no_redirect_ledger(root); + // The upstream restore: online against the registry mirror, with the + // mock origin named hosted so the pin is found. let cwd = root.to_str().unwrap().to_string(); - let (code, env, stderr) = hosted::run_json(root, &["rollback", "--cwd", &cwd], &[]); + let (code, env, stderr) = run_online(root, &["rollback", "--cwd", &cwd], &server); assert_eq!(code, 0, "[{driver}] rollback: {env:#}\n{stderr}"); assert_eq!( hosted::read(root, "vlt-lock.json"), @@ -363,21 +416,16 @@ async fn vlt_vendored_preflight_refuses_before_the_hosted_revert() { // so no driver may strip the live hosted pin on its behalf. let pkg = "{\n \"dependencies\": {\n \"left-pad\": \"1.3.0\"\n },\n \"devDependencies\": {\n \"left-pad\": \"1.3.0\"\n }\n}\n"; std::fs::write(root.join("package.json"), pkg).unwrap(); - let ledger = std::fs::read(hosted::ledger_path(root)).unwrap(); let assert_intact = |what: &str| { assert_eq!(hosted::read(root, "vlt-lock.json"), hosted_lock, "[{what}]"); assert_eq!(hosted::read(root, "package.json"), pkg, "[{what}]"); - assert_eq!( - std::fs::read(hosted::ledger_path(root)).unwrap(), - ledger, - "[{what}]" - ); + assert_no_redirect_ledger(root); assert!(vendor_entry(root).is_none(), "[{what}]"); assert!(hosted::store_dir(root, TILDE_ID).exists(), "[{what}]"); }; for (what, dry) in [("vendor --dry-run", true), ("vendor", false)] { let extra: &[&str] = if dry { &["--dry-run"] } else { &[] }; - let (code, env, stderr) = vendor(root, extra); + let (code, env, stderr) = vendor_online(root, &server, extra); assert_eq!(code, 1, "[{what}] {env:#}\n{stderr}"); assert!( detail_of(&env, "vendor_lock_entry_unsupported").contains("multiple dependency fields"), @@ -443,10 +491,10 @@ async fn vlt_hosted_artifact_preflight_refuses_before_the_vendored_revert() { assert!(root.join(rel()).join("index.js").is_file()); } -/// A vendor that fails after the takeover revert was persisted (here a -/// patch-service artifact failing its integrity check) still heals the -/// hosted store copy against the restored registry pin: the redirect -/// record is gone, so no later run could find it again. +/// A vendor that fails after the takeover's upstream restore was persisted +/// (here a patch-service artifact failing its integrity check) still heals +/// the hosted store copy against the restored registry pin: the lock no +/// longer pins anything hosted, so no later run could find it again. #[tokio::test(flavor = "multi_thread")] async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { let tmp = tempfile::tempdir().unwrap(); @@ -479,7 +527,7 @@ async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { "--api-token", "fake", ]; - let (code, env, stderr) = hosted::run_json(root, &argv, &[]); + let (code, env, stderr) = run_online(root, &argv, &server); assert_eq!(code, 1, "{env:#}\n{stderr}"); let codes = all_codes(&env); assert!( @@ -498,12 +546,7 @@ async fn vlt_failed_vendor_after_the_takeover_revert_still_heals_the_store() { assert_eq!(hosted::read(root, "vlt-lock.json"), registry_lock()); assert_eq!(hosted::read(root, "package.json"), PACKAGE_JSON); assert!(vendor_entry(root).is_none()); - assert!( - redirect_ledger(root).is_none_or(|l| l["records"] - .as_object() - .is_none_or(|r| !r.contains_key(PURL))), - "the redirect record is dropped" - ); + assert_no_redirect_ledger(root); assert!( !hosted::store_dir(root, TILDE_ID).exists(), "the hosted store copy is invalidated" diff --git a/crates/socket-patch-cli/tests/mode_migration_bun.rs b/crates/socket-patch-cli/tests/mode_migration_bun.rs index 49452d222..46dced8c7 100644 --- a/crates/socket-patch-cli/tests/mode_migration_bun.rs +++ b/crates/socket-patch-cli/tests/mode_migration_bun.rs @@ -20,6 +20,13 @@ //! that matters — a fresh checkout's `bun install --frozen-lockfile` from //! an EMPTY cache materializes the bytes the lock claims. //! +//! v5: hosted mode keeps no ledger. Every unwind of a hosted pin (the +//! vendor takeover, `rollback`, `remove`) restores the entry's upstream +//! registry 4-tuple, re-resolving the integrity from the npm registry — +//! here a wiremock mirror of the pristine lock's integrities +//! (`SOCKET_NPM_REGISTRY`), with the mock patch server named hosted via +//! `SOCKET_PATCH_SERVER_URL` (see [`ONLINE_ENV`]). +//! //! Fixture: `package.json` with two real registry deps, `left-pad@1.3.0` //! (the patched target) and `is-number@7.0.0` (dependency-free; the //! untouched bystander in the single-patch legs, the second hosted record @@ -39,17 +46,17 @@ //! stage the patched content). //! //! Scenarios: -//! 1. vendored → hosted (`vendor --offline`, then `scan --mode hosted`): +//! 1. vendored → hosted (`vendor`, then `scan --mode hosted`): //! `redirect_takeover_reverted_vendored`, vendored ledger entry + //! committed artifact gone, bun.lock = the hosted URL 3-tuple with no -//! `.socket/vendor/` residue, the redirect ledger's `original` is the -//! PRISTINE registry line (originals chain intact across migrations), -//! fresh frozen install → marker bytes; `rollback` → pristine bytes, -//! no vendor artifacts or ledgers, fresh install → original bytes. +//! `.socket/vendor/` residue and no hosted ledger, fresh frozen +//! install → marker bytes; `rollback` → pristine bytes (the upstream +//! restore), no vendor artifacts or ledgers, fresh install → original +//! bytes. //! 2. hosted → vendored, BOTH drivers on copies of one hosted project: -//! `vendor --offline` (staged manifest) and `scan --mode vendored`: -//! `vendor_takeover_reverted_redirect`, redirect ledger record + edit -//! dropped (file removed when emptied), bun.lock carries the local +//! `vendor` (staged manifest) and `scan --mode vendored`: +//! `vendor_takeover_reverted_redirect` (upstream restored first, no +//! ledger anywhere), bun.lock carries the local //! `.socket/vendor/npm//` 3-tuple and not the hosted URL, the //! vendor ledger's `original` is the pristine registry line, fresh //! frozen install → marker bytes; `vendor --revert` → pristine bytes. @@ -59,11 +66,10 @@ //! vendored --dry-run` classifies `would_vendor` (never `would_refuse`); //! over a live vendored state `scan --mode hosted --dry-run` previews //! `redirect_would_revert_vendored`; none of the previews writes a -//! byte (bun.lock, both ledgers, every file under `.socket/`), and the +//! byte (bun.lock, every file under `.socket/`), and the //! wet runs then land exactly the takeovers previewed. //! 4. two hosted records in ONE scan; scoped `rollback ` and, on -//! a fresh copy, `remove ` (per-purl path — the whole-ledger -//! replay is not eligible) unwind ONLY a's line/record/edit; a fresh +//! a fresh copy, `remove ` restore ONLY a's upstream line; a fresh //! frozen install lands a's ORIGINAL bytes and b's MARKER bytes; the //! unscoped `rollback` that follows restores the pristine lock. //! 5. unscoped `rollback` from each mixed state — after (1) and after @@ -342,12 +348,23 @@ fn bun(cwd: &Path, args: &[&str], bun_home: &Path) -> Output { cmd.output().expect("failed to run bun") } +/// The env every binary run of the current (serialized) test carries once +/// [`mount_hosted_api`] has set it up: `SOCKET_PATCH_SERVER_URL` (the mock +/// patch server's origin, so its hosted URLs count as hosted pins — v5 +/// keeps no ledger to vouch for them) and `SOCKET_NPM_REGISTRY` (the +/// registry mirror the upstream restore reads). Every test is +/// `#[serial]`, so one slot is enough. +static ONLINE_ENV: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + /// The real binary with `--no-telemetry` appended: nothing in this suite /// should ever post a telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).arg("--no-telemetry").current_dir(cwd); scrub_env(&mut cmd); + for (k, v) in ONLINE_ENV.lock().unwrap_or_else(|e| e.into_inner()).iter() { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -408,15 +425,12 @@ fn vendored_scan(proj: &Path, api: &str, extra: &[&str]) -> (i32, String, String run_socket(proj, &args) } -/// `vendor --json --offline` (+ extra) over the staged manifest. +/// `vendor --json` (+ extra) over the staged manifest. Online: vendoring +/// over a hosted pin restores its upstream registry entry first, which reads +/// the registry (the [`ONLINE_ENV`] mirror); the staged manifest + blob keep +/// everything else local. fn vendor_cmd(proj: &Path, extra: &[&str]) -> (i32, String, String) { - let mut args = vec![ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ]; + let mut args = vec!["vendor", "--json", "--cwd", proj.to_str().unwrap()]; args.extend_from_slice(extra); run_socket(proj, &args) } @@ -588,17 +602,6 @@ fn packages_line(lock: &str, name: &str) -> String { .to_string() } -/// The redirect ledger's `redirect_bun_lock_package` edit keyed by the -/// lock's package-map key (`name`), if any. -fn ledger_edit_for(ledger: &Value, name: &str) -> Option { - ledger["edits"].as_array().and_then(|edits| { - edits - .iter() - .find(|e| e["kind"] == "redirect_bun_lock_package" && e["key"] == name) - .cloned() - }) -} - fn warning_codes(v: &Value) -> Vec { v.as_array() .map(|w| { @@ -671,6 +674,8 @@ impl Fixture { /// package.json + real install + era assertions. `None` = skip (already /// reported), or a hard failure under the REQUIRED gate. fn stage_fixture(tag: &str) -> Option { + // A previous test's mock servers are gone: start with no online env. + ONLINE_ENV.lock().unwrap_or_else(|e| e.into_inner()).clear(); let (bun_raw, bun_version) = bun_toolchain(tag)?; let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); @@ -995,6 +1000,41 @@ async fn mount_hosted_api( ) .mount(server) .await; + // The npm registry mirror the v5 upstream restore reads for a hosted + // bun.lock entry: each dep's version document carrying the integrity the + // PRISTINE lock recorded (bun's registry 4-tuple is `name@version`, `""`, + // deps, integrity — the integrity is the only registry-derived field). + for dep in [&DEP_A, &DEP_B] { + let line = fx.pristine_line(dep); + let integrity = line + .rsplit('"') + .nth(1) + .filter(|s| s.starts_with("sha512-")) + .unwrap_or_else(|| panic!("no integrity in the pristine line {line}")) + .to_string(); + Mock::given(method("GET")) + .and(path(format!("/registry/{}/{}", dep.name, dep.version))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": dep.name, + "version": dep.version, + "dist": { + "tarball": format!( + "https://registry.npmjs.org/{0}/-/{0}-{1}.tgz", + dep.name, dep.version + ), + "integrity": integrity + } + }))) + .mount(server) + .await; + } + *ONLINE_ENV.lock().unwrap_or_else(|e| e.into_inner()) = vec![ + ("SOCKET_PATCH_SERVER_URL".to_string(), server.uri()), + ( + "SOCKET_NPM_REGISTRY".to_string(), + format!("{}/registry", server.uri()), + ), + ]; patches } @@ -1002,10 +1042,9 @@ async fn mount_hosted_api( /// `proj` is PURELY hosted for `hp.dep`: no vendored ledger claim, no /// committed artifact (at either uuid), no `.socket/vendor/` residue in the -/// lock, the packages line IS the URL 3-tuple, and the redirect ledger's -/// record + edit are present with `original` == the PRISTINE registry line -/// and `new` == the live line. Every other dep's line is byte-identical to -/// the pristine lock. +/// lock, the packages line IS the URL 3-tuple, and no hosted ledger is +/// written (v5: the lock is the hosted state). Every other dep's line is +/// byte-identical to the pristine lock. fn assert_pure_hosted(fx: &Fixture, proj: &Path, hp: &HostedPatch) { let dep = hp.dep; let state = read(proj, ".socket/vendor/state.json"); @@ -1047,57 +1086,23 @@ fn assert_pure_hosted(fx: &Fixture, proj: &Path, hp: &HostedPatch) { other.name ); } - let ledger = read_json(proj, ".socket/vendor/redirect-state.json"); - assert_eq!( - ledger["records"][dep.purl]["uuid"], dep.uuid_h, - "the redirect ledger must record the hosted patch: {ledger:#}" - ); - let edit = ledger_edit_for(&ledger, dep.name).unwrap_or_else(|| { - panic!( - "no redirect_bun_lock_package edit for {}: {ledger:#}", - dep.name - ) - }); - assert_eq!(edit["path"], "bun.lock", "{edit:#}"); - assert_eq!( - edit["original"], - json!(fx.pristine_line(dep)), - "the redirect ledger's `original` must be the PRISTINE registry line — never a \ - `.socket/vendor/` local-path line (originals chain intact across migrations): {edit:#}" - ); - assert_eq!( - edit["new"], - json!(packages_line(&lock, dep.name)), - "the redirect ledger's `new` must be the live lock line: {edit:#}" + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" ); } -/// `proj` is PURELY vendored for `dep` at `uuid`: the redirect ledger no -/// longer claims the purl (record and edit both gone; file removed when -/// emptied), the packages line carries the local `.socket/vendor/npm//` +/// `proj` is PURELY vendored for `dep` at `uuid`: no hosted ledger exists, +/// the packages line carries the local `.socket/vendor/npm//` /// 3-tuple and no hosted URL, the artifact is committed, and the vendor /// ledger's `bun_lock_package` wiring records the PRISTINE registry line as /// its `original` (never the grant-tokenized hosted URL line). Every other /// dep's line is byte-identical to the pristine lock. fn assert_pure_vendored(fx: &Fixture, proj: &Path, dep: &Dep, uuid: &str, hosted_url: &str) { - match std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")) { - Ok(text) => { - let ledger: Value = serde_json::from_str(&text).unwrap(); - assert!( - ledger["records"].get(dep.purl).is_none(), - "the superseded redirect record must be dropped: {ledger:#}" - ); - assert!( - ledger_edit_for(&ledger, dep.name).is_none(), - "the superseded bun.lock edit must be dropped: {ledger:#}" - ); - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // An emptied ledger is deleted — the expected outcome when this - // was the only hosted record. - } - Err(e) => panic!("unreadable redirect ledger: {e}"), - } + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "no hosted ledger may exist" + ); let lock = read(proj, "bun.lock"); assert!( !lock.contains(hosted_url) && !lock.contains("/patch/npm/"), @@ -1324,13 +1329,28 @@ fn take_over_to_hosted(fx: &Fixture, proj: &Path, api: &str, hp: &HostedPatch, t bun_vex::Marker::Redirected, &[(GHSA, &[CVE_VIEW])], ); + // The stale uuid may only be NAMED by the advisory that says it + // was superseded (v5: no hosted ledger record shadows the + // manifest's, so the manifest record is what gets superseded) — + // never in an event or the document. assert!( - !out.stdout.contains(DEP_A.uuid_v) + !out.envelope["events"].to_string().contains(DEP_A.uuid_v) && !std::fs::read_to_string(&out.output) .unwrap() .contains(DEP_A.uuid_v), "the stale manifest uuid must not be attested ({tag}): {out}" ); + let warnings = out.envelope["warnings"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + warnings + .iter() + .filter(|w| w.to_string().contains(DEP_A.uuid_v)) + .all(|w| w["code"] == "vex_record_superseded"), + "only the supersede advisory may name the stale uuid ({tag}): {out}" + ); }); eprintln!("BUN-VEX stale-manifest-{tag} hosted wired-uuid-wins ok"); } @@ -1354,8 +1374,8 @@ fn take_over_to_hosted(fx: &Fixture, proj: &Path, api: &str, hp: &HostedPatch, t /// committed artifact. Returns that uuid. #[derive(Clone, Copy, PartialEq, Debug)] enum VendoredDriver { - /// `vendor --json --offline` over a hand-staged manifest (uuid_v). - VendorOffline, + /// `vendor --json` over a hand-staged manifest (uuid_v). + VendorManifest, /// `scan --mode vendored --json --yes` — discovery + download from the /// mock API (uuid_h), then the same vendor engine. ScanVendored, @@ -1374,11 +1394,11 @@ fn take_over_to_vendored( // cleartext-logging heuristic would otherwise taint every `{vendor_env}` // assertion message with the `uuid`-named half. let uuid = match driver { - VendoredDriver::VendorOffline => dep.uuid_v, + VendoredDriver::VendorManifest => dep.uuid_v, VendoredDriver::ScanVendored => dep.uuid_h, }; let vendor_env = match driver { - VendoredDriver::VendorOffline => { + VendoredDriver::VendorManifest => { stage_manifest(fx, proj, dep); let (code, stdout, stderr) = vendor_cmd(proj, &[]); assert_eq!(code, 0, "vendor failed ({tag}): {stdout}\n{stderr}"); @@ -1419,7 +1439,7 @@ fn take_over_to_vendored( match driver { // Standalone `vendor` is fed by the staged manifest and leaves its // record in place — the legacy manifest-tracked shape. - VendoredDriver::VendorOffline => { + VendoredDriver::VendorManifest => { let manifest = read_json(proj, ".socket/manifest.json"); assert_eq!( manifest["patches"][dep.purl]["uuid"], uuid, @@ -1454,7 +1474,7 @@ fn take_over_to_vendored( ); eprintln!("HOSTED→VENDORED OK ({tag}, {driver:?}, bun {})", fx.bun_raw); let cve = match driver { - VendoredDriver::VendorOffline => CVE_MANIFEST, + VendoredDriver::VendorManifest => CVE_MANIFEST, VendoredDriver::ScanVendored => CVE_VIEW, }; manifestless_vex( @@ -1500,7 +1520,7 @@ async fn bun_vendored_then_hosted_takeover_leaves_pure_hosted() { }; let proj = fx.proj.clone(); - // A: vendor (offline) from the staged manifest. + // A: vendor from the staged manifest. stage_manifest(&fx, &proj, &DEP_A); let (code, stdout, stderr) = vendor_cmd(&proj, &[]); assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); @@ -1542,10 +1562,8 @@ async fn bun_hosted_then_vendored_takeover_round_trips_to_registry() { let patches = mount_hosted_api(&server, &fx, &[&DEP_A]).await; let hp = &patches[0]; - // A: hosted redirect: registry 4-tuple → URL 3-tuple, ledger claims the - // purl with one `redirect_bun_lock_package` edit whose original is - // the pristine registry line; a fresh frozen install lands the - // patched tree. + // A: hosted redirect: registry 4-tuple → URL 3-tuple (no ledger); a + // fresh frozen install lands the patched tree. let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); @@ -1574,10 +1592,10 @@ async fn bun_hosted_then_vendored_takeover_round_trips_to_registry() { &by_vendor, &server.uri(), hp, - VendoredDriver::VendorOffline, - "vendor-offline", + VendoredDriver::VendorManifest, + "vendor-manifest", ); - assert_vendor_revert_restores_pristine(&fx, &by_vendor, "vendor-offline"); + assert_vendor_revert_restores_pristine(&fx, &by_vendor, "vendor-manifest"); take_over_to_vendored( &fx, @@ -1624,14 +1642,14 @@ async fn bun_dry_run_previews_match_wet_outcomes() { let (code, stdout, stderr) = hosted_scan(&hosted, &api, &[]); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); assert_pure_hosted(&fx, &hosted, hp); - // The manifest record `vendor` acts on (offline: the staged blob). + // The manifest record `vendor` acts on (the staged blob). stage_manifest(&fx, &hosted, &DEP_A); let before = snapshot(&hosted); - // `vendor --dry-run`: the takeover is PROBED (write-free per-purl revert - // on a ledger clone) and previewed; the backend preview does not run - // against the still-hosted lock, so no false `vendor_lock_entry_not_found` - // and no refusal. + // `vendor --dry-run`: the takeover's upstream restore is resolved + // write-free (registry lookup included) and previewed; the backend + // preview does not run against the still-hosted lock, so no false + // `vendor_lock_entry_not_found` and no refusal. let (code, stdout, stderr) = vendor_cmd(&hosted, &["--dry-run"]); assert_eq!(code, 0, "vendor --dry-run must succeed: {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); @@ -1741,7 +1759,7 @@ async fn bun_dry_run_previews_match_wet_outcomes() { // ───────────────────────────────────────────────────────────────────────── /// After unwinding ONLY DEP_A: its line is the pristine registry tuple, -/// DEP_B is still hosted, the ledger keeps exactly DEP_B's record + edit, +/// DEP_B is still hosted (the lock is the only hosted state — no ledger), /// and a fresh frozen install lands A's ORIGINAL and B's MARKER bytes. fn assert_only_a_unwound(fx: &Fixture, proj: &Path, b: &HostedPatch, tag: &str) { let lock = read(proj, "bun.lock"); @@ -1757,22 +1775,10 @@ fn assert_only_a_unwound(fx: &Fixture, proj: &Path, b: &HostedPatch, tag: &str) "{tag}: {} must stay hosted:\n{lock}", DEP_B.name ); - let ledger = read_json(proj, ".socket/vendor/redirect-state.json"); assert!( - ledger["records"].get(DEP_A.purl).is_none(), - "{tag}: A's record must be dropped: {ledger:#}" - ); - assert_eq!( - ledger["records"][DEP_B.purl]["uuid"], DEP_B.uuid_h, - "{tag}: B's record must stay: {ledger:#}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "{tag}: no hosted ledger may exist" ); - let edits = ledger["edits"].as_array().unwrap(); - assert_eq!( - edits.len(), - 1, - "{tag}: exactly B's edit must stay: {ledger:#}" - ); - assert_eq!(edits[0]["key"], DEP_B.name, "{tag}: {ledger:#}"); let fresh = fresh_frozen_install(fx, proj, &format!("fresh-{tag}")); assert_installed(&fresh, &DEP_A, &fx.a.orig, tag); assert_installed(&fresh, &DEP_B, &fx.b.patched, tag); @@ -1790,7 +1796,7 @@ async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { let patches = mount_hosted_api(&server, &fx, &[&DEP_A, &DEP_B]).await; let (a, b) = (&patches[0], &patches[1]); - // Both deps hosted-redirected in ONE scan: two records, two edits. + // Both deps hosted-redirected in ONE scan: two lock pins, no ledger. let (code, stdout, stderr) = hosted_scan(&proj, &server.uri(), &[]); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); let env = envelope(&stdout, &stderr); @@ -1804,33 +1810,17 @@ async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { hp.dep.name ); } - let ledger = read_json(&proj, ".socket/vendor/redirect-state.json"); - assert_eq!( - ledger["records"].as_object().map(|m| m.len()), - Some(2), - "{ledger:#}" - ); - assert_eq!( - ledger["edits"].as_array().map(|e| e.len()), - Some(2), - "{ledger:#}" + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" ); - for hp in [a, b] { - let edit = ledger_edit_for(&ledger, hp.dep.name) - .unwrap_or_else(|| panic!("no edit for {}: {ledger:#}", hp.dep.name)); - assert_eq!( - edit["original"], - json!(fx.pristine_line(hp.dep)), - "{edit:#}" - ); - } let fresh = fresh_frozen_install(&fx, &proj, "fresh-two-hosted"); assert_installed(&fresh, &DEP_A, &fx.a.patched, "two hosted records"); assert_installed(&fresh, &DEP_B, &fx.b.patched, "two hosted records"); - // Scoped rollback of A: per-purl path (two records ⇒ the whole-ledger - // replay is not eligible). Used to exit 1 with hosted.failed = ["cannot - // replay yet"]. + // Scoped rollback of A: only A's pin is restored to its upstream + // registry entry (v5: re-resolved from the registry mirror); B's pin is + // left alone. let by_rollback = fx.dir("two-hosted-copy-rollback"); copy_project(&proj, &by_rollback); let (code, stdout, stderr) = rollback_cmd(&by_rollback, &[DEP_A.purl]); @@ -1841,8 +1831,7 @@ async fn bun_scoped_rollback_and_remove_unwind_one_of_two_hosted_records() { assert_eq!(env["hosted"]["failed"], json!([]), "{env:#}"); assert_eq!(env["hosted"]["unsupported"], json!([]), "{env:#}"); assert_only_a_unwound(&fx, &by_rollback, b, "scoped-rollback"); - // Then the unscoped rollback: covers the last record ⇒ whole-ledger - // replay ⇒ pristine. + // Then the unscoped rollback restores the remaining pin ⇒ pristine. assert_unscoped_rollback_restores_pristine(&fx, &by_rollback, "after-scoped-rollback"); // `remove ` takes the same per-purl hosted leg. @@ -1914,8 +1903,8 @@ async fn bun_rollback_from_each_mixed_state_restores_pristine() { assert_unscoped_rollback_restores_pristine(&fx, &one, "mixed-1"); // State (2): hosted → vendored (scan-driven), then rollback: the - // vendored leg unwires + removes the artifact, the (emptied) redirect - // ledger is already gone, the manifest record is retired. + // vendored leg unwires + removes the artifact (the takeover already + // restored the upstream entry, so nothing hosted is left). let two = fx.dir("mixed-hosted-then-vendored"); copy_project(&proj, &two); let (code, stdout, stderr) = hosted_scan(&two, &api, &[]); diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs index 8b1d3b608..ec54b19b2 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -11,7 +11,10 @@ //! `registry = "socket-patch-…"` Cargo.toml pin after a vendored takeover), a //! double takeover must keep the unrecoverable crates.io lock originals in //! the vendored ledger, and the takeover classifier must never tell the user -//! to delete the live ledger. +//! to delete live hosted state. v5 hosted mode keeps no ledger: vendoring +//! over a hosted pin first restores the crates.io entry (re-resolved from +//! the sparse index — a wiremock here via `SOCKET_CRATES_INDEX`), and an +//! unrestorable pin (offline) is refused. //! //! Each scenario drives the REAL binary against real cargo (network used for //! the crates.io fixture build only; the hosted registry is wiremock) and @@ -22,8 +25,10 @@ //! marker: `(redirected)` for the hosted uuid after vendored → hosted, //! `(vendored)` for the vendored uuid after hosted → vendored and after the //! A → B → A round trip. (0) with the manifest still naming the displaced -//! patch (the wired uuid wins), (1) manifest deleted / ledger kept (zero -//! API calls), (2) ledgers deleted (lockfile wiring + API record), (3) +//! patch (the wired uuid wins), (1) manifest deleted / ledgers kept (zero +//! API calls for a vendored pin; a hosted pin's record — v5 keeps no hosted +//! ledger — comes from the API), (2) ledgers deleted (lockfile wiring + API +//! record), (3) //! `--offline` with no ledger → `record_unavailable`, zero requests. The //! displaced patch is never attested. //! @@ -66,6 +71,16 @@ fn binary() -> PathBuf { } fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, String) { + run_socket_env(cwd, args, cargo_home, &[]) +} + +/// [`run_socket`] with extra env applied after the scrub. +fn run_socket_env( + cwd: &Path, + args: &[&str], + cargo_home: &Path, + env: &[(&str, &str)], +) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { @@ -75,6 +90,9 @@ fn run_socket(cwd: &Path, args: &[&str], cargo_home: &Path) -> (i32, String, Str } cmd.env_remove("VIRTUAL_ENV"); cmd.env("CARGO_HOME", cargo_home); + for (k, v) in env { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -386,6 +404,53 @@ async fn mount_hosted_mocks( index_url } +/// Serve a crates.io sparse-index row for DEP@`version` carrying the +/// PRISTINE lock's checksum, from `server` under `/crates-index` — what the +/// v5 upstream restore reads (`SOCKET_CRATES_INDEX`) to put a hosted +/// Cargo.lock entry back on crates.io. Mirroring the pristine value keeps +/// the unwind hermetic. Returns the index base. +async fn mount_crates_index(server: &MockServer, version: &str, pristine_lock: &str) -> String { + let cksum = cargo_e2e_matrix::parse_lock(pristine_lock) + .into_iter() + .find(|p| p.name == DEP) + .and_then(|p| p.checksum) + .expect("pristine lock has a checksum"); + let row = serde_json::json!({ + "name": DEP, "vers": version, "deps": [], "cksum": cksum, + "features": {}, "yanked": false, + }) + .to_string(); + Mock::given(method("GET")) + .and(path(format!("/crates-index/{}", sparse_index_rel(DEP)))) + .respond_with(ResponseTemplate::new(200).set_body_raw(row, "text/plain")) + .mount(server) + .await; + format!("{}/crates-index", server.uri()) +} + +/// Plain `vendor --json` ONLINE over a hosted pin on `server`'s origin +/// (`--patch-server-url`), the crates index mirrored at `index`. +fn vendor_over_hosted( + proj: &Path, + cargo_home: &Path, + server: &MockServer, + index: &str, +) -> (i32, String, String) { + run_socket_env( + proj, + &[ + "vendor", + "--json", + "--patch-server-url", + server.uri().as_str(), + "--cwd", + proj.to_str().unwrap(), + ], + cargo_home, + &[("SOCKET_CRATES_INDEX", index)], + ) +} + /// Manifest-less VEX over a post-takeover fresh checkout: `wired` (uuid + /// marker) must attest, `displaced` never. Runs on a blocking thread (the /// shared `PatchApi` brings its own runtime). @@ -452,17 +517,31 @@ impl TakeoverVex { assert_eq!(out.code, Some(0), "(0) with manifest:\n{out}"); only_wired(out.doc(), "(0)"); } - // (1) Manifest deleted, ledger kept. + // (1) Manifest deleted, ledgers kept. A vendored pin's record lives + // in the vendor ledger (zero API calls); v5 hosted mode keeps no + // ledger, so a hosted pin's record comes from the API. strip_manifest(fresh); let before = api.request_count(); let out = run_vex(&bin, fresh, &run); - assert_eq!(out.code, Some(0), "(1) ledger-backed:\n{out}"); + assert_eq!(out.code, Some(0), "(1) manifest deleted:\n{out}"); only_wired(out.doc(), "(1)"); - assert_eq!( - api.request_count(), - before, - "(1) the ledger record needs no API" - ); + if matches!(marker, Marker::Redirected) { + assert!( + api.view_requests(uuid) >= 1, + "(1) the hosted record is fetched from the API" + ); + assert_eq!( + api.view_requests(self.displaced), + 0, + "(1) never asked for the displaced one" + ); + } else { + assert_eq!( + api.request_count(), + before, + "(1) the vendor ledger record needs no API" + ); + } // (2) Ledgers deleted: the lockfile wiring + the API's record. strip_ledgers(fresh); let out = run_vex(&bin, fresh, &run); @@ -622,8 +701,8 @@ async fn vendored_then_hosted_takeover_leaves_pure_hosted() { "the orphaned committed tree must be removed" ); assert!( - proj.join(".socket/vendor/redirect-state.json").exists(), - "hosted ledger written" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" ); let lock_block = package_block(&read(&proj, "Cargo.lock"), DEP).unwrap_or_default(); assert!( @@ -654,7 +733,7 @@ async fn vendored_then_hosted_takeover_leaves_pure_hosted() { .run() .await; - // D: a later vendored-flow no-op must NOT emit the inverted + // D: a later vendored-flow no-op must NOT emit the (removed) // vendor_supersedes_redirect warning (C4b: pre-fix it told the user to // delete the LIVE hosted ledger while the lock pointed at the sparse // index). Empty API + no manifest = the no-manifest no-op path. @@ -785,8 +864,9 @@ async fn lockless_vendor_then_first_build_then_hosted_takeover() { } // ── C2 / C7: hosted → vendored takeover via the plain `vendor` command ────── -// The primary migration entry point must revert the hosted edits first (from -// the redirect ledger), surface the takeover, leave the project PURELY +// The primary migration entry point must restore the hosted pin's crates.io +// entry first (v5: from the sparse index), surface the takeover, leave the +// project PURELY // vendored (fresh checkout builds offline under --locked), and a final // `vendor --revert` must restore the pristine pre-hosted project. #[tokio::test(flavor = "multi_thread")] @@ -831,19 +911,16 @@ async fn hosted_then_vendored_takeover_leaves_pure_vendored() { "hosted pin present" ); + assert!( + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" + ); + // B: plain `vendor` over the hosted state — the takeover. + let index = + mount_crates_index(&server, &version, &String::from_utf8_lossy(&lock_pristine)).await; stage_patch(&proj, &purl, &orig, &patched); - let (code, stdout, stderr) = run_socket( - &proj, - &[ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - &cargo_home, - ); + let (code, stdout, stderr) = vendor_over_hosted(&proj, &cargo_home, &server, &index); assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); assert_eq!(envelope["summary"]["applied"], 1, "{stdout}"); @@ -855,7 +932,7 @@ async fn hosted_then_vendored_takeover_leaves_pure_vendored() { // The project is FULLY vendored: the hosted Cargo.toml pin and the // registries block are gone, [patch.crates-io] + detached lock are in, - // and the hosted ledger record is dropped. + // and no hosted ledger exists. let toml = read(&proj, "Cargo.toml"); assert!( !toml.contains("socket-patch-"), @@ -875,7 +952,7 @@ async fn hosted_then_vendored_takeover_leaves_pure_vendored() { ); assert!( !proj.join(".socket/vendor/redirect-state.json").exists(), - "the emptied hosted ledger must be removed: {}", + "no hosted ledger may exist: {}", read(&proj, ".socket/vendor/redirect-state.json") ); let lock_block = package_block(&read(&proj, "Cargo.lock"), DEP).unwrap_or_default(); @@ -996,18 +1073,11 @@ async fn double_takeover_a_b_a_preserves_lock_originals() { ); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); - // A again: vendor back. - let (code, stdout, stderr) = run_socket( - &proj, - &[ - "vendor", - "--json", - "--offline", - "--cwd", - proj.to_str().unwrap(), - ], - &cargo_home, - ); + // A again: vendor back (restoring the hosted pin's crates.io entry + // first). + let index = + mount_crates_index(&server, &version, &String::from_utf8_lossy(&lock_pristine)).await; + let (code, stdout, stderr) = vendor_over_hosted(&proj, &cargo_home, &server, &index); assert_eq!(code, 0, "re-vendor failed: {stdout}\n{stderr}"); // The vendored ledger's lock originals are the PRISTINE crates.io values @@ -1073,12 +1143,14 @@ async fn double_takeover_a_b_a_preserves_lock_originals() { ); } -// ── FAIL CLOSED: vendoring over a hosted redirect with no ledger refuses ──── -// When the redirect ledger is gone the hosted originals are unrecoverable — -// the vendor run must refuse the purl with an actionable error instead of -// creating the mixed unbuildable state and reporting success. +// ── FAIL CLOSED: vendoring over an unrestorable hosted pin refuses ───────── +// When the hosted pin's crates.io entry cannot be re-resolved (here: +// `--offline`), the vendor run must refuse the purl with an actionable error +// instead of creating the mixed unbuildable state and reporting success. The +// cargo backend's `hosted_redirect_live` guard backstops a half-reverted +// project whose lock is back on crates.io but whose manifest still pins. #[tokio::test(flavor = "multi_thread")] -async fn vendor_over_hosted_without_ledger_is_refused() { +async fn vendor_over_unrestorable_hosted_pin_is_refused() { let tmp = tempfile::tempdir().unwrap(); let Some((proj, cargo_home, version, crate_dir)) = stage_fixture(tmp.path()) else { return; @@ -1115,8 +1187,7 @@ async fn vendor_over_hosted_without_ledger_is_refused() { ); assert_eq!(code, 0, "hosted scan failed: {stdout}\n{stderr}"); - // The revert data is gone. - std::fs::remove_file(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + assert!(!proj.join(".socket/vendor/redirect-state.json").exists()); let toml_before = read(&proj, "Cargo.toml"); let lock_before = read(&proj, "Cargo.lock"); @@ -1127,6 +1198,8 @@ async fn vendor_over_hosted_without_ledger_is_refused() { "vendor", "--json", "--offline", + "--patch-server-url", + server.uri().as_str(), "--cwd", proj.to_str().unwrap(), ], @@ -1134,8 +1207,10 @@ async fn vendor_over_hosted_without_ledger_is_refused() { ); assert_eq!(code, 1, "must fail closed: {stdout}\n{stderr}"); assert!( - stdout.contains("hosted_redirect_live"), - "actionable refusal code missing: {stdout}" + stdout.contains("redirect_revert_failed") + && stdout.contains("cannot vendor over the live hosted pin") + && stdout.contains("git checkout --"), + "actionable refusal (with the checkout remedy) missing: {stdout}" ); // Nothing was half-applied: the hosted wiring is untouched and no // vendored artifact/wiring was created. @@ -1186,6 +1261,15 @@ async fn vendor_over_hosted_without_ledger_is_refused() { stdout.contains("hosted_redirect_live"), "actionable refusal code missing: {stdout}" ); + assert!( + stdout.contains("socket-patch rollback") + || stdout.contains("git checkout -- Cargo.toml Cargo.lock"), + "the refusal names the v5 remedy, not a ledger: {stdout}" + ); + assert!( + !stdout.contains("redirect-state.json"), + "no ledger is named: {stdout}" + ); assert_eq!(read(&proj, "Cargo.toml"), table_toml); assert_eq!(read(&proj, "Cargo.lock"), pristine_lock); assert!(!vendor_ledger_claims(&proj, &purl)); diff --git a/crates/socket-patch-cli/tests/mode_migration_npm.rs b/crates/socket-patch-cli/tests/mode_migration_npm.rs index a2398ae3c..f734f77af 100644 --- a/crates/socket-patch-cli/tests/mode_migration_npm.rs +++ b/crates/socket-patch-cli/tests/mode_migration_npm.rs @@ -2,16 +2,16 @@ //! family must leave the project FULLY in the new mode — or refuse. //! //! Twin of `mode_migration_cargo.rs` for the yarn classic + berry lock -//! flavors. Vendoring an npm purl over a LIVE hosted redirect must run the -//! cross-mode pre-revert, or it would: +//! flavors. Vendoring an npm purl over a LIVE hosted pin must first restore +//! the pin's upstream registry entry (v5: re-resolved from the registry; +//! hosted mode keeps no ledger), or it would: //! (a) record the HOSTED patch.socket.dev lock fragment as the vendor //! ledger's unrecoverable pre-vendor "original" (not the pristine -//! registry fragment), -//! (b) leave the redirect ledger's records + edits in place forever, so the -//! `vendor_supersedes_redirect` warning's promised auto-reconcile never -//! converges, and -//! (c) make `vendor --revert` land back on the (grant-tokenized, expiring) +//! registry fragment), and +//! (b) make `vendor --revert` land back on the (grant-tokenized, expiring) //! hosted wiring with no CLI path back to registry state. +//! The reverse direction's hosted state is the lock alone; `rollback` +//! restores its upstream entry. //! //! Each scenario drives the REAL binary against a real `corepack yarn` //! (network used for the registry fixture install only; the hosted patch @@ -112,9 +112,17 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> } fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + run_socket_env(cwd, args, &[]) +} + +/// [`run_socket`] with extra env applied after the scrub. +fn run_socket_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); scrub_socket_env(&mut cmd); + for (k, v) in env { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -377,6 +385,44 @@ async fn mount_hosted_mocks( hosted_url } +/// Serve, from `server` (as `SOCKET_NPM_REGISTRY`), the npm registry version +/// document the v5 upstream restore reads for DEP — mirrored from what the +/// PRISTINE classic lock recorded (`resolved "#"`, +/// `integrity`). The restore of a hosted classic entry must reproduce the +/// registry entry yarn wrote from exactly that document; mirroring it keeps +/// the unwind hermetic (the binary's TLS stack need not reach the real +/// registry). Returns the registry base to hand the binary. +async fn mount_registry_from_classic_lock(server: &MockServer, lock: &str) -> String { + let block = lock + .split("\n\n") + .find(|b| { + b.contains(&format!("{DEP}@")) && b.contains(&format!("version \"{DEP_VERSION}\"")) + }) + .unwrap_or_else(|| panic!("no {DEP} block in the pristine lock:\n{lock}")); + let field = |name: &str| { + block + .lines() + .find_map(|l| l.trim().strip_prefix(&format!("{name} "))) + .map(|v| v.trim_matches('"').to_string()) + .unwrap_or_else(|| panic!("no `{name}` in {block}")) + }; + let resolved = field("resolved"); + let (tarball, shasum) = resolved + .split_once('#') + .map(|(t, s)| (t.to_string(), Some(s.to_string()))) + .unwrap_or((resolved.clone(), None)); + Mock::given(method("GET")) + .and(path(format!("/registry/{DEP}/{DEP_VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": DEP, + "version": DEP_VERSION, + "dist": { "tarball": tarball, "integrity": field("integrity"), "shasum": shasum } + }))) + .mount(server) + .await; + format!("{}/registry", server.uri()) +} + fn run_hosted_scan(proj: &Path, server_uri: &str) -> (i32, String, String) { run_socket( proj, @@ -501,7 +547,7 @@ fn fresh_checkout(proj: &Path, tmp: &Path, tag: &str, berry: bool) -> PathBuf { } /// Assertions shared by the classic and berry hosted→vendored legs: -/// the redirect ledger is fully reconciled, the vendor ledger's recorded +/// no hosted ledger exists, the vendor ledger's recorded /// originals are the PRISTINE registry fragments, a fresh checkout installs /// the patched bytes, and `vendor --revert` restores the registry lock /// byte-identically. @@ -522,12 +568,11 @@ fn assert_pure_vendored_and_round_trip( "takeover advisory missing from the vendor envelope ({tag}): {vendor_stdout}" ); - // (b) The superseded redirect ledger is DROPPED — records and edits both - // — so the vendor_supersedes_redirect warning can never fire again and - // no stale hosted originals survive as a revert replay hazard. + // v5 hosted mode keeps no ledger: the lock is the only hosted state, + // and the takeover's restore removed it. assert!( !proj.join(".socket/vendor/redirect-state.json").exists(), - "the emptied redirect ledger must be removed ({tag}): {}", + "no hosted ledger may exist ({tag}): {}", read(proj, ".socket/vendor/redirect-state.json") ); @@ -597,7 +642,7 @@ fn assert_pure_vendored_and_round_trip( "fresh vendored install must carry the PATCHED bytes ({tag})" ); - // (c) Round trip: `vendor --revert` restores the REGISTRY lock + // (b) Round trip: `vendor --revert` restores the REGISTRY lock // byte-identically (pre-fix it restored the hosted fragment, with no CLI // path back to registry state). let (code, stdout, stderr) = run_socket( @@ -756,21 +801,28 @@ async fn classic_hosted_then_vendored_takeover_round_trips_to_registry() { let lock = read(&proj, "yarn.lock"); assert!(lock.contains(&hosted_url), "hosted wiring present:\n{lock}"); assert!( - proj.join(".socket/vendor/redirect-state.json").exists(), - "hosted ledger written" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" ); - // B: vendor over the live hosted redirect — the takeover. + // B: vendor over the live hosted pin — the takeover. Online: the + // upstream restore re-resolves the registry entry (mirrored from the + // pristine lock), and the mock origin is named hosted via + // --patch-server-url. + let registry = + mount_registry_from_classic_lock(&server, &String::from_utf8_lossy(&lock_pristine)).await; stage_patch(&proj, &fx.orig, &fx.patched); - let (code, stdout, stderr) = run_socket( + let (code, stdout, stderr) = run_socket_env( &proj, &[ "vendor", "--json", - "--offline", + "--patch-server-url", + server.uri().as_str(), "--cwd", proj.to_str().unwrap(), ], + &[("SOCKET_NPM_REGISTRY", registry.as_str())], ); assert_eq!(code, 0, "vendor failed: {stdout}\n{stderr}"); let envelope: serde_json::Value = serde_json::from_str(&stdout).expect("json envelope"); @@ -830,14 +882,18 @@ async fn berry_hosted_then_vendored_takeover_round_trips_to_registry() { "hosted wiring present:\n{lock}" ); - // B: vendor over the live hosted redirect — the takeover. + // B: vendor over the live hosted pin — the takeover. Online against the + // REAL registry: berry's restore re-derives the 10c0 checksum from the + // registry tarball. The mock origin is named hosted via + // --patch-server-url. stage_patch(&proj, &fx.orig, &fx.patched); let (code, stdout, stderr) = run_socket( &proj, &[ "vendor", "--json", - "--offline", + "--patch-server-url", + server.uri().as_str(), "--cwd", proj.to_str().unwrap(), ], @@ -896,8 +952,8 @@ async fn berry_hosted_then_vendored_takeover_round_trips_to_registry() { // ── vendored → hosted takeover, yarn classic (reverse direction) ──────────── // The hosted scan must revert the vendored wiring + ledger entry + committed // artifact FIRST (per purl, the exact `vendor --revert` machinery), then -// redirect — leaving the project purely hosted with the redirect ledger's -// originals recording the PRISTINE registry fragments. +// redirect — leaving the project purely hosted (the lock is the only hosted +// state), from which `rollback` restores the PRISTINE registry lock. #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_vendored_then_hosted_takeover_leaves_pure_hosted() { @@ -942,8 +998,8 @@ async fn classic_vendored_then_hosted_takeover_leaves_pure_hosted() { ); // The project is FULLY hosted: no vendored ledger claim, no committed - // artifact, no `file:` lock residue; the hosted wiring is present and its - // ledger records the PRISTINE registry originals. + // artifact, no `file:` lock residue; the hosted wiring is present and no + // hosted ledger is written. assert!( !read(&proj, ".socket/vendor/state.json").contains(PURL), "the displaced vendored ledger entry must be dropped: {}", @@ -959,11 +1015,9 @@ async fn classic_vendored_then_hosted_takeover_leaves_pure_hosted() { !lock.contains(".socket/vendor/"), "no vendored residue in the lock:\n{lock}" ); - let ledger = read(&proj, ".socket/vendor/redirect-state.json"); assert!( - ledger.contains("registry.yarnpkg.com") || ledger.contains("registry.npmjs.org"), - "the redirect ledger's originals must be the pristine registry \ - fragments (originals chain intact across migrations): {ledger}" + !proj.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode writes no ledger" ); // Fresh checkout installs the patched bytes from the hosted tarball. @@ -1000,6 +1054,31 @@ async fn classic_vendored_then_hosted_takeover_leaves_pure_hosted() { &lock_pristine, ) }); + + // The originals chain across migrations: `rollback` restores the hosted + // pin's upstream registry entry, which is the pristine lock byte for + // byte (online: the entry is re-resolved from the registry document). + let registry = + mount_registry_from_classic_lock(&server, &String::from_utf8_lossy(&lock_pristine)).await; + let (code, stdout, stderr) = run_socket_env( + &proj, + &[ + "rollback", + "--json", + "--yes", + "--patch-server-url", + server.uri().as_str(), + "--cwd", + proj.to_str().unwrap(), + ], + &[("SOCKET_NPM_REGISTRY", registry.as_str())], + ); + assert_eq!(code, 0, "rollback failed: {stdout}\n{stderr}"); + assert_eq!( + read(&proj, "yarn.lock"), + String::from_utf8_lossy(&lock_pristine), + "rollback lands on the pristine registry lock" + ); } // ── vendored → hosted takeover, yarn berry (reverse direction) ───────────── diff --git a/crates/socket-patch-cli/tests/mode_migration_vlt.rs b/crates/socket-patch-cli/tests/mode_migration_vlt.rs index 8fd1ea9d3..64349b266 100644 --- a/crates/socket-patch-cli/tests/mode_migration_vlt.rs +++ b/crates/socket-patch-cli/tests/mode_migration_vlt.rs @@ -97,10 +97,55 @@ fn vendor_cmd(proj: &Path, extra: &[&str]) -> SocketOut { socket(proj, &args, &[]) } +/// ` … --json --yes --cwd --api-url/--org/--api-token +/// --patch-server-url ` with `SOCKET_NPM_REGISTRY` = the +/// harness registry. v5 keeps no hosted ledger: a hosted pin on the mock +/// patch service is only recognized under `--patch-server-url`, and every +/// unwind of it (rollback, remove, a vendored takeover) restores its +/// upstream entry from the npm registry — here the harness registry. +fn api_upstream(fx: &Fixture, dir: &Path, head: &[&str], extra: &[&str]) -> SocketOut { + let cwd = cwd_args(dir); + let uri = fx.svc.uri(); + let mut args: Vec<&str> = head.to_vec(); + args.extend([ + "--json", + "--yes", + "--cwd", + &cwd, + "--api-url", + &uri, + "--org", + ORG, + "--api-token", + "sktsec_placeholder_value_for_tests_api", + "--patch-server-url", + &uri, + ]); + args.extend_from_slice(extra); + socket(dir, &args, &[("SOCKET_NPM_REGISTRY", &fx.reg.url())]) +} + +/// `vendor` over a (possibly) hosted project: no `--offline`, since the +/// takeover restores the hosted pin's upstream entry first. +fn vendor_upstream(fx: &Fixture, dir: &Path, extra: &[&str]) -> SocketOut { + api_upstream(fx, dir, &["vendor"], extra) +} + +/// `rollback` of everything `dir` holds, hosted pins restored upstream. +fn rollback_all(fx: &Fixture, dir: &Path, extra: &[&str]) -> SocketOut { + let svc = fx.svc.uri(); + rollback_upstream(dir, &fx.reg.url(), Some(&svc), extra) +} + +/// `remove ` with hosted pins restored upstream. +fn remove_upstream(fx: &Fixture, dir: &Path, purl: &str) -> SocketOut { + api_upstream(fx, dir, &["remove", purl], &[]) +} + fn vendored_scan(fx: &Fixture, dir: &Path, extra: &[&str]) -> SocketOut { let mut args = vec!["--vendor-source", "build"]; args.extend_from_slice(extra); - socket_api(dir, &fx.svc, &["scan", "--mode", "vendored"], &args) + api_upstream(fx, dir, &["scan", "--mode", "vendored"], &args) } fn hosted_scan(fx: &Fixture, dir: &Path, extra: &[&str]) -> SocketOut { @@ -134,33 +179,12 @@ fn assert_pure_hosted(fx: &Fixture, dir: &Path, t: &PatchTarget) { !dir.join(format!(".socket/vendor/npm/{}", t.uuid)).exists(), "no vendored artifact left" ); - let ledger: Value = serde_json::from_slice( - &std::fs::read(dir.join(".socket/vendor/redirect-state.json")).unwrap(), - ) - .unwrap(); - let before: Value = serde_json::from_slice(&fx.lock_before).unwrap(); - let id = node_id(&before, &t.name, &t.version); - let original = ledger["edits"] - .as_array() - .unwrap() - .iter() - .find(|e| { - e["kind"] == "redirect_vlt_lock_node" - && e["original"] - .as_str() - .is_some_and(|o| o.starts_with(&format!("\"{id}\""))) - }) - .and_then(|e| e["original"].as_str()) - .unwrap_or_else(|| panic!("a ledger edit for {id}: {ledger:#}")) - .to_string(); - let pristine = node_line(&String::from_utf8_lossy(&fx.lock_before), &id) - .unwrap() - .trim() - .trim_end_matches(',') - .to_string(); - assert_eq!( - original, pristine, - "the ledger original is the pristine registry line" + // v5: no hosted ledger — the lock pin is the whole hosted state (the + // vendored takeover restored the upstream entry before pinning, which + // the unscoped rollback's byte-exact pristine lock proves). + assert!( + !dir.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no redirect ledger" ); } @@ -181,11 +205,8 @@ fn assert_pure_vendored(dir: &Path, t: &PatchTarget) { "no hosted URL left: {text}" ); assert!( - !dir.join(".socket/vendor/redirect-state.json").exists() - || !std::fs::read_to_string(dir.join(".socket/vendor/redirect-state.json")) - .unwrap() - .contains(&t.purl()), - "the redirect record is gone" + !dir.join(".socket/vendor/redirect-state.json").exists(), + "no hosted ledger (v5)" ); } @@ -198,7 +219,7 @@ fn assert_fresh(fx: &Fixture, dir: &Path, t: &PatchTarget, want: State, name: &s } fn assert_unscoped_rollback_pristine(fx: &Fixture, dir: &Path, name: &str) { - let out = rollback(dir, &[]); + let out = rollback_all(fx, dir, &[]); assert_eq!(out.code, 0, "{name}: {out}"); assert_eq!( String::from_utf8_lossy(&lock_bytes(dir)), @@ -239,9 +260,9 @@ async fn two_target_fixture(leg: Leg) -> Fixture { // ── 1–2. takeovers ──────────────────────────────────────────────────────── /// Vendored → hosted through `scan --mode hosted` and through `get -/// --mode hosted`: the vendored entry, artifact and wiring go, the ledger -/// original is the pristine registry line, a fresh checkout is patched; -/// the unscoped rollback restores the pristine lock. +/// --mode hosted`: the vendored entry, artifact and wiring go, no hosted +/// ledger is written, a fresh checkout is patched; the unscoped rollback +/// restores the pristine lock (the hosted pin back to its upstream entry). #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_migration_vendored_then_hosted() { @@ -281,9 +302,9 @@ async fn vlt_pinned_matrix_migration_vendored_then_hosted() { } /// Hosted → vendored through `scan --mode vendored` and through `vendor` -/// (a staged manifest): the redirect record goes, the lock is vendored, -/// fresh checkouts are patched, and `vendor --revert` restores the -/// pristine lock. +/// (a staged manifest): the takeover restores the hosted pin's upstream +/// entry first, the lock is vendored, fresh checkouts are patched, and +/// `vendor --revert` restores the pristine (upstream) lock. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_migration_hosted_then_vendored() { @@ -300,7 +321,7 @@ async fn vlt_pinned_matrix_migration_hosted_then_vendored() { vendored_scan(&fx, &dir, &[]) } else { stage_manifest(&dir, &[fx.t()]); - vendor_cmd(&dir, &[]) + vendor_upstream(&fx, &dir, &[]) }; assert_eq!(out.code, 0, "{driver}: {out}"); assert!( @@ -349,7 +370,7 @@ async fn vlt_pinned_matrix_migration_dry_run_parity() { fx.vlt_ok(&fx.proj, &fx.leg.locked_install_args()); stage_manifest(&fx.proj, &[fx.t()]); let before = project_bytes(&fx.proj); - let out = vendor_cmd(&fx.proj, &["--dry-run"]); + let out = vendor_upstream(&fx, &fx.proj, &["--dry-run"]); assert_eq!(out.code, 0, "{out}"); assert!( has_code(&out.json(), "vendor_would_revert_redirect"), @@ -371,7 +392,7 @@ async fn vlt_pinned_matrix_migration_dry_run_parity() { before, "the previews write nothing" ); - let out = vendor_cmd(&fx.proj, &[]); + let out = vendor_upstream(&fx, &fx.proj, &[]); assert_eq!(out.code, 0, "{out}"); assert!( has_code(&out.json(), "vendor_takeover_reverted_redirect"), @@ -400,9 +421,10 @@ async fn vlt_pinned_matrix_migration_dry_run_parity() { // ── 4–5. scoped and unscoped unwinds ────────────────────────────────────── -/// Two hosted records; `rollback ` and, on a copy, `remove -/// ` unwind only a; a fresh checkout lands a's registry bytes and -/// b's patch; the unscoped rollback then restores the pristine lock. +/// Two hosted pins; `rollback ` and, on a copy, `remove ` +/// restore only a's upstream entry; a fresh checkout lands a's registry +/// bytes and b's patch; the unscoped rollback then restores the pristine +/// lock. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_migration_scoped_unwind_one_of_two() { @@ -417,32 +439,28 @@ async fn vlt_pinned_matrix_migration_scoped_unwind_one_of_two() { assert_pinned(&fx.proj, &fx.svc, &a); assert_pinned(&fx.proj, &fx.svc, &b); let copy = copy_project(&fx, &fx.proj, "remove-copy"); - let out = rollback(&fx.proj, &[&a.purl()]); + let out = rollback_all(&fx, &fx.proj, &[&a.purl()]); assert_eq!(out.code, 0, "{out}"); - let cwd = cwd_args(©); - let purl = a.purl(); - let out = socket( - ©, - &[ - "remove", - &purl, - "--json", - "--yes", - "--offline", - "--cwd", - &cwd, - ], - &[], + assert_eq!( + out.json()["hosted"]["reverted"], + serde_json::json!([a.purl()]), + "only a is restored: {out}" ); + let out = remove_upstream(&fx, ©, &a.purl()); assert_eq!(out.code, 0, "{out}"); + let before: Value = serde_json::from_slice(&fx.lock_before).unwrap(); for dir in [&fx.proj, ©] { assert_not_pinned(dir, &a); assert_pinned(dir, &fx.svc, &b); - let ledger = - std::fs::read_to_string(dir.join(".socket/vendor/redirect-state.json")).unwrap(); + let lock = read_lock(dir); + let id = node_id(&lock, &a.name, &a.version); + assert_eq!( + lock["nodes"][&id][2], before["nodes"][&id][2], + "a's registry integrity is back: {lock:#}" + ); assert!( - !ledger.contains(&a.purl()) && ledger.contains(&b.purl()), - "{ledger}" + !dir.join(".socket/vendor/redirect-state.json").exists(), + "no hosted ledger (v5)" ); assert_fresh(&fx, dir, &a, State::Pristine, "a-unwound"); assert_fresh(&fx, dir, &b, State::Patched, "b-kept"); @@ -464,9 +482,9 @@ async fn vlt_pinned_matrix_migration_rollback_from_mixed() { let b = fx.svc.target(MS.0).clone(); let out = hosted_scan(&fx, &fx.proj, &[]); assert_eq!(out.code, 0, "{out}"); - let out = socket_api( + let out = api_upstream( + &fx, &fx.proj, - &fx.svc, &["get", &a.uuid, "--mode", "vendored"], &["--vendor-source", "build"], ); @@ -585,7 +603,7 @@ async fn vlt_pinned_matrix_migration_agent_rollback_after_takeovers() { vendored_scan(&fx, &dir, &[]) }; assert_eq!(out.code, 0, "{mode}: {out}"); - let out = rollback(&dir, &[]); + let out = rollback_all(&fx, &dir, &[]); assert_eq!(out.code, 0, "{mode} rollback: {out}"); assert_eq!( String::from_utf8_lossy(&lock_bytes(&dir)), @@ -614,29 +632,15 @@ async fn vlt_pinned_matrix_migration_agent_rollback_after_takeovers() { assert_eq!(out.code, 0, "{out}"); let out = hosted_scan(&fx, &dir, &[]); assert_eq!(out.code, 0, "{out}"); - let out = socket_api( + let out = api_upstream( + &fx, &dir, - &fx.svc, &["get", &b.uuid, "--mode", "vendored"], &["--vendor-source", "build"], ); assert_eq!(out.code, 0, "{out}"); for t in [&a, &b] { - let cwd = cwd_args(&dir); - let purl = t.purl(); - let out = socket( - &dir, - &[ - "remove", - &purl, - "--json", - "--yes", - "--offline", - "--cwd", - &cwd, - ], - &[], - ); + let out = remove_upstream(&fx, &dir, &t.purl()); assert_eq!(out.code, 0, "remove {}: {out}", t.name); } assert_eq!( @@ -700,9 +704,9 @@ fn npm_run(dir: &Path, args: &[&str]) -> std::process::Output { cmd.output().expect("spawn npm") } -/// vlt → npm (`vlt-lock.json` deleted): the rollback refuses the vlt edits -/// ("vlt-lock.json no longer exists") and keeps the ledger, writing no -/// lock. +/// vlt → npm (`vlt-lock.json` deleted): with the lock gone no hosted pin is +/// left to discover (v5 keeps no ledger), so rollback finds no state and +/// recreates nothing. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_migration_pm_switch_vlt_to_npm() { @@ -712,19 +716,19 @@ async fn vlt_pinned_matrix_migration_pm_switch_vlt_to_npm() { let fx = Fixture::build(leg, Shape::left_pad()).await; fx.scan(&[]); std::fs::remove_file(fx.proj.join(VLT_LOCK)).unwrap(); - let ledger = std::fs::read(fx.proj.join(".socket/vendor/redirect-state.json")).unwrap(); - let out = rollback(&fx.proj, &[]); - assert_eq!(out.code, 1, "{out}"); assert!( - out.stdout.contains("vlt-lock.json no longer exists"), - "{out}" + !fx.proj.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no ledger" ); - assert!(!fx.proj.join(VLT_LOCK).exists(), "nothing recreated"); + let files = package_files(&fx.proj); + let out = rollback_all(&fx, &fx.proj, &[]); assert_eq!( - std::fs::read(fx.proj.join(".socket/vendor/redirect-state.json")).unwrap(), - ledger, - "the ledger is kept" + (out.code, out.json()["error"].as_str()), + (1, Some("Manifest not found")), + "no lock, no pin, nothing to roll back: {out}" ); + assert!(!fx.proj.join(VLT_LOCK).exists(), "nothing recreated"); + assert_eq!(package_files(&fx.proj), files, "and nothing written"); fx.leg.ran(); } @@ -772,9 +776,9 @@ fn upgrade() -> Option { } /// rc.14 hosted, then the upgraded vlt refuses the v0 lock; the lock is -/// re-created, the rescan re-pins under the tilde DepID (superseding the -/// recorded legacy edit), rollback is clean and VEX attests after the -/// rescan. +/// re-created, the rescan re-pins under the tilde DepID (no ledger to +/// supersede), rollback restores the re-created lock byte-for-byte and VEX +/// attests after the rescan. #[tokio::test(flavor = "multi_thread")] #[ignore = "real vlt: SOCKET_PATCH_VLT_E2E_JS"] async fn vlt_pinned_matrix_migration_upgrade_hosted() { @@ -810,17 +814,15 @@ async fn vlt_pinned_matrix_migration_upgrade_hosted() { fx.vex_attested(fx.t()), "attested after the rescan: {doc:#}" ); - let ledger = - std::fs::read_to_string(fx.proj.join(".socket/vendor/redirect-state.json")).unwrap(); assert!( - !ledger.contains('·'), - "the legacy edit is superseded: {ledger}" + !fx.proj.join(".socket/vendor/redirect-state.json").exists(), + "v5 hosted mode writes no ledger" ); - let out = rollback(&fx.proj, &[]); + let out = rollback_all(&fx, &fx.proj, &[]); assert_eq!(out.code, 0, "{out}"); assert_eq!( - lock_bytes(&fx.proj), - relocked, + String::from_utf8_lossy(&lock_bytes(&fx.proj)), + String::from_utf8_lossy(&relocked), "rollback to the re-created lock" ); fx.leg.ran(); diff --git a/crates/socket-patch-cli/tests/npm_e2e_common/manifestless.rs b/crates/socket-patch-cli/tests/npm_e2e_common/manifestless.rs index 5412bb533..6827c5601 100644 --- a/crates/socket-patch-cli/tests/npm_e2e_common/manifestless.rs +++ b/crates/socket-patch-cli/tests/npm_e2e_common/manifestless.rs @@ -16,8 +16,9 @@ use std::path::{Path, PathBuf}; use crate::vex_e2e_common::{ - assert_absent, assert_attested, assert_not_attested, run_vex, seed_legacy_manifest, - statements_for, strip_ledgers, strip_manifest, Marker, PatchApi, VexOutcome, VexRun, VexVia, + assert_absent, assert_attested, assert_no_hosted_ledger, assert_not_attested, run_vex, + seed_legacy_manifest, statements_for, strip_ledgers, strip_manifest, Marker, PatchApi, + VexOutcome, VexRun, VexVia, }; /// npm >= 12 needs `allow-remote=all` for a hosted redirect's lock (the @@ -77,6 +78,8 @@ pub struct ManifestlessCase<'a> { /// the revert cell. pub registry_locks: Vec<(&'a str, Vec)>, /// Also drive embedded `apply --vex` / `vendor --vex` (manifest-less). + /// For a hosted flow `vendor` is skipped: over hosted pins it EJECTS + /// the project into `.socket/vendor/` (v5) instead of attesting it. pub embedded: &'a [VexVia], } @@ -109,7 +112,10 @@ fn run(case: &ManifestlessCase<'_>, run: VexRun) -> VexOutcome { run_vex(&crate::vex_e2e_common::binary(), case.project, &run) } -/// The manifest-less cells every npm hosted / vendored flow ends in: +/// The manifest-less cells every npm hosted / vendored flow ends in. The +/// flow's own state is checked first: a vendored flow wrote the vendor +/// ledger; a hosted flow wrote NO ledger (v5 hosted state is the lockfile +/// alone). /// /// 0. `legacy-manifest` (vendored only): the `.socket/manifest.json` a /// pre-5.0 vendored run left beside its ledger (the ledger's embedded @@ -117,31 +123,40 @@ fn run(case: &ManifestlessCase<'_>, run: VexRun) -> VexOutcome { /// 1. `manifest-deleted`: `.socket/manifest.json` removed (ledgers kept) → /// standalone `vex` attests the purl with the right marker + vuln ids /// (and every `embedded` command does too); -/// 2. `ledgers-deleted`: both ledgers removed too → still attested, from -/// lockfile discovery + the patch API (≥ 1 view request); +/// 2. `ledgers-deleted`: the vendor ledger removed too → still attested, +/// from lockfile discovery + the patch API (≥ 1 view request); /// 3. `offline`: no ledgers, `--offline` → `record_unavailable`, ZERO /// requests; /// 4. `reverted`: ledgers restored, locks reverted to their registry bytes -/// → NOT attested (default and `--no-verify`); with the ledgers gone as -/// well nothing names the patch at all. +/// → NOT attested (default and `--no-verify`): vendored → the stale +/// ledger entry is `vendor_unwired`; hosted → nothing names the patch at +/// all (the lock was the only hosted state). With the ledgers gone as +/// well nothing names the patch either way. /// /// Leaves the project reverted (locks at registry bytes, no ledgers). pub fn manifestless_vex_matrix(case: &ManifestlessCase<'_>) -> MatrixReport { let mut report = MatrixReport::default(); let label = &case.label; let p = case.project; - let ledger_paths = [ - socket_patch_core::vendor::VENDOR_STATE_REL, - socket_patch_core::patch::redirect::REDIRECT_STATE_REL, - ]; - let ledgers: Vec<(&str, Vec)> = ledger_paths + let hosted = case.marker != Marker::Vendored; + let ledgers: Vec<(&str, Vec)> = [socket_patch_core::vendor::VENDOR_STATE_REL] .iter() .filter_map(|rel| std::fs::read(p.join(rel)).ok().map(|b| (*rel, b))) .collect(); - assert!( - !ledgers.is_empty(), - "[{label}] the flow must have written a ledger" - ); + if hosted { + assert_no_hosted_ledger(p, &format!("[{label}]")); + } else { + assert!( + !ledgers.is_empty(), + "[{label}] the vendored flow must have written its ledger" + ); + } + let embedded: Vec = case + .embedded + .iter() + .copied() + .filter(|via| !(hosted && *via == VexVia::Vendor)) + .collect(); // 0. a LEGACY vendored checkout: vendored mode is manifest-free now, // but a pre-5.0 run left the record in `.socket/manifest.json` @@ -162,7 +177,7 @@ pub fn manifestless_vex_matrix(case: &ManifestlessCase<'_>) -> MatrixReport { let out = run(case, VexRun::online(case.api)); assert_eq!(out.code, Some(0), "[{label}] manifest-deleted:\n{out}"); assert_attested(out.doc(), case.purl, case.uuid, case.marker, case.vulns); - for via in case.embedded { + for via in &embedded { let out = run(case, VexRun::online(case.api).via(*via)); assert_eq!(out.code, Some(0), "[{label}] embedded {via:?}:\n{out}"); assert_attested(out.doc(), case.purl, case.uuid, case.marker, case.vulns); @@ -188,7 +203,7 @@ pub fn manifestless_vex_matrix(case: &ManifestlessCase<'_>) -> MatrixReport { "[{label}] the record must come from the patch API: {:?}", case.api.requests() ); - for via in case.embedded { + for via in &embedded { let out = run(case, VexRun::online(case.api).via(*via)); assert_eq!( out.code, @@ -221,10 +236,6 @@ pub fn manifestless_vex_matrix(case: &ManifestlessCase<'_>) -> MatrixReport { for (lock, bytes) in &case.registry_locks { std::fs::write(p.join(lock), bytes).unwrap(); } - let unwired = match case.marker { - Marker::Vendored => "vendor_unwired", - _ => "redirect_unwired", - }; for no_verify in [false, true] { let mut r = VexRun::online(case.api); r.no_verify = no_verify; @@ -235,7 +246,14 @@ pub fn manifestless_vex_matrix(case: &ManifestlessCase<'_>) -> MatrixReport { "[{label}] reverted (no_verify={no_verify}):\n{out}" ); assert_absent(out.doc.as_ref(), case.purl); - assert_not_attested(&out.envelope, case.purl, unwired); + if hosted { + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "[{label}] reverted hosted lock: no hosted state is left:\n{out}" + ); + } else { + assert_not_attested(&out.envelope, case.purl, "vendor_unwired"); + } } strip_ledgers(p); let out = run(case, VexRun::online(case.api)); diff --git a/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs b/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs index 4697de930..f8b7cfadb 100644 --- a/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs +++ b/crates/socket-patch-cli/tests/redirect_npm_allow_remote.rs @@ -11,14 +11,21 @@ //! The hosted run therefore AUTO-CONFIGURES it (the npm twin of the pnpm //! `trustLockfile` auto-config): it ensures `allow-remote=all` in the project //! `.npmrc` (created, or one line appended with every other byte kept), -//! records the edit in the redirect ledger (`redirect_npmrc_allow_remote`) -//! so `rollback` removes exactly what it added, respects an explicit other -//! user value, honors `--no-npm-allow-remote-config` / +//! respects an explicit other user value, honors `--no-npm-allow-remote-config` / //! `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` and `--dry-run`, and ALWAYS warns //! (`redirect_npm_allow_remote`) with the whole-tree tradeoff — while a //! project whose npm-family redirect is not in an npm lock stays quiet. //! -//! Hermetic: wiremock API, the built binary, no npm needed. +//! v5 hosted mode keeps no ledger, so nothing records WHICH `.npmrc` bytes +//! the run wrote. When `rollback` / `remove` restore the last npm-lock pin +//! to its upstream registry entry, the `.npmrc` is deleted only when it is +//! still byte-identical to the file a hosted run creates; any other file +//! (the user's, or one hosted mode appended to) is kept as-is, and a line +//! `allow-remote=all` left in it is reported (`npm_allow_remote_left`) for +//! the user to remove if nothing else needs it. +//! +//! Hermetic: wiremock API + wiremock npm registry (`SOCKET_NPM_REGISTRY`, +//! for the upstream restore), the built binary, no npm needed. use std::path::Path; @@ -37,6 +44,7 @@ const UUID: &str = "11111111-1111-4111-8111-111111111111"; const PATCHED_SHA512: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; const UPSTREAM_SHA512: &str = "sha512-UPSTREAMupstream=="; const CODE: &str = "redirect_npm_allow_remote"; +const LEFT: &str = "npm_allow_remote_left"; fn hosted_url() -> String { format!( @@ -92,6 +100,18 @@ async fn mock_api(server: &MockServer) { }))) .mount(server) .await; + // The npm registry's version document (the upstream restore's source). + Mock::given(method("GET")) + .and(path(format!("/npm-registry/{NAME}/{VERSION}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": NAME, "version": VERSION, + "dist": { + "tarball": format!("https://registry.npmjs.org/{NAME}/-/{NAME}-{VERSION}.tgz"), + "integrity": UPSTREAM_SHA512, + } + }))) + .mount(server) + .await; Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -225,44 +245,52 @@ fn allow_remote_warning(doc: &Value) -> Option<&str> { .and_then(|w| w["detail"].as_str()) } -/// The recorded `.npmrc` ledger edits (`(action, key, new)`). -fn npmrc_edits(root: &Path) -> Vec<(String, String, String)> { - let Ok(text) = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")) else { - return Vec::new(); - }; - let ledger: Value = serde_json::from_str(&text).unwrap(); - ledger["edits"] +/// v5 hosted mode records the `.npmrc` edit nowhere: no ledger is written. +fn assert_no_ledger(root: &Path) { + assert!( + !root.join(".socket/vendor/redirect-state.json").exists(), + "hosted mode keeps no ledger" + ); +} + +/// The run-level warning `code` of a rollback/remove envelope, if any. +fn run_warning<'a>(doc: &'a Value, code: &str) -> Option<&'a str> { + doc["warnings"] .as_array() .into_iter() .flatten() - .filter(|e| e["kind"] == "redirect_npmrc_allow_remote") - .map(|e| { - assert_eq!(e["path"], ".npmrc", "{e}"); - ( - e["action"].as_str().unwrap().to_string(), - e["key"].as_str().unwrap().to_string(), - e["new"].as_str().unwrap().to_string(), - ) - }) - .collect() + .find(|w| w["code"] == code) + .and_then(|w| w["detail"].as_str()) } -fn rollback(cwd: &Path, extra: &[&str]) -> (i32, Value) { +/// The args + env every hosted rollback/remove needs here: the mock patch +/// host recognized as hosted, and the npm registry pointed at the mock. +fn hosted_unwind_env(server: &MockServer) -> (String, [&'static str; 2]) { + ( + format!("{}/npm-registry", server.uri()), + ["--patch-server-url", "http://patch.test"], + ) +} + +fn rollback(cwd: &Path, server: &MockServer, extra: &[&str]) -> (i32, Value) { let cwd_s = cwd.to_str().unwrap().to_string(); + let (registry, flags) = hosted_unwind_env(server); let mut args = vec!["rollback", "--json", "--cwd", &cwd_s]; + args.extend_from_slice(&flags); args.extend_from_slice(extra); - let (code, stdout, stderr) = run_isolated(cwd, &args, &[]); + let (code, stdout, stderr) = + run_isolated(cwd, &args, &[("SOCKET_NPM_REGISTRY", registry.as_str())]); let doc = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("not JSON ({e}):\n{stdout}\nstderr:\n{stderr}")); (code, doc) } /// A package-lock.json redirect CREATES `.npmrc` with exactly -/// `allow-remote=all`, records a `created` ledger edit, and warns (JSON + -/// human) with the npm 12 failure, the whole-tree tradeoff and the opt-out; -/// the idempotent re-run records nothing new and still warns (the -/// already-set variant); `rollback` deletes the file it created and -/// restores the lock. +/// `allow-remote=all` (no ledger records it), and warns (JSON + human) with +/// the npm 12 failure, the whole-tree tradeoff and the opt-out; the +/// idempotent re-run changes nothing and still warns (the already-set +/// variant); `rollback` restores the lock to its upstream entry and deletes +/// the still-pristine `.npmrc` the run created. #[tokio::test] async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { let server = MockServer::start().await; @@ -290,10 +318,7 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), "allow-remote=all\n" ); - assert_eq!( - npmrc_edits(tmp.path()), - vec![("created".into(), "allow-remote".into(), "all".into())] - ); + assert_no_ledger(tmp.path()); // The `.npmrc` write rides the hosted run's lock window: the lock is // released (unlinked) when the run ends. assert!( @@ -309,7 +334,12 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { detail.contains("already sets `allow-remote=all`"), "{detail}" ); - assert_eq!(npmrc_edits(tmp.path()).len(), 1, "no duplicate ledger edit"); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), + "allow-remote=all\n", + "no duplicate line" + ); + assert_no_ledger(tmp.path()); // Human output: the `Warning (): …` line on stderr; --silent mutes it. let (code, _, stderr) = scan_hosted(tmp.path(), &server.uri(), &[]); @@ -323,7 +353,7 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { assert!(!stderr.contains(CODE), "--silent is errors only: {stderr}"); // Rollback: the created .npmrc is deleted with the lock redirect. - let (code, doc) = rollback(tmp.path(), &[]); + let (code, doc) = rollback(tmp.path(), &server, &[]); assert_eq!(code, 0, "{doc:#}"); assert!(!tmp.path().join(".npmrc").exists(), "{doc:#}"); // (The npm writer normalizes the trailing newline; compare the JSON.) @@ -332,10 +362,7 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { json(&std::fs::read(tmp.path().join("package-lock.json")).unwrap()), json(&pristine) ); - assert!(!tmp - .path() - .join(".socket/vendor/redirect-state.json") - .exists()); + assert!(run_warning(&doc, LEFT).is_none(), "{doc:#}"); assert!( !tmp.path().join(".socket").exists(), "a fully unwound hosted project keeps no .socket/ residue: {doc:#}" @@ -343,9 +370,11 @@ async fn package_lock_redirect_writes_npmrc_warns_and_rollback_removes_it() { } /// An existing `.npmrc` (BOM + CRLF, no allow-remote) gets exactly one -/// appended line in its own line ending; a user edit made AFTER the scan -/// survives rollback, which removes only the appended line. The shrinkwrap -/// flavor is configured the same way. +/// appended line in its own line ending. With no ledger recording the +/// append, rollback never edits a file the user owns: the `.npmrc` (and a +/// user edit made AFTER the scan) survives byte-for-byte, and the leftover +/// `allow-remote=all` line is reported (`npm_allow_remote_left`). The +/// shrinkwrap flavor is configured the same way. #[tokio::test] async fn existing_npmrc_gets_one_line_and_rollback_keeps_user_edits() { let server = MockServer::start().await; @@ -366,22 +395,24 @@ async fn existing_npmrc_gets_one_line_and_rollback_keeps_user_edits() { std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), format!("{user}allow-remote=all\r\n") ); - assert_eq!( - npmrc_edits(tmp.path()), - vec![("added".into(), "allow-remote".into(), "all".into())] - ); + assert_no_ledger(tmp.path()); // The user keeps editing the file after the scan. let mut live = std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(); live.push_str("fund=false\r\n"); std::fs::write(tmp.path().join(".npmrc"), &live).unwrap(); - let (code, doc) = rollback(tmp.path(), &[]); + let (code, doc) = rollback(tmp.path(), &server, &[]); assert_eq!(code, 0, "{doc:#}"); + assert_eq!(doc["hosted"]["reverted"], json!([PURL]), "{doc:#}"); assert_eq!( std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), - format!("{user}fund=false\r\n"), - "only the appended line is removed" + live, + "a user-owned .npmrc is never edited by the restore" + ); + assert!( + run_warning(&doc, LEFT).is_some_and(|d| d.contains("allow-remote=all")), + "the leftover line is reported: {doc:#}" ); // The reversal prunes the emptied `.socket/` — never the user's // `.npmrc`, which lives outside it and keeps their settings. @@ -391,8 +422,8 @@ async fn existing_npmrc_gets_one_line_and_rollback_keeps_user_edits() { ); } -/// An explicit other value (`none` / `root`) is RESPECTED — never rewritten, -/// no ledger edit — and named with the manual remedy; an `allow_remote` +/// An explicit other value (`none` / `root`) is RESPECTED — never rewritten +/// — and named with the manual remedy; an `allow_remote` /// spelling npm does not honor in `.npmrc` is left alone and the real key /// appended; an existing `allow-remote=all` is kept (already-set warning). #[tokio::test] @@ -418,7 +449,7 @@ async fn explicit_values_are_respected_and_unhonored_spellings_are_not_trusted() npmrc, "an explicit user setting is never rewritten" ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); } let tmp = tempfile::tempdir().unwrap(); @@ -440,9 +471,9 @@ async fn explicit_values_are_respected_and_unhonored_spellings_are_not_trusted() assert!( allow_remote_warning(&doc).is_some_and(|d| d.contains("already sets `allow-remote=all`")) ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); // A user-owned setting survives rollback untouched. - let (code, doc) = rollback(tmp.path(), &[]); + let (code, doc) = rollback(tmp.path(), &server, &[]); assert_eq!(code, 0, "{doc:#}"); assert_eq!( std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), @@ -498,7 +529,7 @@ async fn opt_out_dry_run_and_unredirected_projects() { !tmp.path().join(".npmrc").exists(), "opt-out writes nothing" ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); } let tmp = tempfile::tempdir().unwrap(); @@ -555,7 +586,7 @@ async fn symlinked_npmrc_is_left_alone() { std::fs::read_to_string(tmp.path().join("shared.npmrc")).unwrap(), "fund=false\n" ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); } /// Review findings, end to end: @@ -565,8 +596,9 @@ async fn symlinked_npmrc_is_left_alone() { /// below it is respected (writing above it would not have taken effect); /// - a CR-only file without the key is never spliced (manual remedy); /// - a section-scoped `allow-remote=all` (inert to npm) gets our top-level -/// line, and rollback removes exactly ours instead of refusing the two -/// copies as ambiguous. +/// line; rollback restores the lock without editing the user's file (the +/// section copy never makes the restore ambiguous or refused) and reports +/// the leftover line. #[tokio::test] async fn npm_ini_line_and_section_rules_are_honored() { let server = MockServer::start().await; @@ -591,7 +623,7 @@ async fn npm_ini_line_and_section_rules_are_honored() { npmrc, "an explicit value is never flipped" ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); } let tmp = tempfile::tempdir().unwrap(); @@ -609,7 +641,7 @@ async fn npm_ini_line_and_section_rules_are_honored() { std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), cr_only ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); let tmp = tempfile::tempdir().unwrap(); write_npm_project(tmp.path(), "package-lock.json"); @@ -621,22 +653,24 @@ async fn npm_ini_line_and_section_rules_are_honored() { std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), format!("allow-remote=all\n{sectioned}") ); - let (code, doc) = rollback(tmp.path(), &[]); + let (code, doc) = rollback(tmp.path(), &server, &[]); assert_eq!( code, 0, - "the section copy must not make the unwind ambiguous: {doc:#}" + "the section copy must not make the restore ambiguous: {doc:#}" ); assert_eq!( std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), - sectioned + format!("allow-remote=all\n{sectioned}"), + "a user-owned .npmrc is never edited by the restore" ); + assert!(run_warning(&doc, LEFT).is_some(), "{doc:#}"); } /// Review finding: only the project `.npmrc` was consulted. An explicit /// `allow-remote` in the env (which beats the project file) or in the /// user / global npm config (a machine / org policy a committed project -/// line would silently override) is now respected — nothing written, no -/// ledger edit — and the warning names the source and the remedy. +/// line would silently override) is now respected — nothing written — and +/// the warning names the source and the remedy. #[tokio::test] async fn outer_npm_config_layers_are_respected() { let server = MockServer::start().await; @@ -672,7 +706,7 @@ async fn outer_npm_config_layers_are_respected() { !tmp.path().join(".npmrc").exists(), "no project override written" ); - assert!(npmrc_edits(tmp.path()).is_empty()); + assert_no_ledger(tmp.path()); // global config under /etc/npmrc, the prefix relocated the // way npm allows from the env (`npm_config_prefix` — it outranks the @@ -748,10 +782,11 @@ async fn outer_npm_config_layers_are_respected() { ); } -/// Review finding: `remove` dropped the hosted leg's warnings, so a -/// redirect-created `.npmrc` the user had since added to was rewritten -/// with no `redirect_npmrc_allow_remote_modified` (CLI_CONTRACT promises -/// it in rollback/remove `warnings[]`). Human stderr and JSON both carry it. +/// Review finding: `remove` dropped the hosted leg's warnings. A +/// redirect-created `.npmrc` the user has since added to is no longer the +/// scaffold, so the restore keeps it byte-for-byte and warns that the +/// `allow-remote=all` line is left (`npm_allow_remote_left`, in +/// rollback/remove `warnings[]`). Human stderr and JSON both carry it. #[tokio::test] async fn remove_surfaces_the_npmrc_modified_warning() { let server = MockServer::start().await; @@ -764,11 +799,17 @@ async fn remove_surfaces_the_npmrc_modified_warning() { std::fs::write(tmp.path().join(".npmrc"), "allow-remote=all\nfund=false\n").unwrap(); let cwd_s = tmp.path().to_str().unwrap().to_string(); + let (registry, flags) = hosted_unwind_env(&server); let mut args = vec!["remove", PURL, "--yes", "--cwd", &cwd_s]; + args.extend_from_slice(&flags); if json { args.push("--json"); } - let (code, stdout, stderr) = run_isolated(tmp.path(), &args, &[]); + let (code, stdout, stderr) = run_isolated( + tmp.path(), + &args, + &[("SOCKET_NPM_REGISTRY", registry.as_str())], + ); assert_eq!(code, 0, "{stdout}\n{stderr}"); if json { let doc: Value = serde_json::from_str(&stdout) @@ -778,7 +819,7 @@ async fn remove_surfaces_the_npmrc_modified_warning() { .as_array() .into_iter() .flatten() - .any(|w| w["code"] == "redirect_npmrc_allow_remote_modified"), + .any(|w| w["code"] == LEFT), "{doc:#}" ); assert!( @@ -786,14 +827,18 @@ async fn remove_surfaces_the_npmrc_modified_warning() { "--json keeps stderr quiet: {stderr}" ); } else { - assert!( - stderr.contains("Warning (redirect_npmrc_allow_remote_modified): "), - "{stderr}" - ); + assert!(stderr.contains(&format!("Warning ({LEFT}): ")), "{stderr}"); } assert_eq!( std::fs::read_to_string(tmp.path().join(".npmrc")).unwrap(), - "fund=false\n" + "allow-remote=all\nfund=false\n", + "a modified .npmrc is kept byte-for-byte" + ); + assert!( + !std::fs::read_to_string(tmp.path().join("package-lock.json")) + .unwrap() + .contains("patch.test"), + "the lock is back on the registry" ); } } diff --git a/crates/socket-patch-cli/tests/remove_duality_invariants.rs b/crates/socket-patch-cli/tests/remove_duality_invariants.rs index ef7ecdf24..8cb2efb1f 100644 --- a/crates/socket-patch-cli/tests/remove_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/remove_duality_invariants.rs @@ -473,6 +473,10 @@ fn default_remove_sweeps_archives_too() { // --------------------------------------------------------------------------- // 4. Hosted-redirect leg +// +// v5 hosted state is the lockfile pin itself (no ledger): removing a hosted +// patch restores the pin's DEFAULT UPSTREAM registry entry, re-resolved +// from a mock npm registry (`SOCKET_NPM_REGISTRY`). // --------------------------------------------------------------------------- const NPM_PURL: &str = "pkg:npm/left-pad@1.3.0"; @@ -504,27 +508,64 @@ fn redirected_lock_text() -> String { ) } -/// 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" - }}"# - ) +/// The exact bytes the upstream restore writes for [`redirected_lock_text`]: +/// parse the fixture, put the registry's resolved/integrity back, serialize +/// with the workspace's preserve_order serde_json + trailing newline. This +/// pins the WHOLE file, not just the two fields. +fn upstream_lock_text() -> String { + let mut expected: serde_json::Value = serde_json::from_str(&redirected_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)); + format!("{}\n", serde_json::to_string_pretty(&expected).unwrap()) +} + +/// A mock npm registry serving left-pad@1.3.0's version document `dist`. +/// wiremock serves from its own thread; the runtime only owns the server. +struct NpmRegistry { + server: wiremock::MockServer, + _rt: tokio::runtime::Runtime, +} + +impl NpmRegistry { + fn start(dist: serde_json::Value) -> Self { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let server = rt.block_on(async { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/npm/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": dist, + }))) + .mount(&server) + .await; + server + }); + Self { server, _rt: rt } + } + + /// The registry's real entry for the fixture package. + fn upstream() -> Self { + Self::start(serde_json::json!({ "tarball": ORIG_RESOLVED, "integrity": ORIG_INTEGRITY })) + } + + fn env(&self) -> String { + format!("{}/npm", self.server.uri()) + } } -/// 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"); +/// A PRE-V5 redirect ledger (v5 never writes one): one npm record and its +/// recorded `redirect_npm_lock_entry` edit matching +/// [`redirected_lock_text`], its recorded original deliberately DIFFERENT +/// from the registry's entry (a replay would write it; the restore must +/// not). +fn legacy_npm_redirect_ledger_text() -> String { format!( r#"{{ "version": 1, @@ -535,12 +576,20 @@ fn npm_redirect_ledger_text() -> String { "kind": "redirect_npm_lock_entry", "action": "rewritten", "key": "node_modules/left-pad", - "original": {{ "resolved": "{ORIG_RESOLVED}", "integrity": "{ORIG_INTEGRITY}" }}, + "original": {{ "resolved": "{ORIG_RESOLVED}", "integrity": "sha512-LEDGERledger==" }}, "new": {{ "resolved": "{HOSTED_RESOLVED}", "integrity": "{HOSTED_INTEGRITY}" }} }} ], "records": {{ - "{NPM_PURL}": {record} + "{NPM_PURL}": {{ + "uuid": "{NPM_UUID}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "synthetic hosted npm patch", + "license": "MIT", + "tier": "free" + }} }} }}"# ) @@ -555,27 +604,30 @@ fn write_redirect_ledger_text(root: &Path, text: &str) -> PathBuf { } /// 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). +/// per-purl exit path): the lockfile pin is restored to the upstream +/// registry entry, and the restore IS the removal, so the +/// `hosted_reverted` event counts toward `summary.removed` (the +/// detached-vendored convention). No ledger is involved, and no `.socket/` +/// state is left behind. #[test] -fn hosted_only_remove_without_manifest_unwinds_redirect() { +fn hosted_only_remove_without_manifest_restores_upstream() { 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()); + std::fs::write(&lock_path, redirected_lock_text()).unwrap(); + let registry = NpmRegistry::upstream(); - let (code, stdout, stderr) = - run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[NPM_PURL, "--json", "--yes"], + &[("SOCKET_NPM_REGISTRY", ®istry.env())], + ); 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}" + "the hosted restore IS the removal on this path; envelope={v}" ); let events = v["events"].as_array().expect("events array"); assert!( @@ -584,48 +636,37 @@ fn hosted_only_remove_without_manifest_unwinds_redirect() { && 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" + upstream_lock_text(), + "the lock must hold exactly the upstream registry 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" + !tmp.path().join(".socket").exists(), + "no manifest (or ledger) may be materialized as a side effect" ); } /// The hosted leg on the manifest path: 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. +/// entry AND the lockfile's hosted pin for the same purl. The remove +/// restores the pin's upstream entry byte-exactly — from the REGISTRY, not +/// from a pre-v5 ledger left beside it (whose recorded original differs), +/// which is retired once no hosted pin remains — and the envelope carries +/// the `hosted_reverted` event alongside the per-purl manifest removal. #[test] -fn hosted_remove_with_manifest_entry_unwinds_redirect() { +fn hosted_remove_with_manifest_entry_restores_upstream() { 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()); + std::fs::write(&lock_path, redirected_lock_text()).unwrap(); + let ledger_path = write_redirect_ledger_text(tmp.path(), &legacy_npm_redirect_ledger_text()); let socket = write_manifest_files_empty(tmp.path(), NPM_PURL, NPM_UUID); + let registry = NpmRegistry::upstream(); - let (code, stdout, stderr) = - run_remove(tmp.path(), &[NPM_PURL, "--json", "--yes", "--offline"], &[]); + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[NPM_PURL, "--json", "--yes"], + &[("SOCKET_NPM_REGISTRY", ®istry.env())], + ); 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"); @@ -634,34 +675,22 @@ fn hosted_remove_with_manifest_entry_unwinds_redirect() { "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(); + let restored = std::fs::read_to_string(&lock_path).unwrap(); assert_eq!( - reverted_text, expected_text, - "the lock must hold exactly the pre-redirect entry" + restored, + upstream_lock_text(), + "the lock must hold exactly the upstream registry entry" ); assert!( - !reverted_text.contains(NPM_UUID), - "no hosted artifact URL (patch uuid) may survive in the lock" + !restored.contains(NPM_UUID) && !restored.contains("LEDGER"), + "no hosted URL survives, and the ledger's recorded original was never replayed" ); - - // Record + edit dropped → empty ledger deleted outright. assert!( !ledger_path.exists(), - "the emptied redirect ledger must be deleted; envelope={v}" + "the pre-v5 ledger is retired once no hosted pin remains; envelope={v}" ); - // Envelope: the hosted unwind event plus the plain per-purl removal. + // Envelope: the hosted restore event plus the plain per-purl removal. let events = v["events"].as_array().expect("events array"); assert!( events.iter().any(|e| e["action"] == "removed" @@ -685,77 +714,51 @@ fn hosted_remove_with_manifest_entry_unwinds_redirect() { } // --------------------------------------------------------------------------- -// 5. Unsupported-ecosystem hosted purl fails closed +// 5. A hosted pin the upstream restore refuses 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 manifest path; the -/// manifest-less twin is below), the unsupported-ecosystem hosted -/// target fails closed BEFORE the manifest mutation: exit 1, top-level -/// `hosted_revert_unsupported`, and BOTH stores byte-identical. +/// With a manifest entry for the purl (the manifest path; the +/// manifest-less twin is below), a pin whose upstream entry cannot be +/// re-derived (the registry records no integrity for it) fails closed +/// BEFORE the manifest mutation: exit 1, top-level `hosted_revert_failed` +/// naming the `git checkout` remedy, and the lock + manifest +/// byte-identical. #[test] -fn hosted_unsupported_ecosystem_remove_fails_closed() { +fn hosted_refused_restore_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 lock_path = tmp.path().join("package-lock.json"); + std::fs::write(&lock_path, redirected_lock_text()).unwrap(); + let lock_before = std::fs::read(&lock_path).unwrap(); + let socket = write_manifest_files_empty(tmp.path(), NPM_PURL, NPM_UUID); let manifest_before = std::fs::read(socket.join("manifest.json")).unwrap(); + let registry = NpmRegistry::start(serde_json::json!({ "tarball": ORIG_RESOLVED })); - let (code, stdout, stderr) = - run_remove(tmp.path(), &[GEM_PURL, "--json", "--yes", "--offline"], &[]); + let (code, stdout, stderr) = run_remove( + tmp.path(), + &[NPM_PURL, "--json", "--yes"], + &[("SOCKET_NPM_REGISTRY", ®istry.env())], + ); assert_eq!( code, 1, - "unsupported hosted revert must fail; stdout=\n{stdout}\nstderr=\n{stderr}" + "a refused hosted restore 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}" - ); + assert_eq!(v["error"]["code"], "hosted_revert_failed", "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}" + msg.contains(&format!( + "cannot restore {NPM_PURL} to its upstream registry entry" + )) && msg.contains("the registry records no integrity") + && msg.contains("git checkout -- package-lock.json") + && msg.contains("The manifest was not modified."), + "the error must name the purl, the cause 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" - ); + // Fail-closed: lock AND manifest byte-identical. + assert_eq!(std::fs::read(&lock_path).unwrap(), lock_before); assert_eq!( std::fs::read(socket.join("manifest.json")).unwrap(), manifest_before, @@ -763,37 +766,36 @@ fn hosted_unsupported_ecosystem_remove_fails_closed() { ); } -/// 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. +/// Manifest-less twin of the refusal: the identifier reaches the +/// hosted-only removal path, where an `--offline` run cannot re-resolve the +/// upstream entry — fail closed with `hosted_revert_failed`, the lock +/// untouched and no `.socket/` state written. #[test] -fn hosted_only_unsupported_remove_without_manifest_fails_closed() { +fn hosted_only_refused_restore_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 lock_path = tmp.path().join("package-lock.json"); + std::fs::write(&lock_path, redirected_lock_text()).unwrap(); + let lock_before = std::fs::read(&lock_path).unwrap(); let (code, stdout, stderr) = - run_remove(tmp.path(), &[GEM_PURL, "--json", "--yes", "--offline"], &[]); + run_remove(tmp.path(), &[NPM_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}" - ); + assert_eq!(v["error"]["code"], "hosted_revert_failed", "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}" + msg.contains(NPM_PURL) + && msg.contains("this run is offline") + && msg.contains("git checkout -- package-lock.json"), + "the refusal names the purl, the cause and the remedy; envelope={v}" ); assert_eq!( - std::fs::read(&ledger_path).unwrap(), - ledger_before, - "the redirect ledger must be unchanged" + std::fs::read(&lock_path).unwrap(), + lock_before, + "the lock must be unchanged" ); + assert!(!tmp.path().join(".socket").exists(), "nothing written"); } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index 3588eadd1..b92a0bbb8 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -129,8 +129,8 @@ fn repair_with_no_manifest_emits_manifest_not_found_envelope() { ); } -/// A project whose ONLY trace is the hosted-mode redirect ledger -/// (`.socket/vendor/redirect-state.json`) — no manifest, no vendor +/// A project whose ONLY trace is a PRE-V5 hosted-mode redirect ledger +/// (`.socket/vendor/redirect-state.json`; v5 never writes one) — no manifest, no vendor /// `state.json`, no `.socket/vendor/...` lockfile references — is a no-op for /// repair, not a `manifest_not_found` error. Hosted redirects point at /// patch.socket.dev URLs and leave no local artifacts to rebuild or sweep, so @@ -177,6 +177,60 @@ fn repair_redirect_only_project_is_informational_no_op() { ); } +/// The v5 shape of a hosted-only project: NO ledger at all, just a lockfile +/// whose entry pins a hosted patch (v5 hosted mode writes only lockfile +/// edits). Repair discovers the pin and takes the same informational +/// `redirect_only_project` skip — never `manifest_not_found` — and writes +/// nothing (no `.socket/` appears). +#[test] +fn repair_hosted_lockfile_pin_only_project_is_informational_no_op() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join("package-lock.json"), + 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": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHED==" + } + } +} +"#, + ) + .unwrap(); + let lock_before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + + let (code, stdout) = run_repair(tmp.path(), &[]); + assert_eq!( + code, 0, + "hosted-pin-only repair must succeed; stdout=\n{stdout}" + ); + let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope JSON"); + assert_eq!(v["status"], "success", "{v}"); + let events = v["events"].as_array().expect("events array"); + assert!( + events + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "redirect_only_project"), + "the hosted pin alone must take the redirect-only skip; got {v}" + ); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_before, + "repair never touches hosted wiring" + ); + assert!( + !tmp.path().join(".socket").exists(), + "a project with nothing to repair never grows .socket/" + ); +} + /// The human (non-JSON) path of the redirect-only no-op: exit 0 with the /// informational message on stdout (not stderr, not an error). #[test] diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index d0b61f89d..ad5978b12 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1483,9 +1483,11 @@ async fn scan_agent_over_vendored_purl_surfaces_run_level_warning() { ); } -/// Write a hosted redirect ledger + a yarn.lock the ledger claims to have -/// edited, whose resolved URL still pins the patch server (the live-wiring -/// proof `redirect_record_live` reads). +/// Write a yarn.lock whose resolved URL pins the Socket patch server — a +/// hosted pin, which is the whole v5 hosted state — plus, when +/// `with_record`, a pre-v5 redirect ledger recording the purl (edits and a +/// record). v5 scan never reads that ledger, so it must not change any +/// verdict: the lock pin alone decides. fn seed_live_hosted_wiring(root: &Path, purl: &str, uuid: &str, with_record: bool) { let hosted_url = format!("https://patch.socket.dev/patch/npm/minimist/1.2.2/tok/{uuid}/minimist-1.2.2.tgz"); @@ -1499,6 +1501,9 @@ fn seed_live_hosted_wiring(root: &Path, purl: &str, uuid: &str, with_record: boo ), ) .unwrap(); + if !with_record { + return; + } let vendor_dir = root.join(".socket/vendor"); std::fs::create_dir_all(&vendor_dir).unwrap(); let mut state = serde_json::json!({ @@ -1581,9 +1586,13 @@ async fn scan_agent_over_live_hosted_wiring_surfaces_run_level_warning() { "must name the migration path: {detail}" ); assert!( - detail.contains("Do not delete"), - "must warn against hand-deleting the ledger (it holds the only \ - revert originals): {detail}" + detail.contains("socket-patch rollback"), + "must name the upstream-restore path (v5 keeps no ledger to \ + protect): {detail}" + ); + assert!( + !detail.contains("ledger"), + "v5 hosted state is the lockfile pin, not a ledger: {detail}" ); assert!( stderr.contains("hosted_wiring_retained"), @@ -1591,13 +1600,11 @@ async fn scan_agent_over_live_hosted_wiring_surfaces_run_level_warning() { ); } -/// Coordination guard (lane B: hosted→vendored pre-revert): once another -/// flow retires the redirect ledger RECORDS for a purl, the agent-flow -/// warning must stay silent — it keys on records still live at scan time, -/// never on leftover `edits` (which are append-only revert data and -/// legitimately outlive the records). +/// v5: the agent-flow warning keys on the lockfile's hosted pin alone — a +/// project with NO redirect ledger at all (what v5 `scan --mode hosted` +/// leaves behind) whose lock still pins the patch server must warn. #[tokio::test] -async fn scan_agent_hosted_warning_silent_once_ledger_records_are_gone() { +async fn scan_agent_hosted_warning_keys_on_the_lock_pin_without_a_ledger() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; @@ -1606,14 +1613,14 @@ async fn scan_agent_hosted_warning_silent_once_ledger_records_are_gone() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2"); - // Ledger with edits but NO records (the post-pre-revert shape) — and - // the lock text still carrying the uuid must not resurrect the warning. + // The lock pin only: no `.socket/` at all. seed_live_hosted_wiring( tmp.path(), purl, AGENT_WARN_UUID, /*with_record=*/ false, ); + assert!(!tmp.path().join(".socket").exists()); let (code, stdout, stderr) = run_scan( tmp.path(), @@ -1622,14 +1629,17 @@ async fn scan_agent_hosted_warning_silent_once_ledger_records_are_gone() { ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let w = find_warning(&v, "hosted_wiring_retained").unwrap_or_else(|| { + panic!("a live lock pin ⇒ hosted_wiring_retained, ledger or not; envelope={v}") + }); assert!( - find_warning(&v, "hosted_wiring_retained").is_none(), - "no ledger records ⇒ no hosted_wiring_retained warning; envelope={v}" + w["detail"].as_str().unwrap_or_default().contains(purl), + "envelope={v}" ); } -/// Registry-clean lock (hosted wiring NOT live) with a leftover record: -/// the live lock is the truth source — no warning. +/// Registry-clean lock (hosted wiring NOT live) with a leftover pre-v5 +/// ledger record: the live lock is the truth source — no warning. #[tokio::test] async fn scan_agent_hosted_warning_silent_when_lock_is_registry_clean() { let mock = MockServer::start().await; @@ -1717,9 +1727,9 @@ async fn report_only_scan_json_surfaces_hosted_redirect_state() { redirectState block; envelope={v}" ); assert_eq!(state["mode"], "hosted", "envelope={v}"); - assert_eq!( - state["ledger"], ".socket/vendor/redirect-state.json", - "the block must name the ledger it reports; envelope={v}" + assert!( + state.get("ledger").is_none() && state.get("ledgerKey").is_none(), + "v5: the block is built from lockfile pins and names no ledger; envelope={v}" ); let records = state["records"].as_array().expect("records array"); assert_eq!(records.len(), 1, "envelope={v}"); @@ -1746,16 +1756,18 @@ async fn report_only_scan_json_surfaces_hosted_redirect_state() { ); } -/// The block keys on ledger RECORDS: an edits-only ledger (the post-takeover -/// / degraded shape) and a ledger-less project both omit it entirely. +/// The block keys on the lockfiles' hosted PINS: a ledger-less project +/// whose lock pins the patch server carries it (v5 hosted mode writes no +/// ledger), and a project with neither a pin nor a ledger omits it. #[tokio::test] -async fn report_only_scan_json_omits_redirect_state_without_ledger_records() { +async fn report_only_scan_json_redirect_state_keys_on_lock_pins() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; - // Edits-only ledger (records retired), live-looking lock text. + // Pin only, no ledger. `--prune` with no mode: the read-only discovery + // envelope (a bare scan is hosted, whose envelope omits the block). let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); write_npm_package(tmp.path(), "minimist", "1.2.2"); @@ -1765,33 +1777,37 @@ async fn report_only_scan_json_omits_redirect_state_without_ledger_records() { AGENT_WARN_UUID, /*with_record=*/ false, ); - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--prune"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert!( - v.get("redirectState").is_none(), - "an edits-only ledger asserts no records ⇒ no redirectState block; \ - envelope={v}" + let state = &v["redirectState"]; + assert_eq!(state["mode"], "hosted", "envelope={v}"); + assert_eq!( + state["records"], + serde_json::json!([{ "purl": purl, "uuid": AGENT_WARN_UUID }]), + "the lock pin is the record; envelope={v}" ); + assert_eq!(state["wiringLive"], serde_json::json!([purl]), "envelope={v}"); - // No ledger at all: the key must stay absent (additive contract). + // No pin, no ledger: the key must stay absent (additive contract). let clean = tempfile::tempdir().expect("tempdir"); write_root_package_json(clean.path()); write_npm_package(clean.path(), "minimist", "1.2.2"); - let (code, stdout, stderr) = run_scan(clean.path(), &mock.uri(), &[]); + let (code, stdout, stderr) = run_scan(clean.path(), &mock.uri(), &["--prune"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert!( v.get("redirectState").is_none(), - "no ledger ⇒ no redirectState block; envelope={v}" + "no hosted pin ⇒ no redirectState block; envelope={v}" ); } -/// Records with a registry-clean lock: the block still lists the records -/// (the ledger is real state) but `wiringLive` is empty — the records/proof -/// split mirrors the agent warning's live-lock gate. +/// A pre-v5 ledger record whose lock entry is back on the registry is NOT +/// hosted state: v5 reads hosted state from the lockfiles only, so the +/// block is omitted (never guessed from ledger presence), and the agent +/// warning stays silent. #[tokio::test] -async fn report_only_scan_json_redirect_state_splits_records_from_live_proof() { +async fn report_only_scan_json_ignores_a_stale_pre_v5_ledger_record() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; @@ -1816,25 +1832,25 @@ async fn report_only_scan_json_redirect_state_splits_records_from_live_proof() { integrity sha512-orig==\n", ) .unwrap(); - - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - let state = &v["redirectState"]; - assert!( - state.is_object(), - "records exist ⇒ block exists; envelope={v}" - ); - assert_eq!( - state["records"].as_array().map(Vec::len), - Some(1), - "envelope={v}" - ); + let ledger_before = std::fs::read(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(); + + for extra in [&["--prune"][..], &["--mode", "agent", "--dry-run"][..]] { + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), extra); + assert_eq!(code, 0, "{extra:?}: stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v.get("redirectState").is_none(), + "{extra:?}: a stale pre-v5 ledger record is not hosted state; envelope={v}" + ); + assert!( + find_warning(&v, "hosted_wiring_retained").is_none(), + "{extra:?}: envelope={v}" + ); + } assert_eq!( - state["wiringLive"].as_array().map(Vec::len), - Some(0), - "registry-clean lock ⇒ empty wiringLive (never guess from ledger \ - presence alone); envelope={v}" + std::fs::read(tmp.path().join(".socket/vendor/redirect-state.json")).unwrap(), + ledger_before, + "scan leaves a pre-v5 ledger byte-identical" ); } @@ -2008,12 +2024,11 @@ async fn vendored_mode_envelopes_omit_redirect_state() { ); } -/// The malformed-ledger degradation warning is advisory, so `--silent` -/// ("errors only") must mute it — on the report-only path like everywhere -/// else. The envelope itself is unchanged either way (no redirectState from -/// a ledger that cannot be read). +/// A malformed pre-v5 redirect ledger is IGNORED by scan (v5 never reads +/// it): no warning on stderr with or without `--silent`, no redirectState, +/// and the file is left byte-identical in place (never quarantined). #[tokio::test] -async fn silent_gates_scan_malformed_ledger_warning() { +async fn scan_ignores_a_malformed_pre_v5_ledger() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; @@ -2026,31 +2041,34 @@ async fn silent_gates_scan_malformed_ledger_warning() { std::fs::create_dir_all(&vendor_dir).unwrap(); std::fs::write(vendor_dir.join("redirect-state.json"), "{ torn ledger").unwrap(); - // Control: without --silent the corruption is surfaced on stderr. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - stderr.contains("malformed"), - "a malformed ledger must be surfaced when not silent: {stderr}" - ); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert!( - v.get("redirectState").is_none(), - "an unreadable ledger asserts nothing; envelope={v}" - ); - - // --silent mutes the advisory warning; the run is otherwise identical. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--silent"]); - assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); - assert!( - !stderr.contains("malformed"), - "--silent must mute the malformed-ledger warning: {stderr}" - ); + for extra in [ + &["--mode", "agent", "--dry-run"][..], + &["--mode", "agent", "--dry-run", "--silent"][..], + &["--prune"][..], + ] { + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), extra); + assert_eq!(code, 0, "{extra:?}: stdout={stdout}; stderr={stderr}"); + assert!( + !stderr.contains("malformed") && !stderr.contains("redirect ledger"), + "{extra:?}: a pre-v5 ledger is never read, so never reported: {stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v.get("redirectState").is_none(), + "{extra:?}: envelope={v}" + ); + assert_eq!( + std::fs::read(vendor_dir.join("redirect-state.json")).unwrap(), + b"{ torn ledger", + "{extra:?}: left in place, byte-identical" + ); + assert!(!vendor_dir.join("redirect-state.json.corrupt").exists()); + } } /// `wiringLive` (like the agent warning) only ever names packages this run /// actually counted: an `--ecosystems` filter that excludes the hosted -/// ecosystem leaves the records listed — the ledger is still real state — +/// ecosystem leaves the records listed — the lock pin is still real state — /// with an EMPTY wiringLive ("purl not crawled/queried this run" is a /// documented silent cause, distinct from "wiring unwound"). This also pins /// the zero-discovery envelope carrying the block at all. @@ -2078,7 +2096,7 @@ async fn ecosystems_filter_keeps_records_but_not_wiring_live() { let state = &v["redirectState"]; assert!( state.is_object(), - "records exist ⇒ the block rides even the filtered/zero-discovery \ + "a pin exists ⇒ the block rides even the filtered/zero-discovery \ envelope; envelope={v}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs b/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs index 4ce81b0c1..e57a7fefb 100644 --- a/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_ordered_concurrency_e2e.rs @@ -842,36 +842,13 @@ async fn mount_hosted(server: &MockServer, failing: &[usize], delay: fn(usize) - } } -/// The ledger with its run timestamps blanked, for a byte comparison. -fn ledger_without_timestamps(root: &Path) -> String { - let raw = std::fs::read_to_string(root.join(".socket/vendor/redirect-state.json")).unwrap(); - let mut v: serde_json::Value = serde_json::from_str(&raw).unwrap(); - fn blank(v: &mut serde_json::Value) { - match v { - serde_json::Value::Object(map) => { - for (k, val) in map.iter_mut() { - let key = k.to_ascii_lowercase(); - if key.ends_with("at") && val.is_string() { - *val = serde_json::Value::String("".into()); - } else { - blank(val); - } - } - } - serde_json::Value::Array(items) => items.iter_mut().for_each(blank), - _ => {} - } - } - blank(&mut v); - serde_json::to_string_pretty(&v).unwrap() -} - /// A wet hosted run where 2 of 6 record views fail, with reversed /// latencies: the `record_fetch_failed` warnings keep `confirmed` order, -/// and stdout, the rewritten lockfile and the ledger all equal a -/// zero-latency run's. +/// stdout and the rewritten lockfile equal a zero-latency run's, and (v5) +/// neither run writes a redirect ledger — a failed record fetch only drops +/// the purl from this run's in-memory VEX records. #[tokio::test] -async fn hosted_record_fetch_failures_keep_order_and_ledger_bytes() { +async fn hosted_record_fetch_failures_keep_order_and_lock_bytes() { let failing = [1usize, 3]; let slow: fn(usize) -> Duration = |i| Duration::from_millis(50 * (6 - i as u64)); let fast: fn(usize) -> Duration = |_| Duration::ZERO; @@ -890,10 +867,16 @@ async fn hosted_record_fetch_failures_keep_order_and_ledger_bytes() { ); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); - outcomes.push((stdout, lock, ledger_without_timestamps(tmp.path()))); + assert!( + !tmp.path() + .join(".socket/vendor/redirect-state.json") + .exists(), + "v5 hosted mode writes no redirect ledger" + ); + outcomes.push((stdout, lock)); } - let (stdout, lock, ledger) = &outcomes[0]; + let (stdout, lock) = &outcomes[0]; let v: serde_json::Value = serde_json::from_str(stdout).unwrap(); let warnings: Vec<&str> = v["redirect"]["warnings"] .as_array() @@ -903,12 +886,19 @@ async fn hosted_record_fetch_failures_keep_order_and_ledger_bytes() { .map(|w| w["detail"].as_str().unwrap()) .collect(); assert_eq!(warnings.len(), 2, "{stdout}"); - assert!(warnings[0].starts_with(&format!("{} redirected", purl(NAMES[1])))); - assert!(warnings[1].starts_with(&format!("{} redirected", purl(NAMES[3])))); + for (warning, idx) in warnings.iter().zip([1usize, 3]) { + assert_eq!( + *warning, + format!( + "{} redirected, but its patch record could not be fetched; this run's VEX \ + attestation omits it (`socket-patch vex` fetches it again once the API \ + answers)", + purl(NAMES[idx]) + ) + ); + } for idx in 0..NAMES.len() { assert!(lock.contains(&hosted_url(idx)), "{lock}"); - let has_record = ledger.contains(&format!("GHSA-conc-{idx:04}-aaaa")); - assert_eq!(has_record, !failing.contains(&idx), "{ledger}"); } assert_eq!(outcomes[0], outcomes[1], "latency must not change the run"); diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs index 859dd7583..fc4c5a38b 100644 --- a/crates/socket-patch-cli/tests/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -967,16 +967,14 @@ async fn scan_delivers_telemetry_before_writing_to_a_closed_stdout() { } } -/// The stderr twin of the closed-stdout test above: a malformed hosted -/// redirect ledger makes a non-hosted scan warn on stderr (the lenient -/// read-only consult) right after the scan event fires, BEFORE any stdout -/// write — so with stderr closed that warning is the run's first -/// SIGPIPE-raising write, and the background send must be flushed ahead of -/// it. The agent preview (`--mode agent --dry-run`) does no network work -/// between the event and the warning, so without the flush the delayed send -/// deterministically loses the race (a bare scan is hosted and reads the -/// ledger strictly, so it is not a case here); the vendored arm is covered -/// too (its warning also precedes `discover_selected`'s flush). +/// The stderr twin of the closed-stdout test above: with stderr closed +/// from the start, the agent preview (`--mode agent --dry-run`) and the +/// vendored arm must still deliver the delayed `patch_scanned` event. +/// Before v5 a malformed hosted redirect ledger made these arms warn on +/// stderr right after the event fired; v5 scan never reads that ledger (a +/// pre-v5 one is ignored), so the project below keeps one to pin that no +/// warning is written in that window at all, and the run still reaches its +/// flush with the send delivered. #[tokio::test] async fn scan_delivers_telemetry_before_writing_to_a_closed_stderr() { const TELEMETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(800); @@ -1006,10 +1004,9 @@ async fn scan_delivers_telemetry_before_writing_to_a_closed_stderr() { let ledger_dir = tmp.path().join(".socket").join("vendor"); std::fs::create_dir_all(&ledger_dir).expect("mkdir .socket/vendor"); std::fs::write(ledger_dir.join("redirect-state.json"), "{ not json") - .expect("write malformed redirect ledger"); + .expect("write malformed pre-v5 redirect ledger"); - // Sanity: with stderr open the run does warn about the ledger, so - // the closed-stderr run below really has a write to die on. + // Sanity: with stderr open the pre-v5 ledger draws no warning. let out = build_cmd_with_token( WELL_SHAPED_TOKEN, tmp.path(), @@ -1022,9 +1019,8 @@ async fn scan_delivers_telemetry_before_writing_to_a_closed_stderr() { .expect("run socket-patch"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.starts_with("Warning: ") && stderr.contains("redirect-state.json"), - "{label}: the malformed redirect ledger's warning must be the \ - run's first stderr write; got: {stderr}" + !stderr.contains("Warning") && !stderr.contains("redirect-state.json"), + "{label}: a pre-v5 redirect ledger is never read by scan; got: {stderr}" ); mock.reset().await; Mock::given(method("POST")) diff --git a/crates/socket-patch-cli/tests/vendor_eject.rs b/crates/socket-patch-cli/tests/vendor_eject.rs new file mode 100644 index 000000000..f6752d916 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor_eject.rs @@ -0,0 +1,360 @@ +//! v5 WS2 eject: standalone `vendor` in a HOSTED project. +//! +//! A hosted project keeps no manifest and no ledger — its patch set is the +//! hosted pins in its lockfiles. A plain `vendor` there EJECTS: each pin's +//! record is fetched from the API (`patches/view/`), vendored into +//! `.socket/vendor/`, and the lock is rewired hosted → vendored (restoring +//! the pin's upstream registry entry first, so `vendor --revert` later lands +//! on the upstream registry entry, not the hosted URL). +//! +//! Every run goes through the built binary with a scrubbed environment: the +//! API, the npm registry (`SOCKET_NPM_REGISTRY`) and the patch-server origin +//! (`SOCKET_PATCH_SERVER_URL`, what makes the mock hosted URL count as +//! hosted) all point at a wiremock; `SOCKET_VENDOR_SOURCE=build` keeps the +//! vendoring service out of it. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const GRANT: &str = "55555555-5555-4555-8555-555555555555"; +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 HOSTED_INTEGRITY: &str = "sha512-HOSTEDpatchedHOSTEDpatched=="; +const UPSTREAM_INTEGRITY: &str = "sha512-UPSTREAMupstreamUPSTREAM=="; + +struct Project { + tmp: tempfile::TempDir, + server: MockServer, +} + +impl Project { + fn root(&self) -> &Path { + self.tmp.path() + } + fn lock_path(&self) -> PathBuf { + self.root().join("package-lock.json") + } + fn lock(&self) -> Value { + serde_json::from_slice(&std::fs::read(self.lock_path()).unwrap()).unwrap() + } + fn lock_bytes(&self) -> Vec { + std::fs::read(self.lock_path()).unwrap() + } + fn hosted_url(&self) -> String { + format!( + "{}/patch/npm/left-pad/1.3.0/{GRANT}/{UUID}/left-pad-1.3.0.tgz", + self.server.uri() + ) + } + fn upstream_tarball(&self) -> String { + format!("{}/left-pad/-/left-pad-1.3.0.tgz", self.server.uri()) + } + fn artifact(&self) -> PathBuf { + self.root() + .join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")) + } + fn redirect_state(&self) -> PathBuf { + self.root().join(".socket/vendor/redirect-state.json") + } + + /// Run the binary against the mocks with every ambient `SOCKET_*` var + /// scrubbed. + fn run(&self, args: &[&str]) -> (i32, String, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args) + .arg("--cwd") + .arg(self.root()) + .current_dir(self.root()); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + let uri = self.server.uri(); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_API_URL", &uri) + .env("SOCKET_API_TOKEN", "fake-token") + .env("SOCKET_ORG_SLUG", ORG) + .env("SOCKET_NPM_REGISTRY", &uri) + .env("SOCKET_PATCH_SERVER_URL", &uri) + .env("SOCKET_VENDOR_SOURCE", "build"); + let out = cmd.output().expect("spawn socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) + } + + fn run_json(&self, args: &[&str]) -> (i32, Value) { + let mut all = args.to_vec(); + all.push("--json"); + let (code, stdout, stderr) = self.run(&all); + let env = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + (code, env) + } +} + +/// A hosted npm project: package-lock.json pins left-pad to the mock hosted +/// tarball (what `scan --mode hosted` leaves behind), the installed copy +/// carries the pristine file, and there is NO `.socket/` at all. +async fn hosted_project(hosted: bool) -> Project { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let project = Project { tmp, server }; + let root = project.root(); + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + 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(); + let (resolved, integrity) = if hosted { + (project.hosted_url(), HOSTED_INTEGRITY.to_string()) + } else { + (project.upstream_tarball(), UPSTREAM_INTEGRITY.to_string()) + }; + 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": resolved, + "integrity": integrity, + "license": "WTFPL" + } + } + }); + let mut bytes = serde_json::to_vec_pretty(&lock).unwrap(); + bytes.push(b'\n'); + std::fs::write(root.join("package-lock.json"), bytes).unwrap(); + project +} + +/// The npm registry's version document for the upstream restore. +async fn mock_registry(p: &Project) { + Mock::given(method("GET")) + .and(path("/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": { + "tarball": p.upstream_tarball(), + "integrity": UPSTREAM_INTEGRITY, + "shasum": "0000000000000000000000000000000000000000" + } + }))) + .mount(&p.server) + .await; +} + +/// `GET patches/view/`: the record, with the patched blob inline. +async fn mock_view(p: &Project) { + let before = compute_git_sha256_from_bytes(ORIG_INDEX); + let after = compute_git_sha256_from_bytes(PATCHED_INDEX); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": before, + "afterHash": after, + "blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX) + } + }, + "vulnerabilities": {}, + "description": "eject fixture", + "license": "MIT", + "tier": "free" + }))) + .mount(&p.server) + .await; +} + +fn find_event<'a>(env: &'a Value, action: &str, code: Option<&str>) -> &'a Value { + env["events"] + .as_array() + .and_then(|events| { + events + .iter() + .find(|e| e["action"] == action && code.is_none_or(|c| e["errorCode"] == c)) + }) + .unwrap_or_else(|| panic!("no `{action}` event (errorCode={code:?}) in:\n{env:#}")) +} + +/// (1) + (2): eject vendors the hosted pin into `.socket/vendor/`, rewires +/// the lock to the vendored artifact, writes no hosted ledger and no +/// manifest; `vendor --revert` then returns the lock to the UPSTREAM +/// registry entry (registry tarball + upstream integrity), not the hosted +/// URL. +#[tokio::test] +async fn eject_vendors_hosted_pins_and_revert_returns_to_upstream() { + let p = hosted_project(true).await; + mock_registry(&p).await; + mock_view(&p).await; + + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "eject must succeed: {env:#}"); + let applied = find_event(&env, "applied", None); + assert_eq!(applied["purl"], PURL, "{env:#}"); + // The eject restores upstream as its own planned step before vendoring, + // so the per-purl takeover warning never fires. + assert!( + !env.to_string().contains("vendor_takeover_reverted_redirect"), + "{env:#}" + ); + assert!( + p.artifact().is_file(), + "the artifact lands in .socket/vendor/" + ); + let lock = p.lock(); + let entry = &lock["packages"]["node_modules/left-pad"]; + let resolved = entry["resolved"].as_str().unwrap_or_default(); + assert!( + resolved.contains(&format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")), + "the lock is wired to the vendored artifact: {lock:#}" + ); + assert!( + !String::from_utf8_lossy(&p.lock_bytes()).contains(&p.hosted_url()), + "no hosted residue: {lock:#}" + ); + assert!(!p.redirect_state().exists(), "no hosted ledger is written"); + assert!( + !p.root().join(".socket/manifest.json").exists(), + "an eject is manifest-free" + ); + let state: Value = + serde_json::from_slice(&std::fs::read(p.root().join(".socket/vendor/state.json")).unwrap()) + .unwrap(); + assert!( + state["entries"].get(PURL).is_some(), + "the vendor ledger tracks the ejected purl: {state:#}" + ); + + // A re-run is a no-op: nothing is hosted any more, so there is nothing + // to eject (the no-manifest path), and the lock stays vendored. + let vendored = p.lock_bytes(); + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["status"], "noManifest", "{env:#}"); + assert_eq!(p.lock_bytes(), vendored); + + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "revert must succeed: {env:#}"); + let lock = p.lock(); + let entry = &lock["packages"]["node_modules/left-pad"]; + assert_eq!( + entry["resolved"], + p.upstream_tarball(), + "revert lands on the upstream registry tarball: {lock:#}" + ); + assert_eq!(entry["integrity"], UPSTREAM_INTEGRITY, "{lock:#}"); + assert!(!p.artifact().exists(), "the artifact is removed"); + assert!(!p.redirect_state().exists()); +} + +/// Human eject: the first line announces the eject. +#[tokio::test] +async fn eject_human_output_announces_the_eject() { + let p = hosted_project(true).await; + mock_registry(&p).await; + mock_view(&p).await; + + let (code, stdout, stderr) = p.run(&["vendor", "--dry-run"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout + .lines() + .next() + .is_some_and(|l| l.starts_with("Would eject 1 hosted package into .socket/vendor/")), + "dry-run first line: {stdout}" + ); + assert!(!p.artifact().exists(), "a dry run vendors nothing"); + + let (code, stdout, stderr) = p.run(&["vendor"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout + .lines() + .next() + .is_some_and(|l| l.starts_with("Ejecting 1 hosted package into .socket/vendor/")), + "first line: {stdout}" + ); + assert!(p.artifact().is_file()); +} + +/// (3): a failed view fetch is a `patch_fetch_failed` failure and exit 1; +/// the hosted pin is left exactly as found. +#[tokio::test] +async fn eject_view_fetch_failure_is_patch_fetch_failed() { + let p = hosted_project(true).await; + mock_registry(&p).await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(500)) + .mount(&p.server) + .await; + let before = p.lock_bytes(); + + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 1, "{env:#}"); + let failed = find_event(&env, "failed", Some("patch_fetch_failed")); + assert_eq!(failed["purl"], PURL, "{env:#}"); + assert_eq!(p.lock_bytes(), before, "the hosted pin stays as found"); + assert!(!p.artifact().exists(), "nothing is vendored"); + assert!(!p.redirect_state().exists()); +} + +/// (4): no manifest and no hosted pins — the old calm no-op: exit 0, +/// `noManifest`, nothing written, no API call. +#[tokio::test] +async fn no_manifest_and_no_hosted_pins_is_a_noop() { + let p = hosted_project(false).await; + let before = p.lock_bytes(); + + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "{env:#}"); + assert_eq!(env["status"], "noManifest", "{env:#}"); + assert_eq!(p.lock_bytes(), before); + assert!(!p.root().join(".socket").exists(), "nothing is created"); + assert!( + p.server + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "the no-op never talks to the API or the registry" + ); + + let (code, stdout, stderr) = p.run(&["vendor"]); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("No manifest found, nothing to vendor."), + "{stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs new file mode 100644 index 000000000..0d64308e2 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor_eject_bun_lockb.rs @@ -0,0 +1,391 @@ +//! v5 vendor over a HOSTED binary `bun.lockb` (Bun <= 1.1.x's default lock, +//! and the legacy lock Bun 1.2 keeps reading). +//! +//! Hosted mode keeps no ledger, so vendoring over a hosted pin first +//! restores the pin's upstream registry entry — for a binary lock, the +//! native codec rebuilds Bun's npm registry record from the registry's +//! `dist.tarball` / `dist.integrity`. Both entry points are covered: the +//! per-purl takeover (`vendor` with the patch record staged) and the eject +//! (`vendor` in a manifest-less hosted project). Each vendors, records the +//! REGISTRY record as the vendor ledger's pre-vendor original, and +//! `vendor --revert` returns the exact pre-hosted bytes (a format-1 lock the +//! hosted rewrite promoted is demoted back; a lock whose workspace behaviors +//! it normalized is refused with the checkout remedy). `rollback` of the +//! hosted pin still refuses with the `git checkout -- bun.lockb` remedy, as +//! does an offline vendor (the registry cannot be asked). +//! +//! The locks are real Bun-written fixtures +//! (`socket-patch-core/tests/fixtures/bun-lockb`), wired hosted by the +//! production binary rewriter; no `bun` binary is needed. Every run goes +//! through the built binary with a scrubbed environment: the API, the npm +//! registry (`SOCKET_NPM_REGISTRY`) and the patch-server origin +//! (`SOCKET_PATCH_SERVER_URL`) all point at a wiremock. + +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use socket_patch_core::patch::redirect::{rewrite_bun_binary, DepOverride, RewriteResult}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const GRANT: &str = "55555555-5555-4555-8555-555555555555"; +const PURL: &str = "pkg:npm/minimist@1.2.2"; +const ORIG_INDEX: &[u8] = b"module.exports = () => 'orig';\n"; +const PATCHED_INDEX: &[u8] = b"module.exports = () => 'patched';\n"; +/// The public registry's `dist` for minimist@1.2.2 — what every fixture pins. +const UPSTREAM_TARBALL: &str = "https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz"; +const UPSTREAM_INTEGRITY: &str = + "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="; + +fn fixture(dir: &str, file: &str) -> Vec { + std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../socket-patch-core/tests/fixtures/bun-lockb") + .join(dir) + .join(file), + ) + .unwrap() +} + +struct Project { + tmp: tempfile::TempDir, + server: MockServer, + /// The Bun-written lock before the hosted rewrite. + pristine: Vec, +} + +impl Project { + fn root(&self) -> &Path { + self.tmp.path() + } + + fn lock(&self) -> Vec { + assert!( + !self.root().join("bun.lock").exists(), + "the CLI must never write a text bun.lock" + ); + std::fs::read(self.root().join("bun.lockb")).unwrap() + } + + fn hosted_url(&self) -> String { + format!( + "{}/patch/npm/minimist/1.2.2/{GRANT}/{UUID}/minimist-1.2.2.tgz", + self.server.uri() + ) + } + + fn run_json(&self, args: &[&str]) -> (i32, Value) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args) + .args(["--json", "--cwd"]) + .arg(self.root()) + .current_dir(self.root()); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + let uri = self.server.uri(); + cmd.env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_API_URL", &uri) + .env("SOCKET_API_TOKEN", "fake-token") + .env("SOCKET_ORG_SLUG", ORG) + .env("SOCKET_NPM_REGISTRY", &uri) + .env("SOCKET_PATCH_SERVER_URL", &uri) + .env("SOCKET_VENDOR_SOURCE", "build"); + let out = cmd.output().expect("spawn socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let env = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), env) + } +} + +fn record() -> Value { + json!({ + "uuid": UUID, + "purl": PURL, + "exportedAt": "2026-01-01T00:00:00Z", + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(ORIG_INDEX), + "afterHash": compute_git_sha256_from_bytes(PATCHED_INDEX), + } + }, + "vulnerabilities": {}, + "description": "binary lock takeover fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// A project whose Bun-`writer`-written bun.lockb pins minimist@1.2.2 to the +/// mock hosted tarball (what `scan --mode hosted` leaves behind), with the +/// pristine package installed and the registry + record view mocked. +async fn hosted_project(writer: &str) -> Project { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let project = Project { + tmp, + server, + pristine: fixture(writer, "bun.lockb"), + }; + let root = project.root(); + std::fs::write(root.join("package.json"), fixture(writer, "package.json")).unwrap(); + for (name, version) in [("minimist", "1.2.2"), ("is-number", "7.0.0")] { + let pkg = root.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{"name":"{name}","version":"{version}"}}"#), + ) + .unwrap(); + std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap(); + } + let dep: DepOverride = serde_json::from_value(json!({ + "ecosystem": "npm", + "name": "minimist", + "version": "1.2.2", + "token": GRANT, + "patchUuid": UUID, + "artifactUrl": project.hosted_url(), + "integrity": { "sha512": format!( + "sha512-{}", base64::engine::general_purpose::STANDARD.encode([42u8; 64])) } + })) + .unwrap(); + let mut rewrite = RewriteResult::default(); + rewrite_bun_binary(&project.pristine, &[dep], &mut rewrite); + assert!(rewrite.warnings.is_empty(), "{:?}", rewrite.warnings); + std::fs::write(root.join("bun.lockb"), &rewrite.binary_files["bun.lockb"]).unwrap(); + + Mock::given(method("GET")) + .and(path("/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "minimist", + "version": "1.2.2", + "dist": { "tarball": UPSTREAM_TARBALL, "integrity": UPSTREAM_INTEGRITY } + }))) + .mount(&project.server) + .await; + let mut view = record(); + view["files"]["package/index.js"]["blobContent"] = + json!(base64::engine::general_purpose::STANDARD.encode(PATCHED_INDEX)); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(&project.server) + .await; + project +} + +/// `.socket/manifest.json` + the after-hash blob: the per-purl takeover +/// (instead of the manifest-less eject). +fn stage_record(root: &Path) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ "patches": { PURL: record() } })).unwrap(), + ) + .unwrap(); + std::fs::write( + socket + .join("blobs") + .join(compute_git_sha256_from_bytes(PATCHED_INDEX)), + PATCHED_INDEX, + ) + .unwrap(); +} + +fn codes(env: &Value) -> Vec { + let mut out = Vec::new(); + for key in ["events", "warnings"] { + for e in env[key].as_array().into_iter().flatten() { + for field in ["errorCode", "code"] { + if let Some(c) = e[field].as_str() { + out.push(c.to_string()); + } + } + } + } + out +} + +/// The vendored project's shared assertions: the lock is wired to the +/// committed artifact with no hosted residue, and the vendor ledger's +/// recorded original is the REGISTRY record. +fn assert_vendored(p: &Project, env: &Value) { + let lock = p.lock(); + let text = String::from_utf8_lossy(&lock); + assert!( + text.contains(&format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz")), + "bun.lockb is wired to the vendored artifact: {env:#}" + ); + assert!( + !text.contains(&p.server.uri()) && !text.contains(GRANT), + "no hosted residue is left in bun.lockb" + ); + assert!(p + .root() + .join(format!(".socket/vendor/npm/{UUID}/minimist-1.2.2.tgz")) + .is_file()); + let state: Value = + serde_json::from_slice(&std::fs::read(p.root().join(".socket/vendor/state.json")).unwrap()) + .unwrap(); + let original = state["entries"][PURL]["wiring"] + .as_array() + .and_then(|w| w.iter().find(|r| r["kind"] == "bun_lockb_package")) + .map(|r| r["original"].clone()) + .unwrap_or_else(|| panic!("bun_lockb_package wiring: {state:#}")); + assert_eq!(original["name"], "minimist", "{original}"); + assert_eq!(original["version"], "1.2.2", "{original}"); + assert_eq!(original["resolution"], UPSTREAM_TARBALL, "{original}"); + assert_eq!(original["integrity"], UPSTREAM_INTEGRITY, "{original}"); +} + +/// Bun 0.1.1 / 0.1.6 (binary format 1, which the hosted rewrite promotes to +/// format 2 and the takeover's restore demotes again), 0.8.1 (uninitialized +/// record padding), 1.1.38 (the last binary-only writer) and 1.2.0 (the +/// legacy lock Bun 1.2 keeps): the takeover vendors over the hosted pin, and +/// the revert gives back the pre-hosted bytes. +#[tokio::test] +async fn takeover_vendors_over_a_hosted_bun_lockb_and_reverts_exactly() { + for writer in ["0.1.1", "0.1.6", "0.8.1", "1.1.38", "1.2.0"] { + let p = hosted_project(writer).await; + stage_record(p.root()); + let hosted = p.lock(); + + let (code, env) = p.run_json(&["vendor", "--dry-run"]); + assert_eq!(code, 0, "{writer}: dry run: {env:#}"); + assert!( + codes(&env) + .iter() + .any(|c| c == "vendor_would_revert_redirect"), + "{writer}: {env:#}" + ); + assert_eq!(p.lock(), hosted, "{writer}: a dry run writes nothing"); + + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "{writer}: vendor over the hosted pin: {env:#}"); + assert_eq!(env["status"], "success", "{writer}: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{writer}: {env:#}"); + let codes = codes(&env); + assert!( + codes + .iter() + .any(|c| c == "vendor_takeover_reverted_redirect"), + "{writer}: {env:#}" + ); + assert!( + !codes.iter().any(|c| c == "redirect_revert_failed"), + "{writer}: {env:#}" + ); + assert_vendored(&p, &env); + + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "{writer}: revert: {env:#}"); + assert!( + p.lock() == p.pristine, + "{writer}: the revert restores the pre-hosted bun.lockb byte for byte" + ); + } +} + +/// The manifest-less eject over a hosted bun.lockb. +#[tokio::test] +async fn eject_vendors_a_hosted_bun_lockb_and_reverts_exactly() { + let p = hosted_project("1.1.38").await; + let (code, env) = p.run_json(&["vendor"]); + assert_eq!(code, 0, "eject must succeed: {env:#}"); + assert_eq!(env["summary"]["applied"], 1, "{env:#}"); + assert_vendored(&p, &env); + assert!(!p.root().join(".socket/manifest.json").exists()); + let (code, env) = p.run_json(&["vendor", "--revert"]); + assert_eq!(code, 0, "revert: {env:#}"); + assert!(p.lock() == p.pristine, "exact pre-hosted bytes"); +} + +/// A hosted lock whose workspace dependency behaviors the rewrite had to +/// normalize cannot be given back byte for byte: the takeover refuses with +/// the checkout remedy (dry and wet alike) and writes nothing. +#[tokio::test] +async fn takeover_refuses_a_workspace_normalized_hosted_bun_lockb() { + let p = hosted_project("1.1.45-extensions").await; + stage_record(p.root()); + let hosted = p.lock(); + for extra in [&["--dry-run"][..], &[][..]] { + let mut args = vec!["vendor"]; + args.extend_from_slice(extra); + let (code, env) = p.run_json(&args); + assert_eq!(code, 1, "{extra:?}: {env:#}"); + let refused = env["events"] + .as_array() + .and_then(|events| { + events + .iter() + .find(|e| e["errorCode"] == "redirect_revert_failed") + }) + .unwrap_or_else(|| panic!("expected redirect_revert_failed: {env:#}")); + assert!( + refused["error"].as_str().is_some_and(|e| e + .contains("workspace dependency behaviors") + && e.contains("git checkout -- bun.lockb")), + "{env:#}" + ); + assert_eq!(p.lock(), hosted, "{extra:?}: a refused vendor writes nothing"); + assert!(!p.root().join(".socket/vendor").exists()); + } +} + +/// `rollback` of the hosted pin, and a vendor that cannot reach the +/// registry, refuse with the checkout remedy and write nothing. +#[tokio::test] +async fn rollback_and_offline_vendor_refuse_with_the_checkout_remedy() { + let p = hosted_project("1.1.38").await; + stage_record(p.root()); + let hosted = p.lock(); + + let (code, env) = p.run_json(&["vendor", "--offline"]); + assert_eq!(code, 1, "{env:#}"); + let refused = env["events"] + .as_array() + .and_then(|events| { + events + .iter() + .find(|e| e["errorCode"] == "redirect_revert_failed") + }) + .unwrap_or_else(|| panic!("expected redirect_revert_failed: {env:#}")); + assert!( + refused["error"] + .as_str() + .is_some_and(|e| e.contains("offline") && e.contains("git checkout -- bun.lockb")), + "{env:#}" + ); + assert_eq!(p.lock(), hosted, "a refused vendor writes nothing"); + assert!(!p.root().join(".socket/vendor").exists()); + + let (code, env) = p.run_json(&["rollback", "--yes"]); + assert_eq!(code, 1, "{env:#}"); + let failed = env["hosted"]["failed"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!( + failed.iter().any(|f| f["purl"] == PURL + && f["error"] + .as_str() + .is_some_and(|e| e.contains("git checkout -- bun.lockb"))), + "{env:#}" + ); + assert_eq!(p.lock(), hosted, "a refused rollback writes nothing"); +} diff --git a/crates/socket-patch-cli/tests/vendor_eject_fresh_checkout.rs b/crates/socket-patch-cli/tests/vendor_eject_fresh_checkout.rs new file mode 100644 index 000000000..792854c44 --- /dev/null +++ b/crates/socket-patch-cli/tests/vendor_eject_fresh_checkout.rs @@ -0,0 +1,358 @@ +//! v5 WS2 eject from a FRESH hosted checkout: only the committed manifests +//! and lockfiles exist — no `node_modules`, no Cargo home, no `.socket/`. +//! +//! The eject is lockfile-only by contract: it restores each hosted pin's +//! upstream registry entry first, so the vendor engine sees an ordinary +//! registry pin with a registry checksum and fetches the pristine source +//! from the registry (verified against that checksum) instead of requiring +//! an installed tree. Every registry and API endpoint is a wiremock. + +use std::io::Write as _; +use std::path::Path; +use std::process::Command; + +use base64::Engine as _; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256, Sha512}; +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG: &str = "test-org"; + +/// A tar.gz with every file under a single `{prefix}/` top-level dir (an +/// npm tarball uses `package/`, a `.crate` uses `{name}-{version}/`). +fn tgz(prefix: &str, files: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + for (rel, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, format!("{prefix}/{rel}"), *content) + .unwrap(); + } + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(&builder.into_inner().unwrap()).unwrap(); + enc.finish().unwrap() +} + +async fn mock_view(server: &MockServer, uuid: &str, purl: &str, file: &str, orig: &[u8], patched: &[u8]) { + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{uuid}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + file: { + "beforeHash": compute_git_sha256_from_bytes(orig), + "afterHash": compute_git_sha256_from_bytes(patched), + "blobContent": base64::engine::general_purpose::STANDARD.encode(patched) + } + }, + "vulnerabilities": {}, + "description": "eject fixture", + "license": "MIT", + "tier": "free" + }))) + .mount(server) + .await; +} + +/// Run the binary with every ambient `SOCKET_*` var scrubbed, an empty +/// `CARGO_HOME`, and the API / registries / patch origin on `server`. +fn run_json(root: &Path, server: &MockServer, args: &[&str]) -> (i32, Value) { + run_json_with(root, server, args, &[]) +} + +fn run_json_with( + root: &Path, + server: &MockServer, + args: &[&str], + extra: &[(&str, String)], +) -> (i32, Value) { + let cargo_home = root.join("../cargo-home"); + std::fs::create_dir_all(&cargo_home).unwrap(); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.args(args) + .arg("--json") + .arg("--cwd") + .arg(root) + .current_dir(root); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + let uri = server.uri(); + let out = cmd + .env("SOCKET_TELEMETRY_DISABLED", "1") + .env("SOCKET_API_URL", &uri) + .env("SOCKET_API_TOKEN", "fake-token") + .env("SOCKET_ORG_SLUG", ORG) + .env("SOCKET_NPM_REGISTRY", &uri) + .env("SOCKET_CRATES_INDEX", format!("{uri}/index")) + .env("SOCKET_CRATES_REGISTRY", format!("{uri}/crates")) + .env("SOCKET_PATCH_SERVER_URL", &uri) + .env("SOCKET_VENDOR_SOURCE", "build") + .env("CARGO_HOME", &cargo_home) + .envs(extra.iter().map(|(k, v)| (*k, v.as_str()))) + .output() + .expect("spawn socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let env = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( + "--json must emit an envelope: {e}\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }); + (out.status.code().unwrap_or(-1), env) +} + +fn applied(env: &Value, purl: &str) -> bool { + env["events"] + .as_array() + .is_some_and(|events| events.iter().any(|e| e["action"] == "applied" && e["purl"] == purl)) +} + +/// npm: a hosted package-lock.json with NO `node_modules`. The pristine +/// tarball comes from the registry the restored entry names. +#[tokio::test] +async fn npm_eject_needs_no_installed_tree() { + const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; + const PURL: &str = "pkg:npm/left-pad@1.3.0"; + const ORIG: &[u8] = b"module.exports = () => 'orig';\n"; + const PATCHED: &[u8] = b"module.exports = () => 'patched';\n"; + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + + let tarball = tgz( + "package", + &[ + ("package.json", br#"{"name":"left-pad","version":"1.3.0"}"#), + ("index.js", ORIG), + ], + ); + let integrity = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(&tarball)) + ); + let upstream = format!("{}/left-pad/-/left-pad-1.3.0.tgz", server.uri()); + Mock::given(method("GET")) + .and(path("/left-pad/1.3.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "name": "left-pad", + "version": "1.3.0", + "dist": { "tarball": upstream, "integrity": integrity } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/left-pad/-/left-pad-1.3.0.tgz")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(tarball)) + .mount(&server) + .await; + mock_view(&server, UUID, PURL, "package/index.js", ORIG, PATCHED).await; + + std::fs::write( + root.join("package.json"), + br#"{"name":"fixture","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}"#, + ) + .unwrap(); + let hosted = format!( + "{}/patch/npm/left-pad/1.3.0/55555555-5555-4555-8555-555555555555/{UUID}/left-pad-1.3.0.tgz", + server.uri() + ); + 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": hosted, + "integrity": "sha512-HOSTEDpatchedHOSTEDpatched==", "license": "WTFPL" + } + } + }); + std::fs::write( + root.join("package-lock.json"), + serde_json::to_string_pretty(&lock).unwrap() + "\n", + ) + .unwrap(); + assert!(!root.join("node_modules").exists()); + + let (code, env) = run_json(&root, &server, &["vendor"]); + assert_eq!(code, 0, "a fresh hosted checkout ejects: {env:#}"); + assert!(applied(&env, PURL), "{env:#}"); + let artifact = root.join(format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")); + assert!(artifact.is_file(), "the artifact lands in .socket/vendor/"); + let lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap(); + assert!(lock.contains(&format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz")), "{lock}"); + assert!(!lock.contains(&hosted), "no hosted residue: {lock}"); +} + +/// cargo: the hosted `Cargo.lock` / `Cargo.toml` pin with an EMPTY Cargo +/// home. The restore re-resolves the crates.io checksum from the sparse +/// index; the pristine `.crate` is downloaded and verified against it. +#[tokio::test] +async fn cargo_eject_needs_no_cargo_home() { + const UUID: &str = "55555555-5555-5555-5555-555555555555"; + const TOKEN: &str = "11111111-1111-1111-1111-111111111111"; + const PURL: &str = "pkg:cargo/serde@1.0.190"; + const ORIG: &[u8] = b"pub fn serde() {}\n"; + const PATCHED: &[u8] = b"pub fn serde() { /* patched */ }\n"; + const TOML: &[u8] = b"[package]\nname = \"serde\"\nversion = \"1.0.190\"\n"; + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + + let krate = tgz("serde-1.0.190", &[("Cargo.toml", TOML), ("src/lib.rs", ORIG)]); + let checksum = hex::encode(Sha256::digest(&krate)); + Mock::given(method("GET")) + .and(path("/index/se/rd/serde")) + .respond_with(ResponseTemplate::new(200).set_body_string( + json!({"name": "serde", "vers": "1.0.190", "cksum": checksum}).to_string(), + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/crates/serde/serde-1.0.190.crate")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(krate)) + .mount(&server) + .await; + mock_view(&server, UUID, PURL, "package/src/lib.rs", ORIG, PATCHED).await; + + // What `scan --mode hosted` leaves behind (the redirect golden + // `cargo/cargo/basic/expected`, minus the unrelated anyhow dep). + std::fs::write( + root.join("Cargo.toml"), + format!( + "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\ + [dependencies]\nserde = {{ version = \"1.0.190\", registry = \"socket-patch-{UUID}\" }}\n" + ), + ) + .unwrap(); + let index = format!("sparse+https://patch.socket.dev/patch-registry/cargo/{TOKEN}/{UUID}/index/"); + std::fs::write( + root.join("Cargo.lock"), + format!( + "version = 3\n\n[[package]]\nname = \"myapp\"\nversion = \"0.1.0\"\n\ + dependencies = [\n \"serde\",\n]\n\n[[package]]\nname = \"serde\"\n\ + version = \"1.0.190\"\nsource = \"{index}\"\nchecksum = \"{}\"\n", + "de".repeat(32) + ), + ) + .unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let (code, env) = run_json(&root, &server, &["vendor"]); + assert_eq!(code, 0, "a fresh hosted cargo checkout ejects: {env:#}"); + assert!(applied(&env, PURL), "{env:#}"); + let toml = std::fs::read_to_string(root.join("Cargo.toml")).unwrap(); + let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); + assert!(!toml.contains("socket-patch-"), "hosted registry key removed: {toml}"); + assert!(!lock.contains("patch.socket.dev"), "no hosted residue: {lock}"); + assert!( + std::fs::read_dir(root.join(".socket/vendor/cargo")) + .map(|mut d| d.next().is_some()) + .unwrap_or(false), + "the crate is vendored under .socket/vendor/cargo" + ); +} + +/// A pure-Python wheel: the unzipped layout is site-packages. +fn wheel(files: &[(&str, &[u8])]) -> Vec { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (name, content) in files { + zip.start_file(*name, opts).unwrap(); + zip.write_all(content).unwrap(); + } + zip.finish().unwrap().into_inner() +} + +/// pypi: a hash-pinned hosted `requirements.txt` with NO virtualenv. The +/// restore re-resolves the release file hash from the PyPI JSON API; the +/// pristine wheel is downloaded and verified against it. +#[tokio::test] +async fn pypi_eject_needs_no_virtualenv() { + const UUID: &str = "2b1f6c1e-8d3a-4f6b-9c2d-7e5a9b1c3d11"; + const PURL: &str = "pkg:pypi/six@1.16.0"; + const WHEEL: &str = "six-1.16.0-py2.py3-none-any.whl"; + const ORIG: &[u8] = b"# six\nVERSION = '1.16.0'\n"; + const PATCHED: &[u8] = b"# six\nVERSION = '1.16.0'\nSAFE = True\n"; + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("proj"); + std::fs::create_dir_all(&root).unwrap(); + + let whl = wheel(&[ + ("six.py", ORIG), + ( + "six-1.16.0.dist-info/METADATA", + b"Metadata-Version: 2.1\nName: six\nVersion: 1.16.0\n\nbody\n", + ), + ( + "six-1.16.0.dist-info/WHEEL", + b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py2-none-any\nTag: py3-none-any\n", + ), + ( + "six-1.16.0.dist-info/RECORD", + b"six.py,,\nsix-1.16.0.dist-info/METADATA,,\nsix-1.16.0.dist-info/WHEEL,,\nsix-1.16.0.dist-info/RECORD,,\n", + ), + ]); + let sha = hex::encode(Sha256::digest(&whl)); + let size = whl.len(); + Mock::given(method("GET")) + .and(path("/pypi/six/1.16.0/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "urls": [{ + "filename": WHEEL, + "url": format!("{}/files/{WHEEL}", server.uri()), + "digests": { "sha256": sha }, + "size": size, + "upload_time_iso_8601": "2021-05-05T14:18:17.000000Z" + }] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/files/{WHEEL}"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(whl)) + .mount(&server) + .await; + mock_view(&server, UUID, PURL, "six.py", ORIG, PATCHED).await; + + let hosted = format!( + "https://patch.socket.dev/patch/pypi/six/1.16.0/11111111-1111-1111-1111-111111111111/{UUID}/{WHEEL}" + ); + std::fs::write( + root.join("requirements.txt"), + // An unpatched hash-pinned sibling: it makes pip's hash-checking + // mode derivable, so the hosted line can be restored as `==` + hash. + format!( + "flask==2.0.1 --hash=sha256:{}\nsix @ {hosted} --hash=sha256:{}\n", + "cd".repeat(32), + "ab".repeat(32) + ), + ) + .unwrap(); + + let (code, env) = run_json_with(&root, &server, &["vendor"], &[( + "SOCKET_PYPI_JSON_API", + format!("{}/pypi", server.uri()), + )]); + assert_eq!(code, 0, "a fresh hosted pypi checkout ejects: {env:#}"); + assert!(applied(&env, PURL), "{env:#}"); + let reqs = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); + assert!(!reqs.contains("patch.socket.dev"), "no hosted residue: {reqs}"); + assert!( + reqs.contains(&format!(".socket/vendor/pypi/{UUID}/")), + "the requirement is wired to the vendored wheel: {reqs}" + ); +} diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/bun.rs b/crates/socket-patch-cli/tests/vex_e2e_common/bun.rs index 6b4954866..f94646f76 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/bun.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/bun.rs @@ -21,15 +21,17 @@ //! `(vendored)` marker and exactly the expected vulnerability ids (+ CVE //! aliases); the embedded `apply --vex` (and, vendored, `vendor --vex`) //! attest the same without touching the lock. -//! 2. `ledgers-deleted` — `.socket/vendor/state.json` and -//! `redirect-state.json` deleted too: still attested, now from lockfile -//! discovery + the patch API (the view route is hit). +//! 2. `ledgers-deleted` — `.socket/vendor/state.json` deleted too (a hosted +//! flow wrote no ledger at all in v5 — asserted up front): still +//! attested, now from lockfile discovery + the patch API (the view route +//! is hit). //! 3. `offline` — `--offline` with no ledgers: `record_unavailable`, exit 1, //! ZERO requests to the API. //! 4. `reverted` — the lock put back to the registry version (ledgers and //! committed artifacts kept, the patched install left in node_modules): -//! NOT attested (`redirect_unwired` / `vendor_unwired`), also under -//! `--no-verify`. +//! NOT attested, also under `--no-verify` — vendored: `vendor_unwired`; +//! hosted: nothing is discovered (exit 2 `manifest_not_found`), since the +//! lock was the only hosted state. //! //! Each passed step prints `BUN-VEX ok` so a matrix log //! reads as a per-version results table. @@ -61,7 +63,8 @@ impl BunMode { } /// The omission a ledger-backed record gets once the lock no longer - /// references it. + /// references it (hosted: only a PRE-v5 redirect ledger still earns + /// it; v5 hosted flows write no ledger). pub fn unwired_reason(self) -> &'static str { match self { BunMode::Hosted => "redirect_unwired", @@ -200,6 +203,15 @@ fn matrix(project: &Path, scratch: &Path, case: &BunVexCase<'_ let what = format!("{} {}", case.tag, mode.label()); let ok = |step: &str| eprintln!("BUN-VEX {} {} {step} ok", case.tag, mode.label()); let checkout = manifestless_checkout(project, &scratch.join(format!("vex-{}", case.tag))); + match mode { + BunMode::Hosted => assert_no_hosted_ledger(&checkout, &what), + BunMode::Vendored => assert!( + checkout + .join(socket_patch_core::vendor::VENDOR_STATE_REL) + .is_file(), + "{what}: the vendored flow must have written its ledger" + ), + } install(&checkout); let lock_path = checkout.join(case.lock); let wired_lock = std::fs::read(&lock_path).unwrap(); @@ -296,10 +308,6 @@ fn matrix(project: &Path, scratch: &Path, case: &BunVexCase<'_ std::fs::write(path, bytes).unwrap(); } } - assert!( - ledgers.iter().any(|(_, b)| b.is_some()), - "{what}: the flow left no ledger — the reverted step would be vacuous" - ); std::fs::write(&lock_path, &case.registry_lock).unwrap(); for no_verify in [false, true] { let run = VexRun { @@ -307,12 +315,27 @@ fn matrix(project: &Path, scratch: &Path, case: &BunVexCase<'_ ..online() }; let out = run_vex(&bin, &checkout, &run); - assert_eq!( - out.code, - Some(1), - "{what} reverted (no_verify={no_verify}): {out}" - ); - assert_skipped(&out.envelope, case.purl, mode.unwired_reason()); + match mode { + BunMode::Vendored => { + assert_eq!( + out.code, + Some(1), + "{what} reverted (no_verify={no_verify}): {out}" + ); + assert_skipped(&out.envelope, case.purl, mode.unwired_reason()); + } + BunMode::Hosted => { + assert_eq!( + out.code, + Some(2), + "{what} reverted (no_verify={no_verify}): {out}" + ); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{what} reverted (no_verify={no_verify}): no hosted state is left: {out}" + ); + } + } assert_absent(out.doc.as_ref(), case.purl); } ok("reverted"); diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs index dfb2aea76..5d0f27e92 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs @@ -130,15 +130,68 @@ pub fn seed_legacy_manifest(project: &Path) -> usize { seeded } -/// Delete both ledgers — `.socket/vendor/state.json` (vendor) and -/// `.socket/vendor/redirect-state.json` (hosted) — so the lockfile wiring -/// (+ committed `.socket/vendor///` artifacts) is the ONLY -/// evidence left. Artifacts are kept. +/// Delete both ledgers — `.socket/vendor/state.json` (vendor) and a PRE-v5 +/// `.socket/vendor/redirect-state.json` (v5 hosted mode writes none) — so +/// the lockfile wiring (+ committed `.socket/vendor///` +/// artifacts) is the ONLY evidence left. Artifacts are kept. pub fn strip_ledgers(project: &Path) { remove_if_present(&project.join(socket_patch_core::vendor::VENDOR_STATE_REL)); remove_if_present(&project.join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL)); } +/// The pre-v5 hosted ledger's path, relative to the project root. v5 +/// hosted mode never writes it; it is only read, as an extra local record +/// source, when a checkout still commits one. +pub const LEGACY_REDIRECT_LEDGER: &str = ".socket/vendor/redirect-state.json"; + +/// Panic if a hosted run left a `.socket/vendor/redirect-state.json` +/// behind: v5 hosted mode keeps its state in the lockfiles only. +pub fn assert_no_hosted_ledger(project: &Path, what: &str) { + assert!( + !project.join(LEGACY_REDIRECT_LEDGER).exists(), + "{what}: v5 hosted mode writes no {LEGACY_REDIRECT_LEDGER}" + ); +} + +/// Commit a PRE-v5 hosted ledger (`.socket/vendor/redirect-state.json`) +/// recording `purl` → the patch record carried by `view` (a +/// [`patch_view`]-shaped API body) — the shape a checkout wired by an +/// older socket-patch still carries. v5 reads it only as an extra local +/// record source (so a hosted pin it describes attests offline); it never +/// writes one. +pub fn write_legacy_redirect_ledger(project: &Path, purl: &str, view: &Value) { + let files: serde_json::Map = view["files"] + .as_object() + .into_iter() + .flatten() + .map(|(k, f)| { + ( + k.clone(), + serde_json::json!({ + "beforeHash": f["beforeHash"], + "afterHash": f["afterHash"], + }), + ) + }) + .collect(); + let record: socket_patch_core::manifest::schema::PatchRecord = + serde_json::from_value(serde_json::json!({ + "uuid": view["uuid"], + "exportedAt": "2026-03-27T00:00:00Z", + "files": files, + "vulnerabilities": view["vulnerabilities"], + "description": view["description"], + "license": view["license"], + "tier": view["tier"], + })) + .expect("the view converts to a patch record"); + let mut state = socket_patch_core::patch::redirect::RedirectState::new(); + state.records.insert(purl.to_string(), record); + let path = project.join(LEGACY_REDIRECT_LEDGER); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_string_pretty(&state).unwrap()).unwrap(); +} + fn remove_if_present(path: &Path) { match std::fs::remove_file(path) { Ok(()) => {} diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs index a21d8a20a..df5ff53a3 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs @@ -49,13 +49,16 @@ //! vendored: `--offline` where the release allows) and prove the PATCHED //! bytes are what Python imports; //! 4. manifest-less VEX there: (a) standalone `vex` attests the purl with -//! the right marker and vulnerability ids; (b) with both ledgers deleted -//! it still attests from lockfile discovery + the API record; (c) -//! `--offline` without ledgers is `record_unavailable` with ZERO requests; -//! (d) the embedded `apply --vex` (+ `vendor --vex` / `scan --redirect -//! --vex`) attest too; (e) the wiring reverted to the registry files with -//! the ledgers and artifacts left behind, reinstalled pristine by uv, is -//! NOT attested — verified or `--no-verify`, online or offline; +//! the right marker and vulnerability ids; (b) with the vendor ledger +//! deleted (a hosted flow writes none in v5 — asserted) it still attests +//! from lockfile discovery + the API record; (c) `--offline` without +//! ledgers is `record_unavailable` with ZERO requests; (d) the embedded +//! `apply --vex` (+ `vendor --vex` / `scan --redirect --vex`) attest too +//! (hosted: online, there is no local record); (e) the wiring reverted to +//! the registry files with the ledgers and artifacts left behind, +//! reinstalled pristine by uv, is NOT attested — verified or +//! `--no-verify`, online or offline (vendored: `vendor_unwired`; hosted: +//! nothing is discovered at all); //! 5. the lanes with project metadata also run a plain (re-resolving) `uv //! sync` — for a transitive target this is the 0.5.6 boundary: older uv //! re-resolves the override against the registry, reinstalls the @@ -447,7 +450,12 @@ impl Report<'_> { // ── filesystem ───────────────────────────────────────────────────────── +/// Copy `src` recursively to `dst`; a missing `src` copies nothing (a +/// hosted flow commits no `.socket/` in v5). pub fn copy_tree(src: &Path, dst: &Path) { + if !src.is_dir() { + return; + } std::fs::create_dir_all(dst).unwrap(); for entry in std::fs::read_dir(src).unwrap().flatten() { let to = dst.join(entry.file_name()); @@ -1044,7 +1052,10 @@ pub fn manifestless_matrix(m: &Matrix<'_>, row: &dyn Fn(&str, &str)) { attested(&vex(VexRun::online(m.api)), "manifest-deleted"); // (b) ledgers deleted too: lockfile discovery + the API record. let ledgers = snapshot(fresh, &LEDGERS); - assert!(!ledgers.is_empty(), "the flow left no ledger"); + match m.mode { + Mode::Hosted => crate::vex_e2e_common::assert_no_hosted_ledger(fresh, "hosted flow"), + Mode::Vendored => assert!(!ledgers.is_empty(), "the vendored flow left no ledger"), + } strip_ledgers(fresh); let before = m.api.view_requests(m.uuid); let out = vex(VexRun::online(m.api)); @@ -1066,6 +1077,13 @@ pub fn manifestless_matrix(m: &Matrix<'_>, row: &dyn Fn(&str, &str)) { // (d) embedded entry points, manifest-less, ledgers back. restore(fresh, &ledgers); for (label, run) in &m.embedded { + let mut run = run.clone(); + if m.mode == Mode::Hosted && run.offline { + // v5 hosted mode keeps no local record: the embedded run + // fetches it like the standalone one. + run.offline = false; + run.proxy_url = Some(m.api.uri()); + } let out = vex(run.clone()); assert_eq!(out.code, Some(0), "[{label}]:\n{out}"); assert_attested(out.doc(), m.purl, m.uuid, marker, m.vulns); @@ -1109,16 +1127,28 @@ pub fn manifestless_matrix(m: &Matrix<'_>, row: &dyn Fn(&str, &str)) { if no_verify { " --no-verify" } else { "" }, if online { " online" } else { " offline" } ); - assert_eq!(out.code, Some(1), "[{step}]:\n{out}"); - assert_not_attested(&out.envelope, m.purl, m.mode.unwired()); + match m.mode { + Mode::Vendored => { + assert_eq!(out.code, Some(1), "[{step}]:\n{out}"); + assert_not_attested(&out.envelope, m.purl, m.mode.unwired()); + } + Mode::Hosted => { + assert_eq!(out.code, Some(2), "[{step}]:\n{out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "[{step}]: no hosted state is left:\n{out}" + ); + } + } } } + let why = match m.mode { + Mode::Vendored => m.mode.unwired(), + Mode::Hosted => "nothing discovered", + }; row( "reverted", - &format!( - "not attested ({}; verified + --no-verify)", - m.mode.unwired() - ), + &format!("not attested ({why}; verified + --no-verify)"), ); restore(fresh, &wired); } @@ -1527,7 +1557,21 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { &|step, result| report.row(step, result), ); - // ── 6. the real revert restores every wiring file ───────────────── + // ── 6. the real revert ──────────────────────────────────────────── + // Vendored: `vendor --revert` restores every wiring file byte for + // byte. Hosted (v5): `rollback` rewrites each pin back to the DEFAULT + // upstream registry entry, re-resolved from the registry — or, where + // the lock gives it nothing to re-derive the entry's shape from, + // refuses that pin (exit 1) and leaves the files alone for a + // version-control restore. + let wired_files = snapshot( + &proj, + &built + .registry + .iter() + .map(|(f, _)| f.as_str()) + .collect::>(), + ); let out = match mode { Mode::Hosted => { let uri = patch_server.clone().unwrap(); @@ -1545,6 +1589,10 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { "fake-token", "--org", ORG, + // v5: the hosted pins ARE the state; one on the mock + // origin counts only when that origin is configured. + "--patch-server-url", + &uri, ], ) } @@ -1559,6 +1607,58 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { ], ), }; + if mode == Mode::Hosted { + let env: Value = serde_json::from_slice(&out.stdout) + .unwrap_or_else(|e| panic!("{}: ({e})\n{}", report.what("revert"), dump(&out))); + let still_wired = |f: &str| { + String::from_utf8_lossy(&std::fs::read(proj.join(f)).unwrap()).contains(uuid) + }; + match out.status.code() { + Some(0) => { + assert_eq!( + env["hosted"]["reverted"], + json!([built.purl]), + "{}:\n{}", + report.what("revert"), + dump(&out) + ); + for (f, _) in &built.registry { + assert!( + !still_wired(f), + "{}: {f} still names the hosted patch", + report.what("revert") + ); + } + report.row("revert", "restored to the upstream registry entry"); + } + _ => { + let error = env["hosted"]["failed"][0]["error"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + error.starts_with(&format!( + "cannot restore {} to its upstream registry entry:", + built.purl + )) && error.contains("restore it from version control instead"), + "{}:\n{}", + report.what("revert"), + dump(&out) + ); + assert_eq!( + snapshot( + &proj, + &wired_files.iter().map(|(f, _)| f.as_str()).collect::>() + ), + wired_files, + "{}: a refused pin writes nothing", + report.what("revert") + ); + report.row("revert", &format!("refused ({error})")); + } + } + return; + } assert_eq!( out.status.code(), Some(0), @@ -1672,8 +1772,33 @@ pub struct Production<'a> { pub embedded: Vec<(&'a str, VexRun)>, } -/// The first production record for one of `p.uuids`, from the ledgers or -/// the manifest `p.proj` holds. +/// The public patch view for `uuid` from the production proxy (the free +/// tier needs no token), or `None` when it does not answer. +fn fetch_public_view(uuid: &str) -> Option { + let url = format!( + "{}/patch/view/{uuid}", + socket_patch_core::constants::DEFAULT_PATCH_API_PROXY_URL + ); + // Own thread + runtime: callers may already be inside a tokio runtime. + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().ok()?; + rt.block_on(async { + let resp = reqwest::get(&url).await.ok()?; + if !resp.status().is_success() { + return None; + } + resp.json::().await.ok() + }) + }) + .join() + .ok() + .flatten() +} + +/// The first production record for one of `p.uuids`, from the vendor +/// ledger or the manifest `p.proj` holds (or a pre-v5 redirect ledger) — +/// and, for a hosted leg (v5 hosted mode keeps no local record), from the +/// production patch API itself. fn production_record(p: &Production<'_>) -> Value { let read = |rel: &str| -> Value { std::fs::read(p.proj.join(rel)) @@ -1699,9 +1824,14 @@ fn production_record(p: &Production<'_>) -> Value { candidates .into_iter() .find(|r| r["uuid"].as_str().is_some_and(|u| p.uuids.contains(&u))) + .or_else(|| { + (p.mode == Mode::Hosted) + .then(|| p.uuids.iter().find_map(|u| fetch_public_view(u))) + .flatten() + }) .unwrap_or_else(|| { panic!( - "{}: no production record for the leg's pinned patches in the ledgers", + "{}: no production record for the leg's pinned patches (local state or API)", p.leg ) }) diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/vlt.rs b/crates/socket-patch-cli/tests/vex_e2e_common/vlt.rs index 02a378f0f..6baebf40b 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/vlt.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/vlt.rs @@ -20,16 +20,18 @@ //! `(vendored)` marker and exactly the expected vulnerability ids (+ CVE //! aliases); the embedded `apply --vex` (and, vendored, `vendor --vex`) //! attest the same without touching the lock. -//! 2. `ledgers-deleted` — `.socket/vendor/state.json` and -//! `redirect-state.json` deleted too: still attested, now from lockfile -//! discovery + the patch API (the view route is hit). +//! 2. `ledgers-deleted` — `.socket/vendor/state.json` deleted too (a hosted +//! flow wrote no ledger at all in v5 — asserted up front): still +//! attested, now from lockfile discovery + the patch API (the view route +//! is hit). //! 3. `offline` — `--offline` with no ledgers: `record_unavailable`, exit 1, //! ZERO requests to the API. //! 4. `reverted` — the lock (and, vendored, the importer package.json //! files) put back to the registry version (ledgers and committed -//! artifacts kept, the patched install left in node_modules): -//! NOT attested (`redirect_unwired` / `vendor_unwired`), also under -//! `--no-verify`. +//! artifacts kept, the patched install left in node_modules): NOT +//! attested, also under `--no-verify` — vendored: `vendor_unwired`; +//! hosted: nothing is discovered (exit 2 `manifest_not_found`), since +//! the lock was the only hosted state. //! //! The checkout copies what git would commit: the root `node_modules/` is //! left out, and so is vlt's `node_modules/` inside a vendored package dir @@ -68,7 +70,8 @@ impl VltMode { } /// The omission a ledger-backed record gets once the lock no longer - /// references it. + /// references it (hosted: only a PRE-v5 redirect ledger still earns + /// it; v5 hosted flows write no ledger). pub fn unwired_reason(self) -> &'static str { match self { VltMode::Hosted => "redirect_unwired", @@ -234,6 +237,15 @@ fn matrix(project: &Path, scratch: &Path, case: &VltVexCase<'_ let what = format!("{} {}", case.tag, mode.label()); let ok = |step: &str| eprintln!("VLT-VEX {} {} {step} ok", case.tag, mode.label()); let checkout = manifestless_checkout(project, &scratch.join(format!("vex-{}", case.tag))); + match mode { + VltMode::Hosted => assert_no_hosted_ledger(&checkout, &what), + VltMode::Vendored => assert!( + checkout + .join(socket_patch_core::vendor::VENDOR_STATE_REL) + .is_file(), + "{what}: the vendored flow must have written its ledger" + ), + } install(&checkout); let lock_path = checkout.join(VLT_LOCK); let wired_lock = std::fs::read(&lock_path).unwrap(); @@ -324,10 +336,6 @@ fn matrix(project: &Path, scratch: &Path, case: &VltVexCase<'_ std::fs::write(path, bytes).unwrap(); } } - assert!( - ledgers.iter().any(|(_, b)| b.is_some()), - "{what}: the flow left no ledger — the reverted step would be vacuous" - ); std::fs::write(&lock_path, &case.registry_lock).unwrap(); for (rel, bytes) in &case.registry_manifests { std::fs::write(checkout.join(rel), bytes).unwrap(); @@ -338,12 +346,27 @@ fn matrix(project: &Path, scratch: &Path, case: &VltVexCase<'_ ..online() }; let out = run_vex(&bin, &checkout, &run); - assert_eq!( - out.code, - Some(1), - "{what} reverted (no_verify={no_verify}): {out}" - ); - assert_skipped(&out.envelope, case.purl, mode.unwired_reason()); + match mode { + VltMode::Vendored => { + assert_eq!( + out.code, + Some(1), + "{what} reverted (no_verify={no_verify}): {out}" + ); + assert_skipped(&out.envelope, case.purl, mode.unwired_reason()); + } + VltMode::Hosted => { + assert_eq!( + out.code, + Some(2), + "{what} reverted (no_verify={no_verify}): {out}" + ); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{what} reverted (no_verify={no_verify}): no hosted state is left: {out}" + ); + } + } assert_absent(out.doc.as_ref(), case.purl); } ok("reverted"); diff --git a/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs b/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs index 608b37a21..eb2ee9a04 100644 --- a/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pdm_hatch_common/mod.rs @@ -22,8 +22,11 @@ //! install in a fabricated `.venv` for the vendored build. The wired tree is //! snapshotted once per (flavor, mode) and every cell restores the snapshot //! into its own temp dir, then DELETES what the cell says to delete -//! (`.socket/manifest.json`, the ledgers `.socket/vendor/state.json` / -//! `.socket/vendor/redirect-state.json`, the venv) before running VEX. +//! (`.socket/manifest.json`, the vendor ledger `.socket/vendor/state.json`, +//! the venv) before running VEX. v5 hosted mode keeps no ledger at all — +//! its only state is the wiring — so for hosted cells "ledgers kept" and +//! "ledgers gone" are the same checkout, and an offline hosted run has no +//! local record (`record_unavailable`). //! //! Hermetic: no network (the API is wiremock; `--offline` runs assert zero //! requests), no real Python / PDM / Hatch needed. The package is a @@ -125,7 +128,24 @@ impl Mode { } } - /// The ledger file this mode's wiring run writes. + /// Whether this mode's wiring run writes a ledger: vendored keeps + /// `.socket/vendor/state.json`; v5 hosted keeps none (its state is the + /// wiring itself). + pub fn has_ledger(self) -> bool { + self == Mode::Vendored + } + + /// The `keep` variants worth running for this mode: hosted has no + /// ledger, so [`LEDGERS_ONLY`] would only repeat [`NOTHING`]. + pub fn ledger_keeps(self) -> &'static [Keep] { + match self { + Mode::Hosted => &[NOTHING], + Mode::Vendored => &[NOTHING, LEDGERS_ONLY], + } + } + + /// The ledger file of this mode: the vendor ledger the vendored wiring + /// run writes, or the PRE-v5 hosted ledger v5 hosted mode never writes. pub fn ledger(self) -> &'static str { match self { Mode::Hosted => ".socket/vendor/redirect-state.json", @@ -588,7 +608,9 @@ pub fn wired(flavor: &Flavor, mode: Mode) -> Arc { }); let what = wired.what(); // Anti-vacuity: the run really rewired a project file, into the - // mode's reference shape, and left its ledger + artifact behind. + // mode's reference shape, and (vendored) left its ledger + artifact + // behind — while v5 hosted mode left NO ledger (its state is the + // wiring alone). let changed = wired.changed_files(); assert!(!changed.is_empty(), "{what}: nothing was rewired"); let wiring_text: String = changed.iter().map(|rel| text(&wired.files[*rel])).collect(); @@ -609,9 +631,10 @@ pub fn wired(flavor: &Flavor, mode: Mode) -> Arc { ); } } - assert!( + assert_eq!( wired.files.contains_key(mode.ledger()), - "{what}: the {} ledger must be written: {:?}", + mode.has_ledger(), + "{what}: the {} ledger must be written iff the mode keeps one: {:?}", mode.ledger(), wired.files.keys().collect::>() ); @@ -740,9 +763,11 @@ pub fn b_no_local_record_is_record_unavailable(flavors: &[Flavor]) { } } -/// c) ledger kept, manifest gone → attested OFFLINE from the ledger record -/// (both `--offline` and `--offline --no-verify`); the committed `.socket/` -/// exactly as the wiring run left it attests too. +/// c) vendored: ledger kept, manifest gone → attested OFFLINE from the +/// ledger record (both `--offline` and `--offline --no-verify`); the +/// committed `.socket/` exactly as the wiring run left it attests too. +/// Hosted: v5 left no local record anywhere, so the committed checkout +/// offline is `record_unavailable` — and online attests from the API. pub fn c_ledger_without_manifest_attests_offline(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); @@ -757,23 +782,55 @@ pub fn c_ledger_without_manifest_attests_offline(flavors: &[Flavor]) { no_verify, ..vex_run(Some(&api)) }; + let out = vex(&cwd, &run); + let what = format!("{what} {keep:?} nv={no_verify}"); + match mode { + Mode::Vendored => assert_ok_attested(&out, mode, &what), + Mode::Hosted => assert_omitted(&out, "record_unavailable", &what), + } + } + api.assert_no_requests(); + if mode == Mode::Hosted { + let api = api_with(mode.uuid(), PURL); assert_ok_attested( - &vex(&cwd, &run), + &vex(&cwd, &vex_run(Some(&api))), mode, - &format!("{what} {keep:?} nv={no_verify}"), + &format!("{what} {keep:?} online"), ); } - api.assert_no_requests(); } } } -/// d) wiring reverted to the registry while ledger + artifact remain → -/// `redirect_unwired` / `vendor_unwired`, `--no-verify` included; a legacy -/// (pre-5.0) manifest put back as well still attests nothing; with the ledger gone too -/// nothing is discovered at all (the orphaned wheel is never evidence). +/// d) vendored: wiring reverted to the registry while ledger + artifact +/// remain → `vendor_unwired`, `--no-verify` included; a legacy (pre-5.0) +/// manifest put back as well still attests nothing; with the ledger gone +/// too nothing is discovered at all (the orphaned wheel is never evidence). +/// Hosted (no ledger in v5): the reverted wiring was the only hosted +/// state, so nothing is discovered and the API is never asked. pub fn d_reverted_wiring_is_unwired_even_with_no_verify(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { + if mode == Mode::Hosted { + let wired = wired(&flavor, mode); + let what = wired.what(); + let (_tmp, cwd) = fresh(); + wired.restore(&cwd, EVERYTHING); + wired.revert_wiring(&cwd); + let api = api_with(mode.uuid(), PURL); + for (offline, no_verify) in [(true, false), (true, true), (false, true)] { + let run = VexRun { + offline, + no_verify, + ..vex_run(Some(&api)) + }; + assert_nothing_discovered( + &vex(&cwd, &run), + &format!("{what} offline={offline} nv={no_verify}"), + ); + } + api.assert_no_requests(); + continue; + } let wired = wired(&flavor, mode); let what = wired.what(); let (_tmp, cwd) = fresh(); @@ -862,12 +919,12 @@ pub fn tamper_wheel(cwd: &Path) { } /// e) tampered installed tree (hosted) / tampered wheel member (vendored) -/// → omitted, with or without the ledgers; `--no-verify` is the documented -/// opt-out of hashing (the wiring/record gates still run). +/// → omitted, with or without the (vendor) ledger; `--no-verify` is the +/// documented opt-out of hashing (the wiring/record gates still run). pub fn e_tampered_evidence_is_omitted(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in mode.ledger_keeps() { let what = format!("{} ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); @@ -894,18 +951,12 @@ pub fn e_tampered_evidence_is_omitted(flavors: &[Flavor]) { /// f1) the Socket patch host swapped for a look-alike in every wired file: /// the uuid-shaped segments (grant token + patch uuid) are all still there. -/// Without a ledger that is not a patch reference at all (nothing is looked -/// up). With the redirect ledger kept, a uuid no allowlisted reference -/// mentions falls back to the LEDGER's own recorded wiring (vex_sources.rs -/// "liveness": self-hosted patch servers outside the allowlist) — which the -/// unchanged sha256 pin still names — so it is NOT unwired, but nothing is -/// installed and the lock is no longer a discovered Socket reference, so no -/// lockfile-pin basis exists either: verification omits it -/// (`package_not_found`). +/// That is not a patch reference at all (nothing is looked up): v5 hosted +/// keeps no ledger that could vouch for a host outside the allowlist. pub fn f_hosted_uuid_on_a_foreign_host_is_not_a_reference(flavors: &[Flavor]) { for flavor in flavors.iter().filter(|f| f.hosted) { let wired = wired(flavor, Mode::Hosted); - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in Mode::Hosted.ledger_keeps() { let what = format!("{} ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); @@ -920,12 +971,8 @@ pub fn f_hosted_uuid_on_a_foreign_host_is_not_a_reference(flavors: &[Flavor]) { } let api = api_with(HOSTED_UUID, PURL); let out = vex(&cwd, &vex_run(Some(&api))); - if keep.ledgers { - assert_omitted(&out, "package_not_found", &what); - } else { - assert_nothing_discovered(&out, &what); - api.assert_no_requests(); - } + assert_nothing_discovered(&out, &what); + api.assert_no_requests(); } } } @@ -1030,7 +1077,7 @@ pub fn g_hosted_installed_tree_states(flavors: &[Flavor]) { for flavor in flavors.iter().filter(|f| f.hosted) { let wired = wired(flavor, Mode::Hosted); for (bytes, expect) in [(PATCHED, None), (PRISTINE, Some("not_applied"))] { - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in Mode::Hosted.ledger_keeps() { let what = format!( "{} installed={expect:?} ledgers={}", wired.what(), @@ -1204,12 +1251,18 @@ pub fn embedded_rescan_of_a_manifest_less_checkout( /// `apply --vex` and `vendor --vex` on a manifest-less wired checkout (CI: /// install, then `socket-patch apply --vex out.json`): the patches the /// wiring names are attested although there is no manifest to apply — -/// offline from the ledgers, and online from the API with no ledger. +/// offline from the vendor ledger, and online from the API with no ledger. +/// Hosted: `apply --vex` only, online (v5 keeps no local hosted record; and +/// `vendor` on a hosted checkout is the eject flow, not a no-op). pub fn embedded_apply_and_vendor_vex_attest_a_manifest_less_checkout(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); - for via in [VexVia::Apply, VexVia::Vendor] { - for keep in [LEDGERS_ONLY, NOTHING] { + let vias: &[VexVia] = match mode { + Mode::Hosted => &[VexVia::Apply], + Mode::Vendored => &[VexVia::Apply, VexVia::Vendor], + }; + for &via in vias { + for &keep in mode.ledger_keeps().iter().rev() { let what = format!("{} {via:?} ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); diff --git a/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs b/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs index 0c8d06e50..0dadda5da 100644 --- a/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pipenv_pip_common/mod.rs @@ -22,8 +22,11 @@ //! install in a fabricated `.venv` for the vendored build. The wired tree is //! snapshotted once per (flavor, mode) and every cell restores the snapshot //! into its own temp dir, then DELETES what the cell says to delete -//! (`.socket/manifest.json`, the ledgers `.socket/vendor/state.json` / -//! `.socket/vendor/redirect-state.json`, the venv) before running VEX. +//! (`.socket/manifest.json`, the vendor ledger `.socket/vendor/state.json`, +//! the venv) before running VEX. v5 hosted mode keeps no ledger at all — +//! its only state is the wiring — so for hosted cells "ledgers kept" and +//! "ledgers gone" are the same checkout, and an offline hosted run has no +//! local record (`record_unavailable`). //! //! Hermetic: no network (the API is wiremock; `--offline` runs assert zero //! requests), no real Python / Pipenv / pip needed. The package is a @@ -134,7 +137,24 @@ impl Mode { } } - /// The ledger file this mode's wiring run writes. + /// Whether this mode's wiring run writes a ledger: vendored keeps + /// `.socket/vendor/state.json`; v5 hosted keeps none (its state is the + /// wiring itself). + pub fn has_ledger(self) -> bool { + self == Mode::Vendored + } + + /// The `keep` variants worth running for this mode: hosted has no + /// ledger, so [`LEDGERS_ONLY`] would only repeat [`NOTHING`]. + pub fn ledger_keeps(self) -> &'static [Keep] { + match self { + Mode::Hosted => &[NOTHING], + Mode::Vendored => &[NOTHING, LEDGERS_ONLY], + } + } + + /// The ledger file of this mode: the vendor ledger the vendored wiring + /// run writes, or the PRE-v5 hosted ledger v5 hosted mode never writes. pub fn ledger(self) -> &'static str { match self { Mode::Hosted => ".socket/vendor/redirect-state.json", @@ -611,7 +631,8 @@ pub fn wired(flavor: &Flavor, mode: Mode) -> Arc { }); let what = wired.what(); // Anti-vacuity: the run really rewired a project file, into the mode's - // reference shape, and left its ledger + artifact behind. + // reference shape, and (vendored) left its ledger + artifact behind — + // while v5 hosted mode left NO ledger (its state is the wiring alone). let changed = wired.changed_files(); assert!(!changed.is_empty(), "{what}: nothing was rewired"); let wiring_text: String = changed.iter().map(|rel| text(&wired.files[*rel])).collect(); @@ -632,9 +653,10 @@ pub fn wired(flavor: &Flavor, mode: Mode) -> Arc { ); } } - assert!( + assert_eq!( wired.files.contains_key(mode.ledger()), - "{what}: the {} ledger must be written: {:?}", + mode.has_ledger(), + "{what}: the {} ledger must be written iff the mode keeps one: {:?}", mode.ledger(), wired.files.keys().collect::>() ); @@ -715,7 +737,8 @@ pub fn assert_no_statement(out: &VexOutcome, what: &str) { /// a) no manifest, no ledgers, online → attested from the wiring alone; /// vex never writes the manifest or a ledger. Also the committed `.socket/` -/// (ledger, plus the wheel when vendored) baseline, offline. +/// baseline, offline: vendored (ledger + wheel) attests from it; hosted +/// commits no local record, so offline it is `record_unavailable`. pub fn a_wiring_only_checkout_attests_online(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); @@ -753,7 +776,12 @@ pub fn a_wiring_only_checkout_attests_online(flavors: &[Flavor]) { offline: true, ..vex_run(None) }; - assert_ok_attested(&vex(&cwd2, &run), mode, &format!("{what} committed")); + let out = vex(&cwd2, &run); + let what = format!("{what} committed"); + match mode { + Mode::Vendored => assert_ok_attested(&out, mode, &what), + Mode::Hosted => assert_omitted(&out, "record_unavailable", &what), + } } } @@ -807,8 +835,10 @@ pub fn b_no_local_record_is_record_unavailable(flavors: &[Flavor]) { } } -/// c) ledger present, manifest absent → attests OFFLINE from the ledger -/// record, verified and `--no-verify`. +/// c) everything the wiring run left, manifest absent, OFFLINE (verified +/// and `--no-verify`). Vendored: the vendor ledger's record attests. Hosted: +/// v5 left no local record, so offline is `record_unavailable` — and the +/// same checkout online attests from the API. pub fn c_ledger_without_manifest_attests_offline(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); @@ -822,20 +852,31 @@ pub fn c_ledger_without_manifest_attests_offline(flavors: &[Flavor]) { no_verify, ..vex_run(Some(&api)) }; + let out = vex(&cwd, &run); + let what = format!("{what} no_verify={no_verify}"); + match mode { + Mode::Vendored => assert_ok_attested(&out, mode, &what), + Mode::Hosted => assert_omitted(&out, "record_unavailable", &what), + } + } + api.assert_no_requests(); + if mode == Mode::Hosted { + let api = api_with(mode.uuid(), PURL); assert_ok_attested( - &vex(&cwd, &run), + &vex(&cwd, &vex_run(Some(&api))), mode, - &format!("{what} no_verify={no_verify}"), + &format!("{what} online"), ); } - api.assert_no_requests(); } } -/// d) wiring reverted to the registry while the ledger (+ artifact) -/// remain → `redirect_unwired` / `vendor_unwired`, `--no-verify` included, -/// online too (the API would vouch for the uuid); with the manifest back as -/// well nothing is attested; with the ledger gone nothing is discovered. +/// d) wiring reverted to the registry. Vendored, while the ledger (+ +/// artifact) remain → `vendor_unwired`, `--no-verify` included, online too +/// (the API would vouch for the uuid); with the manifest back as well +/// nothing is attested; with the ledger gone nothing is discovered. Hosted +/// (no ledger in v5): the reverted wiring was the only hosted state, so +/// nothing is discovered at all and the API is never asked. pub fn d_reverted_wiring_is_unwired_even_with_no_verify(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); @@ -856,11 +897,15 @@ pub fn d_reverted_wiring_is_unwired_even_with_no_verify(flavors: &[Flavor]) { no_verify, ..vex_run(Some(&api)) }; - assert_omitted( - &vex(&cwd, &run), - mode.unwired(), - &format!("{what} offline={offline} no_verify={no_verify}"), - ); + let out = vex(&cwd, &run); + let what = format!("{what} offline={offline} no_verify={no_verify}"); + match mode { + Mode::Vendored => assert_omitted(&out, mode.unwired(), &what), + Mode::Hosted => assert_nothing_discovered(&out, &what), + } + } + if mode == Mode::Hosted { + api.assert_no_requests(); } // A patched install left behind does not revive the claim either. install(&cwd, ".venv", PATCHED); @@ -927,12 +972,12 @@ pub fn tamper_wheel(cwd: &Path) { } /// e) tampered installed tree (hosted) / tampered wheel member (vendored) -/// → omitted, with and without the ledger; `--no-verify` is the documented -/// opt-out of hashing (the wiring / record gates still run). +/// → omitted, with and without the (vendor) ledger; `--no-verify` is the +/// documented opt-out of hashing (the wiring / record gates still run). pub fn e_tampered_evidence_is_omitted(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in mode.ledger_keeps() { let what = format!("{} ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); @@ -959,17 +1004,16 @@ pub fn e_tampered_evidence_is_omitted(flavors: &[Flavor]) { /// f) the Socket patch host swapped for a look-alike in every wired file /// (the uuid-shaped segments — grant token + patch uuid — are all still -/// there): with no ledger nothing is discovered and the API is never asked; -/// with the stale ledger kept the verified run still omits it (no -/// discovered pin, nothing installed), and a patched install is not enough -/// either once the ledger is gone. +/// there): nothing is discovered and the API is never asked, and a patched +/// install is not enough either (v5 hosted keeps no ledger that could vouch +/// for a host outside the allowlist). pub fn f_hosted_uuid_on_a_foreign_host_is_not_a_reference(flavors: &[Flavor]) { for flavor in flavors.iter().filter(|f| f.hosted) { let wired = wired(flavor, Mode::Hosted); let what = wired.what(); - for keep in [NOTHING, LEDGERS_ONLY] { + { let (_tmp, cwd) = fresh(); - wired.restore(&cwd, keep); + wired.restore(&cwd, NOTHING); for rel in wired.changed_files() { let spoofed = text(&wired.files[rel]).replace( "https://patch.socket.dev/", @@ -980,22 +1024,12 @@ pub fn f_hosted_uuid_on_a_foreign_host_is_not_a_reference(flavors: &[Flavor]) { } let api = api_with(HOSTED_UUID, PURL); let out = vex(&cwd, &vex_run(Some(&api))); - let what = format!("{what} ledgers={}", keep.ledgers); - if keep.ledgers { - // The redirect ledger's own evidence (a host outside the - // discovery allowlist falls back to the ledger's recorded - // edit file still naming the uuid — the `--patch-server-url` - // escape hatch), but without a discovered integrity pin or - // an installed tree there is nothing to verify. - assert_omitted(&out, "package_not_found", &what); - } else { - assert_nothing_discovered(&out, &what); - api.assert_no_requests(); - install(&cwd, ".venv", PATCHED); - let out = vex(&cwd, &vex_run(Some(&api))); - assert_nothing_discovered(&out, &format!("{what} installed")); - api.assert_no_requests(); - } + assert_nothing_discovered(&out, &what); + api.assert_no_requests(); + install(&cwd, ".venv", PATCHED); + let out = vex(&cwd, &vex_run(Some(&api))); + assert_nothing_discovered(&out, &format!("{what} installed")); + api.assert_no_requests(); } } } @@ -1096,7 +1130,7 @@ pub fn g_hosted_installed_tree_states(flavors: &[Flavor]) { for flavor in flavors.iter().filter(|f| f.hosted) { let wired = wired(flavor, Mode::Hosted); for (bytes, expect) in [(PATCHED, None), (PRISTINE, Some("not_applied"))] { - for keep in [NOTHING, LEDGERS_ONLY] { + for &keep in Mode::Hosted.ledger_keeps() { let what = format!( "{} installed={expect:?} ledgers={}", wired.what(), @@ -1260,12 +1294,18 @@ pub fn embedded_rescan_of_a_manifest_less_checkout(flavors: &[Flavor]) { /// `apply --vex` and `vendor --vex` on a manifest-less wired checkout (CI: /// install, then `socket-patch apply --vex out.json`): the patches the /// wiring names are attested although there is no manifest to apply — -/// offline from the ledgers, and online from the API with no ledger. +/// offline from the vendor ledger, and online from the API with no ledger. +/// Hosted: `apply --vex` only, online (v5 keeps no local hosted record; and +/// `vendor` on a hosted checkout is the eject flow, not a no-op). pub fn embedded_apply_and_vendor_vex_attest_a_manifest_less_checkout(flavors: &[Flavor]) { for (flavor, mode) in cells(flavors) { let wired = wired(&flavor, mode); - for via in [VexVia::Apply, VexVia::Vendor] { - for keep in [LEDGERS_ONLY, NOTHING] { + let vias: &[VexVia] = match mode { + Mode::Hosted => &[VexVia::Apply], + Mode::Vendored => &[VexVia::Apply, VexVia::Vendor], + }; + for &via in vias { + for &keep in mode.ledger_keeps().iter().rev() { let what = format!("{} {via:?} ledgers={}", wired.what(), keep.ledgers); let (_tmp, cwd) = fresh(); wired.restore(&cwd, keep); diff --git a/crates/socket-patch-cli/tests/vex_pipenv_pip_steps/mod.rs b/crates/socket-patch-cli/tests/vex_pipenv_pip_steps/mod.rs index 776c4393f..3a98ae009 100644 --- a/crates/socket-patch-cli/tests/vex_pipenv_pip_steps/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pipenv_pip_steps/mod.rs @@ -11,10 +11,14 @@ //! 3. `offline`: no manifest, no ledgers, `--offline` → //! `record_unavailable` (and, against the mock, ZERO requests); //! 4. `reverted`: the wiring back on the registry, ledgers + artifacts kept -//! → NOT attested (`redirect_unwired` / `vendor_unwired`), offline and -//! online, with and without `--no-verify`; +//! → NOT attested, offline and online, with and without `--no-verify`: +//! `redirect_unwired` / `vendor_unwired` while a ledger still names the +//! patch; with no ledger at all (v5 hosted mode keeps none, so a reverted +//! hosted checkout holds no patch state anywhere) the run is the plain +//! `manifest_not_found` error; //! 5. `apply-vex`: `apply --vex` on the manifest-less checkout, ledgers -//! kept, offline → attested. +//! kept → attested: offline (zero requests) when a ledger supplies the +//! record, online from the API otherwise (v5 hosted mode). //! //! Every step runs on a scoped OS thread, so the helper is callable from //! `#[tokio::test]`s (the mock patch API owns its own runtime). @@ -232,6 +236,7 @@ fn steps_inner(s: &Steps<'_>) { let p = copy("reverted"); strip_manifest(&p); (s.revert)(&p); + let ledgered = has_ledger(&p); for (offline, no_verify) in [(true, false), (true, true), (false, false), (false, true)] { let out = s.run( &p, @@ -245,31 +250,56 @@ fn steps_inner(s: &Steps<'_>) { "{} reverted offline={offline} no_verify={no_verify}", s.what ); - assert_eq!(out.code, Some(1), "{what}: {out}"); - assert_absent(out.doc.as_ref(), s.purl); - assert_not_attested(&out.envelope, s.purl, s.unwired()); + if ledgered { + assert_eq!(out.code, Some(1), "{what}: {out}"); + assert_absent(out.doc.as_ref(), s.purl); + assert_not_attested(&out.envelope, s.purl, s.unwired()); + } else { + // Nothing names the patch any more: no manifest, no ledger, + // no hosted wiring. + assert_eq!(out.code, Some(2), "{what}: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{what}: {out}" + ); + assert_absent(out.doc.as_ref(), s.purl); + } } }); s.step("apply-vex", || { let p = copy("apply-vex"); strip_manifest(&p); - let silent = PatchApi::empty(); - let out = s.run( - &p, - VexRun { - offline: true, - ..VexRun::online(&silent) - } - .via(VexVia::Apply), - ); - assert_eq!(out.code, Some(0), "{} apply --vex: {out}", s.what); - s.assert_attested(&out, "apply-vex"); + if has_ledger(&p) { + let silent = PatchApi::empty(); + let out = s.run( + &p, + VexRun { + offline: true, + ..VexRun::online(&silent) + } + .via(VexVia::Apply), + ); + assert_eq!(out.code, Some(0), "{} apply --vex: {out}", s.what); + s.assert_attested(&out, "apply-vex"); + silent.assert_no_requests(); + } else { + // No local record (a v5 hosted checkout): the API supplies it. + let out = s.run(&p, online().via(VexVia::Apply)); + assert_eq!(out.code, Some(0), "{} apply --vex: {out}", s.what); + s.assert_attested(&out, "apply-vex"); + } assert!(!p.join(".socket/manifest.json").exists(), "{}", s.what); - silent.assert_no_requests(); }); } +/// Whether the checkout still holds a ledger that can name the patch (the +/// vendor ledger, or a pre-v5 hosted ledger). +fn has_ledger(project: &Path) -> bool { + project.join(".socket/vendor/state.json").exists() + || project.join(".socket/vendor/redirect-state.json").exists() +} + /// Copy `from` into `to` recursively (symlinks copied as the files they /// point at; a venv's interpreter links are not needed by VEX). pub fn copy_tree(from: &Path, to: &Path) { diff --git a/crates/socket-patch-cli/tests/vex_pypi_real_common/mod.rs b/crates/socket-patch-cli/tests/vex_pypi_real_common/mod.rs index 9af94bcae..7ed0bf480 100644 --- a/crates/socket-patch-cli/tests/vex_pypi_real_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pypi_real_common/mod.rs @@ -625,17 +625,29 @@ impl VexMatrix<'_> { ); let uuid = self.mode.uuid(); - // (1) manifest deleted, ledgers kept: offline from the ledger - // record (zero network), then online. + // (1) manifest deleted, ledgers kept: offline from the vendor + // ledger's record (zero network), then online. v5 hosted mode keeps + // no ledger, so a hosted checkout has no local record to go offline + // from (step 3 pins its `record_unavailable`). let api = self.api(); - let offline = VexRun { - offline: true, - ..self.run_for(&api) - }; - let out = run_vex(&bin, checkout, &offline); - assert_eq!(out.code, Some(0), "{}: {out}", self.what("ledger offline")); - assert_attested(out.doc(), PURL, uuid, self.mode.marker(), VULNS); - api.assert_no_requests(); + if matches!(self.mode, Mode::Hosted) { + assert!( + !checkout + .join(socket_patch_core::patch::redirect::REDIRECT_STATE_REL) + .exists(), + "{}: v5 hosted mode writes no redirect ledger", + self.what("no ledger") + ); + } else { + let offline = VexRun { + offline: true, + ..self.run_for(&api) + }; + let out = run_vex(&bin, checkout, &offline); + assert_eq!(out.code, Some(0), "{}: {out}", self.what("ledger offline")); + assert_attested(out.doc(), PURL, uuid, self.mode.marker(), VULNS); + api.assert_no_requests(); + } let out = run_vex(&bin, checkout, &self.run_for(&api)); assert_eq!( out.code, @@ -689,7 +701,14 @@ impl VexMatrix<'_> { self.done("ledgers-deleted"); // Embedded forms on the same manifest-less, ledgerless checkout. - for via in [VexVia::Apply, VexVia::Vendor] { + // Hosted: `apply --vex` only — a manifest-less `vendor` over hosted + // pins EJECTS them into `.socket/vendor/` (v5), which is a rewire, + // not an attestation of the hosted wiring. + let embedded: &[VexVia] = match self.mode { + Mode::Hosted => &[VexVia::Apply], + Mode::Vendored => &[VexVia::Apply, VexVia::Vendor], + }; + for &via in embedded { let out = run_vex(&bin, checkout, &self.run_for(&api).via(via)); assert_eq!( out.code, @@ -742,8 +761,17 @@ impl VexMatrix<'_> { }; let out = run_vex(&bin, checkout, &run); let what = self.what(&format!("reverted offline={offline} no_verify={no_verify}")); - assert_eq!(out.code, Some(1), "{what}: {out}"); - assert_not_attested(&out.envelope, PURL, self.mode.unwired()); + if matches!(self.mode, Mode::Hosted) { + // No ledger and no wiring: nothing names the patch any more. + assert_eq!(out.code, Some(2), "{what}: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{what}: {out}" + ); + } else { + assert_eq!(out.code, Some(1), "{what}: {out}"); + assert_not_attested(&out.envelope, PURL, self.mode.unwired()); + } assert!(out.doc.is_none(), "{what}: {out}"); } self.done("reverted"); diff --git a/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs b/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs index c13031057..df11effa1 100644 --- a/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs +++ b/crates/socket-patch-cli/tests/vlt_e2e_common/fixture.rs @@ -327,10 +327,20 @@ impl Fixture { vex_doc_attests(&self.proj.join("out.vex.json"), t) } + /// The pre-v5 hosted ledger, if any file sits there. v5 hosted mode + /// never writes it, so every hosted leg expects `None`. pub fn ledger(&self) -> Option> { std::fs::read(self.proj.join(".socket/vendor/redirect-state.json")).ok() } + /// `rollback` of this fixture's hosted pins: discovered from the lock on + /// the mock patch service's origin, restored from the harness registry + /// (see [`rollback_upstream`]). + pub fn rollback(&self, extra: &[&str]) -> SocketOut { + let svc = self.svc.uri(); + rollback_upstream(&self.proj, &self.reg.url(), Some(&svc), extra) + } + pub fn store_ids(&self, t: &PatchTarget) -> Vec { node_ids(&read_lock(&self.proj), &t.name, &t.version) } diff --git a/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs b/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs index 3a41ce66b..4730ee81b 100644 --- a/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vlt_e2e_common/mod.rs @@ -1819,7 +1819,8 @@ pub fn get_hosted(proj: &Path, svc: &PatchService, uuid: &str, extra: &[&str]) - out.json() } -/// `rollback --yes --json` (whole ledger) in `proj`. +/// `rollback --yes --json` in `proj`: every hosted pin the lockfiles wire +/// (v5 keeps no hosted ledger) plus the vendor ledger / manifest. pub fn rollback(proj: &Path, extra: &[&str]) -> SocketOut { let cwd = proj.to_str().unwrap().to_string(); let mut args = vec!["rollback", "--json", "--yes", "--cwd", &cwd]; @@ -1827,6 +1828,27 @@ pub fn rollback(proj: &Path, extra: &[&str]) -> SocketOut { socket(proj, &args, &[]) } +/// [`rollback`] of HOSTED pins against the harness: `--patch-server-url +/// ` (when the pins sit on the mock patch service rather than +/// `patch.socket.dev`; discovery recognizes no other host) and +/// `SOCKET_NPM_REGISTRY=`, the npm registry the v5 upstream +/// restore re-resolves each pin's integrity from (the harness registry, so +/// synthetic packages restore too and the run stays hermetic). +pub fn rollback_upstream( + proj: &Path, + registry: &str, + patch_server: Option<&str>, + extra: &[&str], +) -> SocketOut { + let cwd = proj.to_str().unwrap().to_string(); + let mut args = vec!["rollback", "--json", "--yes", "--cwd", &cwd]; + if let Some(origin) = patch_server { + args.extend(["--patch-server-url", origin]); + } + args.extend_from_slice(extra); + socket(proj, &args, &[("SOCKET_NPM_REGISTRY", registry)]) +} + pub fn redirect_warnings(doc: &Value) -> Vec<(String, String)> { let mut out = Vec::new(); for w in [&doc["redirect"]["warnings"], &doc["warnings"]] { diff --git a/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs b/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs index 560bacb80..b41fbd77b 100644 --- a/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs +++ b/crates/socket-patch-cli/tests/vlt_hosted_common/mod.rs @@ -537,3 +537,46 @@ pub fn vex_attests(path: &Path) -> bool { .any(|c| c["@id"] == PURL) }) } + +/// v5 hosted mode writes no ledger: assert `.socket/vendor/redirect-state.json` +/// does not exist under `root`. +pub fn assert_no_ledger(root: &Path) { + let ledger = ledger_path(root); + assert!( + !ledger.exists(), + "v5 hosted mode must not write the redirect ledger, found {}", + ledger.display() + ); +} + +/// A pre-v5 ledger record (the `PatchRecord` shape) built from a +/// `/patches/view` body: `publishedAt` becomes `exportedAt`, `purl` is +/// dropped. +pub fn legacy_record_from_view(view: &Value) -> Value { + let mut record = view.clone(); + let obj = record.as_object_mut().expect("a view body is an object"); + obj.remove("purl"); + let exported = obj + .remove("publishedAt") + .unwrap_or_else(|| json!("2024-01-01T00:00:00Z")); + obj.insert("exportedAt".to_string(), exported); + obj.entry("description").or_insert_with(|| json!("x")); + obj.entry("license").or_insert_with(|| json!("MIT")); + obj.entry("tier").or_insert_with(|| json!("free")); + record +} + +/// Write a pre-v5 hosted ledger (`.socket/vendor/redirect-state.json`) +/// holding `records` (`(purl, PatchRecord JSON)`) and no edits — what an +/// older socket-patch left behind; v5 reads it only as an extra local +/// record source (vex) and never for planning (scan). +pub fn write_legacy_ledger(root: &Path, records: &[(&str, Value)]) { + let map: serde_json::Map = records + .iter() + .map(|(purl, record)| (purl.to_string(), record.clone())) + .collect(); + let ledger = json!({ "version": 1, "mode": "hosted", "records": map }); + let path = ledger_path(root); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_vec_pretty(&ledger).unwrap()).unwrap(); +} diff --git a/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs b/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs index b9f5e7cf8..04ce2881e 100644 --- a/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs +++ b/crates/socket-patch-cli/tests/yarn_berry_common/mod.rs @@ -54,11 +54,11 @@ //! //! | cell | shape | expectation | //! |---|---|---| -//! | `manifest-deleted` | ledgers + artifacts, `--immutable --check-cache` install | online + offline attest (ledger record); embedded `apply --vex` (+ `vendor --vex` / `scan --mode hosted --vex`) attest | +//! | `manifest-deleted` | ledgers + artifacts (hosted: v5 writes no ledger, asserted), `--immutable --check-cache` install | online attests; offline attests from the vendor ledger record (hosted: `record_unavailable`, no local record); embedded `apply --vex` (+ `vendor --vex` / `scan --mode hosted --vex`) attest | //! | `ledgers-deleted` | lockfile (+ vendored artifact) only | online attests from the API record; embedded `apply --vex` attests | //! | `offline` | no ledgers, `--offline` | `record_unavailable`, exit 1, ZERO API requests | //! | `tampered` | installed file (hosted) / artifact member (vendored) altered | `hash_mismatch` / `vendor_hash_mismatch` | -//! | `reverted` | lock (+ package.json) back to the registry, ledgers + artifacts kept, real `--immutable` install of the pristine bytes | `redirect_unwired` / `vendor_unwired` with AND without `--no-verify`, online and offline, zero API requests; with the ledgers gone too: nothing discovered | +//! | `reverted` | lock (+ package.json) back to the registry, ledgers + artifacts kept, real `--immutable` install of the pristine bytes | vendored: `vendor_unwired`; hosted: nothing discovered (exit 2 `manifest_not_found`, the lock was the only hosted state) — with AND without `--no-verify`, online and offline, zero API requests; with the ledgers gone too: nothing discovered | //! | `reverted-lock-only` (vendored) | lock reverted, the `resolutions` mapping left behind | `vendor_unwired` (with and without `--no-verify`) | //! | `pnp-linker` | the SAME wired lock installed under `nodeLinker: pnp` | the documented PnP contract: standalone vex attests from the lock's `checksum:` pin (hosted) / the committed artifact (vendored); `apply --vex` refuses (`yarn_pnp_unsupported`) | //! @@ -576,6 +576,25 @@ impl<'a> BerryVexFlow<'a> { fn assert_omitted_run(&self, out: &VexOutcome, reason: &str, ctx: &str) { crate::vex_e2e_common::assert_omitted(out, self.purl, reason, ctx); } + + /// A reverted checkout: vendored, the stale ledger entry is + /// `vendor_unwired`; hosted (v5, no ledger), the lock was the only + /// hosted state, so nothing is discovered at all. + fn assert_reverted_run(&self, out: &VexOutcome, ctx: &str) { + match self.wiring { + BerryWiring::Vendored { .. } => { + self.assert_omitted_run(out, self.wiring.unwired_reason(), ctx) + } + BerryWiring::Hosted { .. } => { + assert_eq!(out.code, Some(2), "{ctx}: {out}"); + assert_eq!( + out.envelope["error"]["code"], "manifest_not_found", + "{ctx}: no hosted state is left: {out}" + ); + assert!(out.doc.is_none(), "{ctx}: no document: {out}"); + } + } + } } /// Recursive copy (files and dirs; symlinks are followed). @@ -635,10 +654,16 @@ pub fn run_manifestless_vex_matrix(flow: &BerryVexFlow<'_>) -> Vec { // ── manifest-deleted: ledgers + artifacts travel, the real yarn installs ── let fresh = flow.checkout("vex-manifest-deleted", true); strip_manifest(&fresh); - assert!( - fresh.join(".socket/vendor").is_dir(), - "manifest-deleted: the flow must have left its .socket/vendor ledgers" - ); + if is_hosted { + // v5 hosted mode keeps no redirect ledger: the yarn.lock pin is the + // whole hosted state, so this checkout already has no ledger. + crate::vex_e2e_common::assert_no_hosted_ledger(&fresh, "manifest-deleted"); + } else { + assert!( + fresh.join(socket_patch_core::vendor::VENDOR_STATE_REL).is_file(), + "manifest-deleted: the vendored flow must have left its .socket/vendor ledger" + ); + } flow.install( &fresh, &["install", "--immutable", "--check-cache"], @@ -662,7 +687,12 @@ pub fn run_manifestless_vex_matrix(flow: &BerryVexFlow<'_>) -> Vec { let before = api.request_count(); let ctx = flow.cell_tag("manifest-deleted/offline-ledger"); let out = run_vex(&bin, &fresh, &flow.offline(&api)); - flow.assert_attested_run(&out, &ctx); + if is_hosted { + // No ledger, so no local record: offline, the hosted pin is omitted. + flow.assert_omitted_run(&out, "record_unavailable", &ctx); + } else { + flow.assert_attested_run(&out, &ctx); + } assert_eq!( api.request_count(), before, @@ -861,7 +891,7 @@ pub fn run_manifestless_vex_matrix(flow: &BerryVexFlow<'_>) -> Vec { ..base.clone() }; let out = run_vex(&bin, &reverted, &run); - flow.assert_omitted_run(&out, unwired, &ctx); + flow.assert_reverted_run(&out, &ctx); } } assert_eq!( @@ -874,15 +904,21 @@ pub fn run_manifestless_vex_matrix(flow: &BerryVexFlow<'_>) -> Vec { std::fs::write(reverted.join(flow.installed), flow.patched).unwrap(); let ctx = flow.cell_tag("reverted/stale-patched-tree"); let out = run_vex(&bin, &reverted, &flow.offline(&api)); - flow.assert_omitted_run(&out, unwired, &ctx); + flow.assert_reverted_run(&out, &ctx); std::fs::write(reverted.join(flow.installed), flow.pristine).unwrap(); let ctx = flow.cell_tag("reverted/apply--vex"); let out = run_vex(&bin, &reverted, &flow.offline(&api).via(VexVia::Apply)); - assert_ne!( - out.code, - Some(0), - "{ctx}: a stale ledger fails the requested VEX: {out}" - ); + if is_hosted { + // Nothing references the patch anywhere: manifest-less `apply + // --vex` keeps its calm exit-0 no-op and writes no document. + assert_eq!(out.code, Some(0), "{ctx}: nothing to attest: {out}"); + } else { + assert_ne!( + out.code, + Some(0), + "{ctx}: a stale ledger fails the requested VEX: {out}" + ); + } assert!(out.doc.is_none(), "{ctx}: no document: {out}"); strip_ledgers(&reverted); let ctx = flow.cell_tag("reverted/no-ledgers"); diff --git a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs index 55163ad95..7f7b18206 100644 --- a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs +++ b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs @@ -2,7 +2,6 @@ //! scoped rollback independent of other packages in the same binary lock. use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; use crate::vendor::bun_lockb::BunLockb; -use serde_json::Value; pub(crate) const KIND: &str = "redirect_bun_lockb_package"; @@ -107,91 +106,9 @@ pub fn rewrite_bun_binary(content: &[u8], overrides: &[DepOverride], result: &mu } } -pub(crate) fn names(edit: &FileEdit, name: &str, version: &str) -> Result { - let original = edit - .original - .as_ref() - .ok_or("binary redirect is missing its original snapshot")?; - let new = edit - .new - .as_ref() - .ok_or("binary redirect is missing its rewritten snapshot")?; - for snapshot in [original, new] { - if snapshot.get("name").and_then(Value::as_str).is_none() - || snapshot.get("resolution").and_then(Value::as_str).is_none() - || !matches!( - snapshot.get("version"), - Some(Value::Null | Value::String(_)) - ) - { - return Err("binary redirect snapshot cannot be attributed to a package; restore the ledger before scoped rollback".into()); - } - } - if original["name"] != new["name"] { - return Err("binary redirect snapshots disagree about package ownership".into()); - } - if original["name"].as_str() != Some(name) { - return Ok(false); - } - let recover_version = |snapshot: &Value| -> Option { - if let Some(version) = snapshot["version"] - .as_str() - .filter(|version| semver::Version::parse(version).is_ok()) - { - return Some(version.into()); - } - super::takeover::hosted_url_version(snapshot["resolution"].as_str()?, name).map(Into::into) - }; - let versions: Vec<_> = [original, new] - .into_iter() - .filter_map(recover_version) - .collect(); - let Some(first) = versions.first() else { - return Err("binary redirect snapshot names this package but its version cannot be recovered; restore the ledger before scoped rollback".into()); - }; - if versions.iter().any(|value| value != first) { - return Err("binary redirect snapshots disagree about package version".into()); - } - Ok(first == version) -} - -pub(crate) fn restore(content: &[u8], edit: &FileEdit) -> Result, String> { - if edit.path != "bun.lockb" || edit.kind != KIND { - return Err("unexpected binary lock edit path or kind".into()); - } - let id = edit - .key - .as_deref() - .and_then(|s| s.parse().ok()) - .ok_or("invalid binary package ID")?; - let original = edit - .original - .as_ref() - .ok_or("missing original binary package snapshot")?; - let new = edit - .new - .as_ref() - .ok_or("missing rewritten binary package snapshot")?; - let mut lock = BunLockb::parse(content)?; - // Another duplicate may already have been restored by the preceding - // edit. Resolve the still-hosted record first, so that duplicate's - // original cannot make us skip an active hosted resolution. - let id = match lock.find_snapshot_id(id, new)? { - Some(id) => id, - None if lock.find_snapshot_id(id, original)?.is_some() => return Ok(content.to_vec()), - None => { - return Err("bun.lockb package has drifted from its recorded hosted resolution".into()) - } - }; - lock.restore(id, original)?; - Ok(lock.bytes()) -} - #[cfg(test)] mod tests { - use super::super::{ - revert_npm_redirect_purl, revert_remaining_redirect_edits, Integrity, RedirectState, - }; + use super::super::Integrity; use super::*; use base64::Engine; const FIXTURE: &[u8] = @@ -218,168 +135,6 @@ mod tests { } } - fn state(edits: Vec) -> RedirectState { - let mut state = RedirectState::new(); - state.edits = edits; - for version in ["1.2.2", "1.2.8"] { - state.records.insert( - format!("pkg:npm/minimist@{version}"), - crate::manifest::schema::PatchRecord { - uuid: version.into(), - exported_at: String::new(), - files: Default::default(), - vulnerabilities: Default::default(), - description: String::new(), - license: String::new(), - tier: String::new(), - }, - ); - } - state - } - - #[tokio::test] - async fn scoped_revert_preserves_other_version_and_full_replay_restores_registry() { - let mut result = RewriteResult::default(); - rewrite_bun_binary(FIXTURE, &[dep("1.2.2"), dep("1.2.8")], &mut result); - assert!(result.warnings.is_empty(), "{:?}", result.warnings); - assert_eq!(result.edits.len(), 2); - let bytes = &result.binary_files["bun.lockb"]; - let mut rerun = RewriteResult::default(); - rewrite_bun_binary(bytes, &[dep("1.2.2"), dep("1.2.8")], &mut rerun); - assert!(rerun.edits.is_empty()); - assert!(rerun.binary_files.is_empty()); - assert_eq!(rerun.confirmed_bun_binary_uuids.len(), 2); - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("bun.lockb"), bytes).unwrap(); - let mut ledger = state(result.edits); - revert_npm_redirect_purl( - dir.path(), - &mut ledger.clone(), - "pkg:npm/minimist@1.2.2", - true, - ) - .await - .unwrap(); - assert_eq!(std::fs::read(dir.path().join("bun.lockb")).unwrap(), *bytes); - revert_npm_redirect_purl(dir.path(), &mut ledger, "pkg:npm/minimist@1.2.2", false) - .await - .unwrap(); - assert_eq!(ledger.edits.len(), 1); - assert!(ledger.records.contains_key("pkg:npm/minimist@1.2.8")); - let partial = - BunLockb::parse(&std::fs::read(dir.path().join("bun.lockb")).unwrap()).unwrap(); - let packages = partial.packages().unwrap(); - assert!(packages - .iter() - .any(|p| p.name == "minimist" && p.version.as_deref() == Some("1.2.2"))); - assert!(packages - .iter() - .any(|p| p.resolution == dep("1.2.8").artifact_url)); - let replay = revert_remaining_redirect_edits(dir.path(), &mut ledger, false).await; - assert!(replay.refusals.is_empty(), "{:?}", replay.refusals); - assert!(ledger.edits.is_empty()); - let restored = - BunLockb::parse(&std::fs::read(dir.path().join("bun.lockb")).unwrap()).unwrap(); - assert_eq!( - restored.packages().unwrap(), - BunLockb::parse(FIXTURE).unwrap().packages().unwrap() - ); - } - - #[tokio::test] - async fn whole_replay_is_byte_exact_and_drift_is_transactional() { - let mut result = RewriteResult::default(); - rewrite_bun_binary(FIXTURE, &[dep("1.2.2"), dep("1.2.8")], &mut result); - let dir = tempfile::tempdir().unwrap(); - let bytes = result.binary_files["bun.lockb"].clone(); - std::fs::write(dir.path().join("bun.lockb"), &bytes).unwrap(); - // The whole-ledger replay restores the binary lock through the - // mode-preserving atomic writer, like the per-purl path. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions( - dir.path().join("bun.lockb"), - std::fs::Permissions::from_mode(0o600), - ) - .unwrap(); - } - let mut ledger = state(result.edits); - let replay = revert_remaining_redirect_edits(dir.path(), &mut ledger.clone(), false).await; - assert!(replay.refusals.is_empty(), "{:?}", replay.refusals); - assert_eq!( - std::fs::read(dir.path().join("bun.lockb")).unwrap(), - FIXTURE - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(dir.path().join("bun.lockb")) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600, - "bun.lockb keeps its mode across the replay" - ); - } - let mut drift = BunLockb::parse(&bytes).unwrap(); - let first_id = ledger.edits[0].key.as_ref().unwrap().parse().unwrap(); - drift - .set_package( - first_id, - "https://other.test/minimist-1.2.2.tgz", - dep("1.2.2").integrity.sha512.as_ref().unwrap(), - ) - .unwrap(); - let drift = drift.bytes(); - std::fs::write(dir.path().join("bun.lockb"), &drift).unwrap(); - let before = ledger.edits.clone(); - let replay = revert_remaining_redirect_edits(dir.path(), &mut ledger, false).await; - assert_eq!(replay.refusals.len(), 1); - assert_eq!(ledger.edits, before); - assert_eq!(std::fs::read(dir.path().join("bun.lockb")).unwrap(), drift); - assert!( - revert_npm_redirect_purl(dir.path(), &mut ledger, "pkg:npm/minimist@1.2.2", true) - .await - .is_err() - ); - } - - #[tokio::test] - async fn scoped_binary_revert_refuses_unattributable_snapshots_without_dropping_records() { - let mut result = RewriteResult::default(); - rewrite_bun_binary(FIXTURE, &[dep("1.2.2")], &mut result); - let root = tempfile::tempdir().unwrap(); - let bytes = &result.binary_files["bun.lockb"]; - std::fs::write(root.path().join("bun.lockb"), bytes).unwrap(); - for malformed in [ - serde_json::json!({"resolution": "https://patch.example.test/1.2.2/minimist-1.2.2.tgz", "version": null}), - serde_json::json!({"name": "minimist", "version": 12, "resolution": "https://patch.example.test/1.2.2/minimist-1.2.2.tgz"}), - ] { - let mut ledger = state(result.edits.clone()); - ledger.edits[0].new = Some(malformed); - let original = serde_json::to_value(&ledger).unwrap(); - for dry_run in [true, false] { - assert!(revert_npm_redirect_purl( - root.path(), - &mut ledger, - "pkg:npm/minimist@1.2.2", - dry_run - ) - .await - .is_err()); - assert_eq!(serde_json::to_value(&ledger).unwrap(), original); - assert_eq!( - std::fs::read(root.path().join("bun.lockb")).unwrap(), - *bytes - ); - } - } - } - #[test] fn malformed_metahash_cannot_confirm_an_existing_binary_redirect() { let mut result = RewriteResult::default(); @@ -399,47 +154,6 @@ mod tests { .any(|w| w.code == "redirect_bun_lockb_invalid")); } - #[tokio::test] - async fn scoped_binary_revert_refuses_same_name_without_a_recoverable_version() { - let mut result = RewriteResult::default(); - rewrite_bun_binary(FIXTURE, &[dep("1.2.2")], &mut result); - let root = tempfile::tempdir().unwrap(); - let bytes = &result.binary_files["bun.lockb"]; - std::fs::write(root.path().join("bun.lockb"), bytes).unwrap(); - let mut ledger = state(result.edits); - let edit = &mut ledger.edits[0]; - for snapshot in [&mut edit.original, &mut edit.new] { - let value = snapshot.as_mut().unwrap(); - value["version"] = Value::Null; - value["resolution"] = - Value::String("https://patch.example.test/unattributable.tgz".into()); - } - let before = serde_json::to_value(&ledger).unwrap(); - for dry_run in [true, false] { - assert!(revert_npm_redirect_purl( - root.path(), - &mut ledger, - "pkg:npm/minimist@1.2.2", - dry_run - ) - .await - .unwrap_err() - .contains("version cannot be recovered")); - assert_eq!(serde_json::to_value(&ledger).unwrap(), before); - assert_eq!( - std::fs::read(root.path().join("bun.lockb")).unwrap(), - *bytes - ); - } - // A known sibling version remains attributable and is left untouched. - let edit = &mut ledger.edits[0]; - for snapshot in [&mut edit.original, &mut edit.new] { - snapshot.as_mut().unwrap()["resolution"] = Value::String(dep("1.2.8").artifact_url); - } - assert!(!names(&ledger.edits[0], "minimist", "1.2.2").unwrap()); - assert!(names(&ledger.edits[0], "minimist", "1.2.8").unwrap()); - } - #[test] fn bad_digest_and_missing_version_leave_binary_untouched() { let mut invalid = dep("1.2.2"); @@ -453,44 +167,4 @@ mod tests { assert_eq!(result.warnings.len(), 1); } } - - #[tokio::test] - async fn duplicate_binary_records_are_all_restored_before_dropping_the_ledger() { - let mut duplicate = BunLockb::parse(FIXTURE).unwrap(); - let packages: Vec<_> = duplicate - .packages() - .unwrap() - .into_iter() - .filter(|p| p.name == "minimist") - .collect(); - assert_eq!(packages.len(), 2); - let first = packages - .iter() - .find(|p| p.version.as_deref() == Some("1.2.2")) - .unwrap(); - let other = packages.iter().find(|p| p.id != first.id).unwrap(); - let original = duplicate.snapshot(first.id).unwrap(); - duplicate.restore(other.id, &original).unwrap(); - let before = duplicate.bytes(); - let mut rewritten = RewriteResult::default(); - rewrite_bun_binary(&before, &[dep("1.2.2")], &mut rewritten); - assert_eq!( - rewritten.edits.len(), - 2, - "both identical package records are redirected" - ); - let tmp = tempfile::tempdir().unwrap(); - std::fs::write( - tmp.path().join("bun.lockb"), - &rewritten.binary_files["bun.lockb"], - ) - .unwrap(); - let mut ledger = state(rewritten.edits); - let reverted = revert_remaining_redirect_edits(tmp.path(), &mut ledger, false).await; - assert!(reverted.refusals.is_empty(), "{:?}", reverted.refusals); - assert!(ledger.edits.is_empty()); - let restored = - BunLockb::parse(&std::fs::read(tmp.path().join("bun.lockb")).unwrap()).unwrap(); - assert_eq!(restored.packages().unwrap(), duplicate.packages().unwrap()); - } } diff --git a/crates/socket-patch-core/src/patch/redirect/hosted_url.rs b/crates/socket-patch-core/src/patch/redirect/hosted_url.rs new file mode 100644 index 000000000..4f9f780d1 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/hosted_url.rs @@ -0,0 +1,46 @@ +//! Hosted-artifact URL leaf ownership: whether a hosted tarball URL names a +//! given npm package version. Shared by the bun binary lock rewriter and the +//! manifest-less VEX discovery of bun / vlt hosted refs. + +/// True when `url` is an http(s) artifact URL whose last path segment is +/// `-.tgz` — the leaf every hosted artifact URL for this +/// `name@version` ends in. `` is the name without its `@scope/`: the +/// vendor path layer (`tgz_rel_leaf`) keeps a scope as a directory level +/// (`@scope/pkg-1.0.0.tgz`), and the hosted rewriter's prior-URL match +/// (`is_prior_hosted_bun_spec`) compares the same last path segment, so +/// `pkg-1.0.0.tgz` is the one spelling both agree on. Anything that fails +/// to parse fails the match (closed). The exact-leaf comparison is the +/// version discriminator: `pkg-1.3.0.tgz` never equals `pkg-11.3.0.tgz` +/// or `pkg-1.3.0-rc1.tgz`. +pub(crate) fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { + if !url.starts_with("https://") && !url.starts_with("http://") { + return false; + } + let scheme_end = url + .find("://") + .expect("url starts with http(s):// — checked above") + + 3; + let Some(path_start) = url[scheme_end..].find('/').map(|i| i + scheme_end) else { + return false; + }; + let leaf = url[path_start..].rsplit('/').next().unwrap_or_default(); + let bare = name.rsplit('/').next().unwrap_or(name); + !leaf.is_empty() && leaf == format!("{bare}-{version}.tgz") +} + +/// The version a hosted artifact `url` names for `name`: its last path +/// segment is `-.tgz` with a semver ``, confirmed by +/// [`hosted_url_names`]. How a hosted bun binary redirect's version is +/// recovered (`bun_binary::names`) and how lockfile discovery reads a bun +/// hosted ref's version. +pub(crate) fn hosted_url_version<'u>(url: &'u str, name: &str) -> Option<&'u str> { + let bare = name.rsplit('/').next().unwrap_or(name); + let version = url + .rsplit('/') + .next()? + .strip_prefix(bare)? + .strip_prefix('-')? + .strip_suffix(".tgz")?; + (semver::Version::parse(version).is_ok() && hosted_url_names(url, name, version)) + .then_some(version) +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 9f03dbaa4..98fd79304 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -53,28 +53,23 @@ mod pnpm_equivalence_tests; mod poetry; #[cfg(test)] mod python_lock_equivalence_tests; -mod replay; mod requirements; #[cfg(test)] mod rewrite_oracle_support; mod staged; mod state; -mod takeover; +mod hosted_url; +pub mod upstream; pub mod vlt; pub mod vlt_heal; pub mod vlt_preflight; -pub use replay::{revert_remaining_redirect_edits, GroupRefusal, ReplayOutcome}; pub use state::{ - drop_superseded_purl, load_redirect_state, persist_redirect_state, save_redirect_state, + load_redirect_state, save_redirect_state, CorruptRedirectState, RedirectState, REDIRECT_STATE_REL, }; /// Hosted-artifact leaf ownership rule, shared with `vex`'s bun lockfile /// discovery (which recovers a URL tuple's version from that leaf). -pub(crate) use takeover::{hosted_url_names, hosted_url_version}; -pub use takeover::{ - redirect_revert_supported, revert_cargo_redirect_purl, revert_golang_redirect_purl, - revert_npm_redirect_purl, revert_redirect_purl, RedirectRevert, -}; +pub(crate) use hosted_url::{hosted_url_names, hosted_url_version}; /// One ecosystem's integrity hashes (mirrors the TS `PatchArtifactIntegrity`). #[derive(Debug, Clone, Default, Deserialize)] @@ -3869,18 +3864,6 @@ fn yarn_classic_block_head(block: &str) -> Option<(String, Option)> { /// can reproduce offline; matches the vendored backend's `SUPPORTED_CACHE_KEY`. const YARN_BERRY_SUPPORTED_CACHE_KEY: &str = "10c0"; -/// Whether ledger edits of `kind` are yarn.lock blocks — the fragments the -/// yarn rewriters record in the lock's ON-DISK line endings, which the -/// reverts (the per-purl takeover and the whole-ledger replay) may respell -/// in the live lock's ending when a `core.autocrlf` checkout changed it -/// ([`crate::utils::line_endings::fragments_in_eol_of`]). -pub(crate) fn yarn_lock_fragment_kind(kind: &str) -> bool { - matches!( - kind, - "redirect_yarn_berry_entry" | "redirect_yarn_classic_entry" - ) -} - /// A yarn.lock is berry (v2+) when it carries the `__metadata:` header block; /// anything else is a classic v1 lock. Shared by both yarn rewriters and /// lockfile discovery (`vex::discover::yarn`) so the grammar split cannot diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs index e8497d85d..ac102ef78 100644 --- a/crates/socket-patch-core/src/patch/redirect/npmrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -5,12 +5,9 @@ //! lockfile entry whose `resolved` tarball URL is not served by the //! configured registry — exactly what a hosted redirect writes. The hosted //! flow therefore ensures `allow-remote=all` in the project `.npmrc` -//! (creating the file, or appending one line) and records the edit in the -//! redirect ledger under [`NPMRC_ALLOW_REMOTE_EDIT_KIND`], so every unwind -//! path (rollback / remove replay, the per-purl npm revert behind scoped -//! rollback and the vendored takeover, the vendored-supersedes-hosted -//! reconcile) removes exactly what was added once no redirected npm lock -//! entry needs it any more. +//! (creating the file, or appending one line) and reports the edit under +//! [`NPMRC_ALLOW_REMOTE_EDIT_KIND`]. The upstream restore (`upstream::npm`) +//! removes the line again once no hosted npm lock entry needs it. //! //! The `.npmrc` grammar here is npm's as MEASURED against npm 12.1.0 //! (`npm config get allow-remote` plus a real EALLOWREMOTE/ENOTFOUND install @@ -36,9 +33,7 @@ //! and — when the project file is silent — the user / global / builtin //! config files ([`resolve_outer_allow_remote`]). -use std::collections::HashSet; -use super::FileEdit; /// Repo-relative path of the project `.npmrc` the auto-config edits. pub const NPMRC_REL: &str = ".npmrc"; @@ -58,11 +53,6 @@ pub const NPMRC_ALLOW_REMOTE_LINE: &str = "allow-remote=all"; /// The exact `.npmrc` the auto-config CREATES when none existed. pub const NPMRC_CREATED: &str = "allow-remote=all\n"; -/// The ledger kinds that record a package-lock.json / npm-shrinkwrap.json -/// hosted splice — the entries that NEED `allow-remote=all` on npm >= 12. -/// While any of them remains in the ledger the `.npmrc` edit stays. -pub const NPM_LOCK_EDIT_KINDS: [&str; 2] = ["redirect_npm_lock_entry", "redirect_npm_lock_dep"]; - const BOM: char = '\u{feff}'; /// ECMAScript whitespace — what npm's `ini` means by `\s` and by @@ -637,303 +627,10 @@ pub fn plan_npmrc_allow_remote_with(existing: Option<&str>, outer: &OuterAllowRe NpmrcPlan::Append(format!("{bom}{}", lines.join("\n"))) } -/// What unwinding one recorded `.npmrc` edit does to the live file. -#[derive(Debug, PartialEq)] -pub enum NpmrcUnwind { - /// The file is gone, or no longer carries the line — already clean. - Unchanged, - /// Delete the file (a `created` edit whose file still holds exactly - /// [`NPMRC_CREATED`]). - Delete, - /// Write this content (the one line removed). `modified_created` is - /// set when a `created` file had grown other content: it is kept and - /// only the line goes, which callers surface as - /// `redirect_npmrc_allow_remote_modified`. - Write { - content: String, - modified_created: bool, - }, -} - -/// Is `line` (one `\n`-split element, maybe carrying a CRLF `\r` or the -/// file's BOM) exactly the line the auto-config writes? -fn is_our_line(line: &str) -> bool { - line.trim_start_matches(BOM).trim_end_matches('\r') == NPMRC_ALLOW_REMOTE_LINE -} - -/// Unwind one recorded `.npmrc` edit (`action` `created` / `added`) against -/// the live `content` (`None` = file absent). Exact inverse of -/// [`plan_npmrc_allow_remote`]'s splice: the one matching line is removed -/// with its line terminator, every other byte kept. Only TOP-LEVEL lines -/// (before the first real ini `[section]` header — the scope the plan -/// writes into and npm reads the key from) count: a copy under a section -/// is inert user text, never ours. `Err` when the line appears more than -/// once at top level (ambiguous — refuse rather than guess which copy the -/// redirect owns). -pub fn unwind_npmrc_allow_remote( - action: &str, - content: Option<&str>, -) -> Result { - let Some(content) = content else { - return Ok(NpmrcUnwind::Unchanged); - }; - if action == "created" && content == NPMRC_CREATED { - return Ok(NpmrcUnwind::Delete); - } - let (bom, body) = match content.strip_prefix(BOM) { - Some(rest) => (&content[..BOM.len_utf8()], rest), - None => ("", content), - }; - let mut lines: Vec<&str> = body.split('\n').collect(); - let end = top_level_end(bom, &lines); - let hits: Vec = lines[..end] - .iter() - .enumerate() - .filter(|(_, l)| is_our_line(l)) - .map(|(i, _)| i) - .collect(); - match hits.as_slice() { - [] => Ok(NpmrcUnwind::Unchanged), - [i] => { - lines.remove(*i); - let rest = lines.join("\n"); - // A created file reduced to nothing but its BOM/whitespace is the - // redirect's own file: delete it rather than leave a husk. - if action == "created" && rest.trim().is_empty() { - return Ok(NpmrcUnwind::Delete); - } - Ok(NpmrcUnwind::Write { - content: format!("{bom}{rest}"), - modified_created: action == "created", - }) - } - _ => Err(format!( - "{NPMRC_REL}: the `{NPMRC_ALLOW_REMOTE_LINE}` line appears more than once — \ - ambiguous, refusing to guess which copy the hosted redirect added; remove the \ - duplicate, then re-run" - )), - } -} - -/// The advisory for a redirect-created `.npmrc` that was modified since. -pub fn npmrc_modified_warning() -> (String, String) { - ( - "redirect_npmrc_allow_remote_modified".to_string(), - format!( - "{NPMRC_REL} was created by the hosted redirect but has been modified since — kept \ - the file and removed only the `{NPMRC_ALLOW_REMOTE_LINE}` line" - ), - ) -} - -/// The unwind of every recorded `.npmrc` edit once no redirected npm lock -/// entry needs `allow-remote=all` any more. -#[derive(Debug, Default)] -pub struct NpmrcUnwindPlan { - /// Ledger indices of the `.npmrc` edits to drop. - pub indices: Vec, - /// `None` — the file needs no change; `Some(None)` — delete it; - /// `Some(Some(text))` — write `text`. - pub staged: Option>, - /// Advisory (code, detail) pairs. - pub warnings: Vec<(String, String)>, -} - -/// Is an unwind of the recorded `.npmrc` edit(s) due once `dropping` (the -/// ledger indices the caller is about to remove) is gone? True iff a -/// [`NPMRC_ALLOW_REMOTE_EDIT_KIND`] edit survives while no -/// [`NPM_LOCK_EDIT_KINDS`] edit does. Callers check this BEFORE reading the -/// live `.npmrc`, so a file that merely has an odd shape never refuses an -/// unrelated revert while the setting is still needed. -pub fn npmrc_unwind_due(edits: &[FileEdit], dropping: &HashSet) -> bool { - let live = |kinds: &[&str]| { - edits - .iter() - .enumerate() - .any(|(i, e)| !dropping.contains(&i) && kinds.contains(&e.kind.as_str())) - }; - live(&[NPMRC_ALLOW_REMOTE_EDIT_KIND]) && !live(&NPM_LOCK_EDIT_KINDS) -} - -/// "Last one out turns off the lights" for the `.npmrc` auto-config: when -/// no [`NPM_LOCK_EDIT_KINDS`] edit survives outside `dropping` (the indices -/// the caller is about to remove), plan the unwind of every recorded -/// [`NPMRC_ALLOW_REMOTE_EDIT_KIND`] edit, newest first, against `current` -/// (the live `.npmrc`). `Ok(None)` when an npm lock edit still needs the -/// setting, or there is no `.npmrc` edit to unwind. `Err` on an ambiguous -/// file or a tampered ledger path (callers fail closed). -pub fn plan_unneeded_npmrc_unwind( - edits: &[FileEdit], - dropping: &HashSet, - current: Option, -) -> Result, String> { - let still_needed = edits - .iter() - .enumerate() - .any(|(i, e)| !dropping.contains(&i) && NPM_LOCK_EDIT_KINDS.contains(&e.kind.as_str())); - if still_needed { - return Ok(None); - } - let indices: Vec = edits - .iter() - .enumerate() - .filter(|(i, e)| !dropping.contains(i) && e.kind == NPMRC_ALLOW_REMOTE_EDIT_KIND) - .map(|(i, _)| i) - .collect(); - if indices.is_empty() { - return Ok(None); - } - let mut plan = NpmrcUnwindPlan { - indices: indices.clone(), - ..NpmrcUnwindPlan::default() - }; - let mut content = current; - for &i in indices.iter().rev() { - let edit = &edits[i]; - if edit.path != NPMRC_REL { - return Err(format!( - "the redirect ledger records a {} edit for `{}` (expected `{NPMRC_REL}`); \ - refusing to touch it", - edit.kind, edit.path - )); - } - match unwind_npmrc_allow_remote(&edit.action, content.as_deref())? { - NpmrcUnwind::Unchanged => {} - NpmrcUnwind::Delete => { - content = None; - plan.staged = Some(None); - } - NpmrcUnwind::Write { - content: next, - modified_created, - } => { - if modified_created { - plan.warnings.push(npmrc_modified_warning()); - } - content = Some(next.clone()); - plan.staged = Some(Some(next)); - } - } - } - Ok(Some(plan)) -} - -/// Read the live project `.npmrc` for an unwind: `Ok(None)` when absent. -/// Refuses (`Err`) a symlink or any non-regular file HERE, at plan time — -/// [`flush_npmrc`] would refuse it too, but only after the caller's other -/// staged files (the reverted lock) had already been written, leaving the -/// lock unwound while the ledger still records the redirect. FIFO-safe -/// (non-blocking open + fstat), so a planted FIFO refuses fast instead of -/// wedging the run. -pub fn read_project_npmrc(project_root: &std::path::Path) -> Result, String> { - let path = project_root.join(NPMRC_REL); - match std::fs::symlink_metadata(&path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(format!("inspect {NPMRC_REL}: {e}")), - Ok(meta) if !meta.is_file() => { - return Err(format!( - "{NPMRC_REL} is not a regular file (a symlink, directory or special file); \ - socket-patch never writes through one — replace it with a regular file, \ - then re-run" - )) - } - Ok(_) => {} - } - match crate::utils::fs::read_regular_to_string_sync(&path) { - Ok(text) => Ok(Some(text)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(format!("read {NPMRC_REL}: {e}")), - } -} - -/// Write (or delete) a planned `.npmrc` unwind through the reverts' shared -/// staged flush: it refuses a non-regular file (a symlink or FIFO planted at -/// the path) instead of writing through it, and writes atomically keeping -/// the file's mode. -pub async fn flush_npmrc( - project_root: &std::path::Path, - staged: &Option, -) -> Result<(), String> { - let one = super::staged::Staged::from([(NPMRC_REL.to_string(), staged.clone())]); - super::staged::flush_staged(project_root, &one, &super::staged::StagedBytes::new()).await -} - -/// What [`unwind_unneeded_npmrc`] did. -#[derive(Debug, Default, PartialEq)] -pub struct NpmrcStandaloneUnwind { - /// The `.npmrc` itself was (or, on a dry run, would be) rewritten or - /// deleted. - pub file_changed: bool, - /// At least one recorded `.npmrc` edit was dropped from the ledger. - pub edits_dropped: bool, - /// Advisory (code, detail) pairs (e.g. - /// `redirect_npmrc_allow_remote_modified`). - pub warnings: Vec<(String, String)>, -} - -/// Standalone "last one out" pass over a ledger the caller has just pruned -/// WITHOUT an on-disk revert (the vendored-supersedes-hosted reconcile): -/// when no npm lock edit remains, unwind the recorded `.npmrc` edits on -/// disk (unless `dry_run`) and drop them from `state`. Returns what changed -/// plus the advisory warnings (the caller surfaces them); the caller -/// persists `state`. The ledger is only consulted — and the file only -/// read — when it records a `.npmrc` edit at all. -pub async fn unwind_unneeded_npmrc( - project_root: &std::path::Path, - state: &mut super::RedirectState, - dry_run: bool, -) -> Result { - if !npmrc_unwind_due(&state.edits, &HashSet::new()) { - // Nothing recorded, or still needed: no read, no refusal over the - // file's shape. - return Ok(NpmrcStandaloneUnwind::default()); - } - let current = read_project_npmrc(project_root)?; - let Some(plan) = plan_unneeded_npmrc_unwind(&state.edits, &HashSet::new(), current)? else { - return Ok(NpmrcStandaloneUnwind::default()); - }; - if !dry_run { - if let Some(staged) = &plan.staged { - flush_npmrc(project_root, staged).await?; - } - } - let drop: HashSet = plan.indices.iter().copied().collect(); - let mut idx = 0usize; - state.edits.retain(|_| { - let keep = !drop.contains(&idx); - idx += 1; - keep - }); - Ok(NpmrcStandaloneUnwind { - file_changed: plan.staged.is_some(), - edits_dropped: !plan.indices.is_empty(), - warnings: plan.warnings, - }) -} - #[cfg(test)] mod tests { use super::*; - fn edit(kind: &str, action: &str) -> FileEdit { - FileEdit { - path: if kind == NPMRC_ALLOW_REMOTE_EDIT_KIND { - NPMRC_REL.into() - } else { - "package-lock.json".into() - }, - kind: kind.into(), - action: action.into(), - key: Some(if kind == NPMRC_ALLOW_REMOTE_EDIT_KIND { - "allow-remote".into() - } else { - "node_modules/left-pad".into() - }), - original: None, - new: Some(serde_json::json!("all")), - } - } - /// The measured npm 12.1.0 grammar (see the module doc): exact key /// spelling only, BOM / whitespace / CRLF / quotes / inline comments /// tolerated, comment lines and `[section]` bodies ignored, LAST @@ -1081,44 +778,6 @@ mod tests { assert_eq!(text, "\u{feff}[x]\nallow-remote=all\n[sec]\ny=1\n"); } - /// `[sec]\nallow-remote=all\n` (inert under a section) plans a - /// top-level append, so the unwind must count only top-level lines, - /// like the plan — counting BOTH copies would refuse as ambiguous and - /// block rollback, remove, the vendored takeover and the reconcile over - /// a state our own writer created. - #[test] - fn unwind_ignores_section_scoped_copies() { - let before = "[sec]\nallow-remote=all\n"; - let NpmrcPlan::Append(text) = plan_npmrc_allow_remote(Some(before)) else { - panic!("append expected"); - }; - assert_eq!(text, "allow-remote=all\n[sec]\nallow-remote=all\n"); - assert_eq!( - unwind_npmrc_allow_remote("added", Some(&text)).unwrap(), - NpmrcUnwind::Write { - content: before.into(), - modified_created: false - } - ); - // Only a section copy left: nothing of ours to remove. - assert_eq!( - unwind_npmrc_allow_remote("added", Some(before)).unwrap(), - NpmrcUnwind::Unchanged - ); - // The ledger-level plan agrees (the per-purl revert / reconcile path). - let edits = vec![edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added")]; - let plan = plan_unneeded_npmrc_unwind(&edits, &HashSet::new(), Some(text)) - .unwrap() - .expect("unwind planned"); - assert_eq!(plan.staged, Some(Some(before.into()))); - // Two TOP-LEVEL copies are still genuinely ambiguous. - assert!(unwind_npmrc_allow_remote( - "added", - Some("allow-remote=all\nallow-remote=all\n[sec]\nallow-remote=all\n") - ) - .is_err()); - } - fn cfg_env(vars: &[(&str, &str)]) -> NpmConfigEnv { NpmConfigEnv { vars: vars @@ -1444,35 +1103,6 @@ mod tests { assert_eq!(strip_verbatim(unc.clone(), true), unc); } - #[test] - fn unwind_due_only_when_no_npm_lock_edit_survives() { - let edits = vec![ - edit("redirect_npm_lock_entry", "rewritten"), - edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "created"), - ]; - assert!(!npmrc_unwind_due(&edits, &HashSet::new())); - assert!(npmrc_unwind_due(&edits, &HashSet::from([0]))); - assert!(!npmrc_unwind_due(&edits, &HashSet::from([0, 1]))); - assert!(!npmrc_unwind_due(&edits[..1], &HashSet::new())); - } - - /// The read used while PLANNING refuses a symlinked `.npmrc`, so the - /// per-purl revert never learns it is unwritable only at flush time, - /// after the reverted lock had landed. - #[cfg(unix)] - #[test] - fn read_project_npmrc_refuses_a_symlink_at_plan_time() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(read_project_npmrc(dir.path()), Ok(None)); - std::fs::write(dir.path().join("shared.npmrc"), "allow-remote=all\n").unwrap(); - std::os::unix::fs::symlink("shared.npmrc", dir.path().join(".npmrc")).unwrap(); - let err = read_project_npmrc(dir.path()).unwrap_err(); - assert!(err.contains("not a regular file"), "{err}"); - std::fs::remove_file(dir.path().join(".npmrc")).unwrap(); - std::fs::create_dir(dir.path().join(".npmrc")).unwrap(); - assert!(read_project_npmrc(dir.path()).is_err()); - } - #[test] fn plan_creates_when_absent() { assert_eq!( @@ -1481,49 +1111,6 @@ mod tests { ); } - #[test] - fn plan_appends_preserving_bytes_and_round_trips() { - let cases = [ - ( - "registry=https://r.example/\n", - "registry=https://r.example/\nallow-remote=all\n", - ), - ("a=1", "a=1\nallow-remote=all"), - ("a=1\n\n", "a=1\nallow-remote=all\n\n"), - ("a=1\r\nb=2\r\n", "a=1\r\nb=2\r\nallow-remote=all\r\n"), - ("\u{feff}a=1\n", "\u{feff}a=1\nallow-remote=all\n"), - ("", "allow-remote=all\n"), - ("\u{feff}", "\u{feff}allow-remote=all\n"), - ("\n", "allow-remote=all\n\n"), - ("; team config\n", "; team config\nallow-remote=all\n"), - // An `allow_remote` spelling npm ignores stays byte-identical. - ("allow_remote=all\n", "allow_remote=all\nallow-remote=all\n"), - // Never inside an ini section: before the first header. - ("a=1\n[sec]\nx=1\n", "a=1\nallow-remote=all\n[sec]\nx=1\n"), - ("[sec]\nx=1\n", "allow-remote=all\n[sec]\nx=1\n"), - ]; - for (before, after) in cases { - let NpmrcPlan::Append(text) = plan_npmrc_allow_remote(Some(before)) else { - panic!("{before:?} must plan an append"); - }; - assert_eq!(text, after, "append into {before:?}"); - assert_eq!( - npmrc_allow_remote(&text).as_deref(), - Some("all"), - "{text:?}" - ); - // Exact inverse: the unwind restores the user's bytes. - assert_eq!( - unwind_npmrc_allow_remote("added", Some(&text)).unwrap(), - NpmrcUnwind::Write { - content: before.to_string(), - modified_created: false - }, - "unwind of {text:?}" - ); - } - } - #[test] fn plan_respects_existing_values() { for text in [ @@ -1551,110 +1138,4 @@ mod tests { } } - #[test] - fn unwind_created_file() { - assert_eq!( - unwind_npmrc_allow_remote("created", Some(NPMRC_CREATED)).unwrap(), - NpmrcUnwind::Delete - ); - assert_eq!( - unwind_npmrc_allow_remote("created", None).unwrap(), - NpmrcUnwind::Unchanged - ); - // Grown since: keep the file, drop only our line, say so. - assert_eq!( - unwind_npmrc_allow_remote("created", Some("allow-remote=all\nfund=false\n")).unwrap(), - NpmrcUnwind::Write { - content: "fund=false\n".into(), - modified_created: true - } - ); - // The user already removed our line: nothing to do. - assert_eq!( - unwind_npmrc_allow_remote("added", Some("fund=false\n")).unwrap(), - NpmrcUnwind::Unchanged - ); - // A commented-out copy is not our line. - assert_eq!( - unwind_npmrc_allow_remote("added", Some("; allow-remote=all\n")).unwrap(), - NpmrcUnwind::Unchanged - ); - // Ambiguous duplicates refuse. - assert!( - unwind_npmrc_allow_remote("added", Some("allow-remote=all\nallow-remote=all\n")) - .is_err() - ); - } - - #[test] - fn last_one_out_keeps_the_setting_while_npm_lock_edits_remain() { - let edits = vec![ - edit("redirect_npm_lock_entry", "rewritten"), - edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "created"), - ]; - // The lock edit survives: the setting is still needed. - assert!( - plan_unneeded_npmrc_unwind(&edits, &HashSet::new(), Some(NPMRC_CREATED.into())) - .unwrap() - .is_none() - ); - // The lock edit is being dropped: unwind (delete the created file). - let plan = - plan_unneeded_npmrc_unwind(&edits, &HashSet::from([0]), Some(NPMRC_CREATED.into())) - .unwrap() - .expect("unwind planned"); - assert_eq!(plan.indices, vec![1]); - assert_eq!(plan.staged, Some(None)); - // A pnpm-only ledger with a stray `.npmrc` edit also unwinds. - let edits = vec![ - edit("redirect_pnpm_resolution", "rewritten"), - edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added"), - ]; - let plan = plan_unneeded_npmrc_unwind( - &edits, - &HashSet::new(), - Some("a=1\nallow-remote=all\n".into()), - ) - .unwrap() - .expect("unwind planned"); - assert_eq!(plan.staged, Some(Some("a=1\n".into()))); - // A tampered ledger path refuses. - let mut bad = edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added"); - bad.path = "../.npmrc".into(); - assert!(plan_unneeded_npmrc_unwind(&[bad], &HashSet::new(), None).is_err()); - } - - #[tokio::test] - async fn standalone_unwind_removes_only_our_line_and_drops_the_edit() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write( - dir.path().join(".npmrc"), - "fund=false\r\nallow-remote=all\r\n", - ) - .unwrap(); - let mut state = super::super::RedirectState::new(); - state - .edits - .push(edit(NPMRC_ALLOW_REMOTE_EDIT_KIND, "added")); - // Dry run: nothing written, edit still dropped from the (throwaway) state. - let mut probe = state.clone(); - unwind_unneeded_npmrc(dir.path(), &mut probe, true) - .await - .unwrap(); - assert!(probe.edits.is_empty()); - assert_eq!( - std::fs::read_to_string(dir.path().join(".npmrc")).unwrap(), - "fund=false\r\nallow-remote=all\r\n" - ); - let out = unwind_unneeded_npmrc(dir.path(), &mut state, false) - .await - .unwrap(); - assert!(out.warnings.is_empty()); - assert!(out.file_changed && out.edits_dropped, "{out:?}"); - assert!(state.edits.is_empty()); - assert_eq!( - std::fs::read_to_string(dir.path().join(".npmrc")).unwrap(), - "fund=false\r\n" - ); - } } diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs index 7725c9e62..1eaf8cfda 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs @@ -7,10 +7,10 @@ use serde_json::{json, Value}; use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -struct Property { - name: String, - range: Range, - value: Value, +pub(super) struct Property { + pub(super) name: String, + pub(super) range: Range, + pub(super) value: Value, } fn properties(text: &str, offset: usize) -> Result, String> { @@ -82,7 +82,7 @@ fn properties(text: &str, offset: usize) -> Result, String> { } } -fn entries(text: &str) -> Result, String> { +pub(super) fn entries(text: &str) -> Result, String> { // A UTF-8 BOM (Windows editors) is not JSON; parse past it. Offsets // below come from `text.find('{')`, so they stay byte-accurate. let value = @@ -111,7 +111,7 @@ fn entries(text: &str) -> Result, String> { Ok(result) } -fn format_entry(value: &Value, text: &str, start: usize) -> Result { +pub(super) fn format_entry(value: &Value, text: &str, start: usize) -> Result { let mut bytes = Vec::new(); let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); value @@ -162,82 +162,6 @@ pub(super) fn reserialized_around_reference(live: &Value, ours: &Value) -> bool .all(|key| live.get(key) == ours.get(key)) } -pub(super) fn restore(text: &str, edit: &FileEdit) -> Result { - if edit.path != "Pipfile.lock" { - return Err("Pipenv edit must target Pipfile.lock".into()); - } - let [section, name]: [String; 2] = - serde_json::from_str(edit.key.as_deref().ok_or("missing Pipenv key")?) - .map_err(|e| e.to_string())?; - let original = edit - .original - .as_ref() - .and_then(Value::as_str) - .ok_or("missing Pipenv original")?; - // The ledger is committed and tamper-able: only a JSON object may be - // spliced back into the lock (never arbitrary text that would corrupt - // it or smuggle in extra entries). - if !serde_json::from_str::(original).is_ok_and(|value| value.is_object()) { - return Err("Pipenv original is not a JSON object".into()); - } - let new = edit - .new - .as_ref() - .and_then(Value::as_str) - .ok_or("missing Pipenv replacement")?; - let Some((_, entry)) = entries(text)? - .into_iter() - .find(|(category, entry)| category == §ion && entry.name == name) - else { - // A relock that DROPPED the entry (`pipenv uninstall `, a Pipfile - // edit + `pipenv lock`): the redirect is gone with it — nothing to - // unwind, the edit retires. - return Ok(text.into()); - }; - let live = &text[entry.range.clone()]; - let ending = if text.contains("\r\n") { "\r\n" } else { "\n" }; - let original_value: Value = serde_json::from_str(original).map_err(|e| e.to_string())?; - let new_value: Option = serde_json::from_str(new).ok(); - // Comparisons are SEMANTIC (parsed JSON), so a line-ending conversion of - // the whole file (git autocrlf, a cross-OS checkout) or a re-serialization - // that only moved whitespace is neither drift nor a reason to refuse. - if live == original || entry.value == original_value { - return Ok(text.into()); - } - // "Still ours": Pipenv re-serialized the entry around the very reference - // we wrote (see [`reserialized_around_reference`]). - let same_reference = new_value - .as_ref() - .is_some_and(|new_value| reserialized_around_reference(&entry.value, new_value)); - if live != new && new_value.as_ref() != Some(&entry.value) && !same_reference { - // A relock (`pipenv lock`, `pipenv update`, `pipenv install ` - // on <= 2023) regenerates the entry to registry shape: the redirect - // is already gone and the user's fresh resolution is the desired end - // state, so the edit is retired instead of holding every pypi revert - // hostage forever. A DIFFERENT `file`/`path` reference (a user's own - // source, a hand edit) is real drift and still refuses. - let registry_shaped = entry - .value - .as_object() - .is_some_and(|object| !object.contains_key("file") && !object.contains_key("path")); - if registry_shaped { - return Ok(text.into()); - } - return Err(format!("Pipenv entry {section}.{name} drifted")); - } - // Still our reference (byte-identical, re-serialized by Pipenv, or a - // `--keep-outdated` hybrid that kept our `file`/`path`): splice the - // recorded original back, in the live file's line ending. - let restored = if ending == "\r\n" { - original.replace("\r\n", "\n").replace('\n', "\r\n") - } else { - original.replace("\r\n", "\n") - }; - let mut result = text.to_owned(); - result.replace_range(entry.range, &restored); - Ok(result) -} - /// Whether any pypi override names a package this `Pipfile.lock` pins — the /// cheap pre-check that decides whether the installer probe /// (`pipenv --version`, up to 10 s) is worth running and whether its @@ -452,7 +376,6 @@ fn plan( if object.get(source_key).and_then(Value::as_str) == Some(&url) && object.get("hashes") == Some(&json!([format!("sha256:{sha}")])) && !object.contains_key("version") - && !object.contains_key("index") { continue; } @@ -464,9 +387,18 @@ fn plan( dep.name, dep.version ))); } + // `index` stays exactly as Pipenv wrote it (present or absent): it + // is the one registry field the upstream restore cannot re-derive. + // Whether Pipenv records it depends on the release, the Pipfile + // spelling and the locking environment (2022.12.19 writes it for an + // `extras` table, 2026.8.0 does not; neither writes it for a + // marker-excluded package or a transitive one), so dropping it would + // make `rollback` guess. On a `file`/`path` entry it only selects + // the source group Pipenv installs the URL through — pip fetches + // the URL itself either way (measured on 2018.11.26 through + // 2026.8.0: install, `--deploy`, `sync`, `verify`). let mut new = object.clone(); new.remove("version"); - new.remove("index"); new.remove("file"); new.remove("path"); new.insert(source_key.into(), Value::String(url.clone())); @@ -529,11 +461,6 @@ mod tests { ); } assert_eq!(plan(&text, &dep, None).unwrap(), (text.clone(), Vec::new())); - let mut restored = text; - for edit in edits.iter().rev() { - restored = restore(&restored, edit).unwrap(); - } - assert_eq!(restored, original); } } @@ -629,17 +556,13 @@ mod tests { } #[test] - fn bom_prefixed_lock_is_rewritten_and_restored_with_the_bom_intact() { + fn bom_prefixed_lock_is_rewritten_with_the_bom_intact() { let dep = dependency("urllib3", "1.26.18", "patch-one"); let original = format!("\u{feff}{}", lock()); let (text, edits) = plan(&original, &dep, None).unwrap(); assert!(text.starts_with('\u{feff}'), "the BOM is preserved"); assert!(text.contains("patch.socket.dev")); - let mut restored = text; - for edit in edits.iter().rev() { - restored = restore(&restored, edit).unwrap(); - } - assert_eq!(restored, original); + assert!(!edits.is_empty()); } #[test] @@ -663,50 +586,6 @@ mod tests { assert!(plan(&lock(), &missing_hash, None).is_err()); } - /// `pipenv lock` (and `update`, and `install ` before 2024) - /// regenerates the redirected entry to registry shape. That is the - /// desired end state of a rollback, so the edit retires cleanly instead - /// of refusing forever; a foreign `file`/`path` reference is still drift. - /// A re-scan after the relock (second edit on the same key) unwinds - /// newest-first to the relocked text. - #[test] - fn relocked_registry_entry_retires_the_edit_instead_of_refusing() { - let dep = dependency("urllib3", "1.26.18", "patch-one"); - // One category, so the relock below regenerates the ONLY redirect. - let mut value: Value = serde_json::from_str(&lock()).unwrap(); - value.as_object_mut().unwrap().remove("tests"); - let original = format_entry(&value, "{", 0).unwrap() + "\n"; - let (redirected, edits) = plan(&original, &dep, None).unwrap(); - assert_eq!(edits.len(), 1); - let redirected_entry = edits[0].new.as_ref().unwrap().as_str().unwrap(); - let relocked_entry = format_entry( - &json!({"hashes": ["sha256:relocked"], "index": "pypi", "version": "==1.26.18"}), - &redirected, - redirected.find(redirected_entry).unwrap(), - ) - .unwrap(); - let relocked = redirected.replacen(redirected_entry, &relocked_entry, 1); - assert_eq!( - restore(&relocked, &edits[0]).unwrap(), - relocked, - "a registry-shaped entry is already unwound" - ); - let foreign = redirected.replacen( - redirected_entry, - &format_entry(&json!({"file": "https://example.org/fork.whl"}), &redirected, 0).unwrap(), - 1, - ); - assert!(restore(&foreign, &edits[0]).is_err(), "a foreign reference is drift"); - - // Re-scan after the relock, then roll back newest-first. - let (again, second) = plan(&relocked, &dep, None).unwrap(); - let mut current = again; - for edit in second.iter().chain(edits.iter()) { - current = restore(¤t, edit).unwrap(); - } - assert_eq!(current, relocked); - } - #[test] fn lock_targets_requires_a_matching_pin_in_a_parseable_lock() { let dep = dependency("URLlib3", "1.26.18", "patch-one"); @@ -723,9 +602,10 @@ mod tests { /// `pipenv lock --keep-outdated` (2022) rewrites our entry into a /// file+version+index hybrid that Pipenv still installs from: it is - /// ours, so it is re-planned to the canonical shape instead of being - /// refused as a foreign source (which also vetoed the sibling rewriters); - /// a hybrid naming ANOTHER version is a real conflict. + /// ours, so it is re-planned to the canonical shape (`version` dropped, + /// Pipenv's `index` kept) instead of being refused as a foreign source + /// (which also vetoed the sibling rewriters); a hybrid naming ANOTHER + /// version is a real conflict. #[test] fn owned_hybrid_entries_are_replanned_not_refused() { let dep = dependency("urllib3", "1.26.18", "patch-one"); @@ -738,7 +618,7 @@ mod tests { assert!(!edits.is_empty(), "the hybrid is re-planned"); let entry: Value = serde_json::from_str(&fixed).unwrap(); assert!(entry["default"]["urllib3"].get("version").is_none()); - assert!(entry["default"]["urllib3"].get("index").is_none()); + assert_eq!(entry["default"]["urllib3"]["index"], json!("pypi")); assert!(entry["default"]["urllib3"]["file"].as_str().unwrap().contains("patch-one")); value["default"]["urllib3"]["version"] = json!("==2.0.0"); @@ -763,84 +643,25 @@ mod tests { assert!(owned_url(&dep.artifact_url, &dep), "the grant's own origin is ours"); assert!(owned_url(public, &dep), "and so is the public service"); assert!(!owned_url("https://patches.internal.example:8443/patch/pypi/urllib3/1.26.19/tok/patch-one/urllib3-1.26.19-py3-none-any.whl", &dep), "another version is not"); - // Rotation on the custom origin restores through the chain. - let original = lock(); - let (first, edits) = plan(&original, &dep, None).unwrap(); + // Rotation on the custom origin re-points the owned entry. + let (first, _) = plan(&lock(), &dep, None).unwrap(); dep.artifact_url = dep.artifact_url.replace("/tok/", "/rotated/"); let (second, rotation) = plan(&first, &dep, None).unwrap(); - let mut restored = second; - for edit in rotation.iter().chain(edits.iter()) { - restored = restore(&restored, edit).unwrap(); - } - assert_eq!(restored, original); + assert!(!rotation.is_empty()); + assert!(second.contains("/rotated/") && !second.contains("/tok/")); } - #[test] - fn restore_refuses_a_non_object_ledger_original() { - let dep = dependency("urllib3", "1.26.18", "patch-one"); - let (text, edits) = plan(&lock(), &dep, None).unwrap(); - for bad in ["\"just a string\"", "[1, 2]", "not json at all", "{\"a\": 1}, \"injected\": {}"] { - let mut edit = edits[0].clone(); - edit.original = Some(Value::String(bad.to_string())); - assert!(restore(&text, &edit).is_err(), "{bad}"); - } - } - - #[test] - fn rollback_is_per_entry_preserves_unrelated_edits_and_refuses_drift() { - let mut value: Value = serde_json::from_str(&lock()).unwrap(); - value["default"]["six"] = json!({"version":"==1.16.0"}); - let original = serde_json::to_string_pretty(&value).unwrap(); - let first = dependency("urllib3", "1.26.18", "patch-one"); - let second = dependency("six", "1.16.0", "patch-two"); - let (one, first_edits) = plan(&original, &first, None).unwrap(); - let (two, second_edits) = plan(&one, &second, None).unwrap(); - for first_removed in [true, false] { - let mut current = two.replace("unchanged", "unrelated-edit"); - let edits = if first_removed { - first_edits - .iter() - .chain(second_edits.iter()) - .collect::>() - } else { - second_edits.iter().chain(first_edits.iter()).collect() - }; - for edit in edits { - current = restore(¤t, edit).unwrap(); - } - assert_eq!(current, original.replace("unchanged", "unrelated-edit")); - } - for edit in &first_edits { - let replacement = edit.new.as_ref().unwrap().as_str().unwrap(); - // A tampered reference (its `#sha256=` pin) is drift… - let drift = two.replacen(replacement, &replacement.replace("#sha256=", "#sha256=0"), 1); - assert!(restore(&drift, edit).is_err()); - // …while a re-serialized entry that kept our reference (Pipenv - // 2023+ relocking a marker-excluded entry restores the registry - // `hashes` and `version` next to it) is still ours and restores. - let mut value: Value = serde_json::from_str(&two).unwrap(); - let section: &str = serde_json::from_str::<[String; 2]>(edit.key.as_deref().unwrap()).unwrap()[0].clone().leak(); - value[section]["urllib3"]["hashes"] = json!(["sha256:upstream-a", "sha256:upstream-b"]); - value[section]["urllib3"]["version"] = json!("==1.26.18"); - let kept = serde_json::to_string_pretty(&value).unwrap(); - let restored: Value = serde_json::from_str(&restore(&kept, edit).unwrap()).unwrap(); - assert!(restored[section]["urllib3"].get("file").is_none(), "{restored}"); - let mut unsafe_edit = edit.clone(); - unsafe_edit.path = "../Pipfile.lock".into(); - assert!(restore(&two, &unsafe_edit).is_err()); - } - } } #[cfg(test)] mod compatibility_tests { use super::*; #[test] - fn rotates_owned_grants_and_preserves_rollback_chain() { + fn rotates_owned_grants() { let mut dep = super::tests::dependency("urllib3", "1.26.18", "patch-one"); for major in [Some(7), Some(11), Some(2018), Some(2026), None] { let original = super::tests::lock(); - let (first, edits) = plan(&original, &dep, major).unwrap(); + let (first, _) = plan(&original, &dep, major).unwrap(); let parsed: Value = serde_json::from_str(&first).unwrap(); let field = if major.is_some_and(|value| value < 2018) { "path" @@ -850,11 +671,8 @@ mod compatibility_tests { assert!(parsed["default"]["urllib3"][field].is_string()); dep.artifact_url = dep.artifact_url.replace("/token/", "/rotated/"); let (second, rotation) = plan(&first, &dep, major).unwrap(); - let mut restored = second; - for edit in rotation.iter().chain(edits.iter()) { - restored = restore(&restored, edit).unwrap(); - } - assert_eq!(restored, original); + assert!(!rotation.is_empty()); + assert!(second.contains("/rotated/") && !second.contains("/token/")); dep.artifact_url = dep.artifact_url.replace("/rotated/", "/token/"); } } @@ -886,49 +704,4 @@ mod compatibility_tests { assert!(result.warnings.iter().any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); } - /// Rollback survives what git and Pipenv do to the lock between the - /// redirect and the revert: a whole-file CRLF<->LF conversion, Pipenv - /// re-serializing our entry (a `--keep-outdated` hybrid that kept our - /// reference), and a relock that dropped the entry altogether. - #[test] - fn rollback_tolerates_line_ending_conversion_hybrids_and_dropped_entries() { - let dep = super::tests::dependency("urllib3", "1.26.18", "patch-one"); - let original = super::tests::lock(); - let (redirected, edits) = plan(&original, &dep, None).unwrap(); - // CRLF conversion of the redirected file → restores the original in CRLF. - let crlf = redirected.replace('\n', "\r\n"); - let mut restored = crlf; - for edit in edits.iter().rev() { - restored = restore(&restored, edit).unwrap(); - } - assert_eq!(restored, original.replace('\n', "\r\n")); - // LF file, CRLF-recorded edits (the redirect ran on a CRLF checkout). - let crlf_original = original.replace('\n', "\r\n"); - let (crlf_redirected, crlf_edits) = plan(&crlf_original, &dep, None).unwrap(); - let mut restored = crlf_redirected.replace("\r\n", "\n"); - for edit in crlf_edits.iter().rev() { - restored = restore(&restored, edit).unwrap(); - } - assert_eq!(restored, original); - // Hybrid: Pipenv re-added version/index next to our reference. - let mut value: Value = serde_json::from_str(&redirected).unwrap(); - value["default"]["urllib3"]["version"] = json!("==1.26.18"); - value["default"]["urllib3"]["index"] = json!("pypi"); - let hybrid = serde_json::to_string_pretty(&value).unwrap(); - let default_edit = edits.iter().find(|e| e.key.as_deref() == Some(r#"["default","urllib3"]"#)).unwrap(); - let restored = restore(&hybrid, default_edit).unwrap(); - let value: Value = serde_json::from_str(&restored).unwrap(); - assert_eq!(value["default"]["urllib3"]["version"], json!("==1.26.18")); - assert!(value["default"]["urllib3"].get("file").is_none(), "{restored}"); - // Dropped entry (`pipenv uninstall`): nothing to unwind, retires. - let mut value: Value = serde_json::from_str(&redirected).unwrap(); - value["default"].as_object_mut().unwrap().remove("urllib3"); - let dropped = serde_json::to_string_pretty(&value).unwrap(); - assert_eq!(restore(&dropped, default_edit).unwrap(), dropped); - // A foreign reference is still drift. - let mut value: Value = serde_json::from_str(&redirected).unwrap(); - value["default"]["urllib3"]["file"] = json!("https://example.org/fork.whl"); - let foreign = serde_json::to_string_pretty(&value).unwrap(); - assert!(restore(&foreign, default_edit).is_err()); - } } diff --git a/crates/socket-patch-core/src/patch/redirect/pnpm.rs b/crates/socket-patch-core/src/patch/redirect/pnpm.rs index a5fa6a4de..90371b462 100644 --- a/crates/socket-patch-core/src/patch/redirect/pnpm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pnpm.rs @@ -192,6 +192,39 @@ impl<'a> Resolution<'a> { } } +impl Resolution<'_> { + /// The default-registry spelling of this resolution: `integrity` only + /// (pnpm omits `tarball` for a package the configured registry serves), + /// every other field kept in place — the inverse of + /// [`Resolution::rewrite`] for the v5 upstream restore. + pub fn restore(&self, integrity: &str) -> String { + let scalar = if integrity + .chars() + .any(|c| c.is_whitespace() || matches!(c, ',' | '[' | ']' | '{' | '}' | '\'' | '"')) + { + serde_json::to_string(integrity).expect("string serializes") + } else { + integrity.to_string() + }; + let mut fields = vec![format!("integrity: {scalar}")]; + fields.extend( + self.fields + .iter() + .filter(|(k, _)| !matches!(*k, "integrity" | "tarball")) + .map(|(k, v)| format!("{k}: {v}")), + ); + if self.block { + format!( + "{} {}", + self.newline, + fields.join(&format!("{} ", self.newline)) + ) + } else { + format!("{{{}}}", fields.join(", ")) + } + } +} + /// The key line every `packages:` entry's resolution map starts at. const RESOLUTION_KEY: &str = " resolution:"; diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs deleted file mode 100644 index 8b6431159..000000000 --- a/crates/socket-patch-core/src/patch/redirect/replay.rs +++ /dev/null @@ -1,3545 +0,0 @@ -//! Whole-ledger reverse replay of hosted-redirect edits. -//! -//! The per-purl reverts in [`super::takeover`] cover cargo, the -//! npm-family lock flavors, and golang (which reuses the golang inverses -//! here). Everything else the hosted rewriters touch — -//! gem, pypi, composer, and the non-package rideshare edits -//! (such as the pnpm `trustLockfile` auto-config) — -//! has no per-purl revert (bun edits the per-purl revert does not claim -//! also fall through to this replay): 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::staged::{flush_staged, staged_read, Staged, StagedBytes}; -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, - /// Like [`Inverse::ReplaceFragment`], but `new` legitimately occurs - /// several times and stands for every occurrence (a cargo v1 lock's - /// full-id dependency reference, named by several dependents). - ReplaceEveryFragment, - PipenvEntry, - HatchDocument, - /// 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 go.sum lines of the upstream module that the pristine file - /// needs back. Re-inserted at go's sorted position, so the file returns - /// byte for byte. - ReinsertRemoved, - /// go.sum lines the redirect added (`new`, `\n`-joined): each is removed - /// as a whole line, whatever the file's line endings. - RemoveAddedLines, - /// The appended cargo `[registries.…]` block (`redirect_cargo_registry`, - /// action `added`): removed together with exactly the one blank - /// separator the rewriter put before it — see - /// [`remove_appended_cargo_block`]. - RemoveAppendedCargoBlock, - /// 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, - /// The npm `.npmrc` `allow-remote=all` auto-config (kind-specific: - /// `created` deletes the untouched file, `added` removes exactly one - /// line — see [`super::npmrc`]). Grouped with the npm lock kinds so a - /// surviving (refused) package-lock edit keeps the setting it needs. - NpmrcAllowRemote, - BunBinaryPackage, - /// A hosted vlt node splice: `original`'s slots [2] and [3] go back on - /// the line keyed by the recorded DepID ([`super::vlt::revert_vlt_slots`]). - VltSlots, - /// 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_pipenv_entry" => ("pypi", Inverse::PipenvEntry), - "redirect_requirements_line" - | "redirect_uv_lock_wheel" - | "redirect_poetry_lock_package" - | "redirect_pdm_lock_package" => ("pypi", Inverse::ReplaceFragment), - "redirect_hatch_document" => ("pypi", Inverse::HatchDocument), - "redirect_composer_dist" => ("composer", Inverse::ReplaceFragment), - "redirect_cargo_toml_dep" | "redirect_cargo_lock_entry" => { - ("cargo", Inverse::ReplaceFragment) - } - super::CARGO_LOCK_REFERENCE_KIND => ("cargo", Inverse::ReplaceEveryFragment), - "redirect_cargo_registry" => ( - "cargo", - if action == "added" { - Inverse::RemoveAppendedCargoBlock - } 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_lockb_package" => ("bun", Inverse::BunBinaryPackage), - "redirect_bun_lock_package" => ("bun", Inverse::ReplaceFragment), - super::vlt::KIND => ("vlt", Inverse::VltSlots), - "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::RemoveAddedLines), - "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), - super::npmrc::NPMRC_ALLOW_REMOTE_EDIT_KIND => ("npm", Inverse::NpmrcAllowRemote), - "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), - } -} - -/// Is `kind` a hosted-redirect edit this release has no replay arm for -/// (a newer socket-patch's writer)? -pub(super) fn is_unclassified_kind(kind: &str, action: &str) -> bool { - classify(kind, action).0 == "unknown" -} - -/// 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. Every ecosystem also -/// lists the reserved "unknown" group: an edit kind this release cannot -/// classify may belong to any record (a newer release's writer for a new -/// lock flavor), so dropping a record beside one would strand that edit. -fn groups_for_record_purl(purl: &str) -> &'static [&'static str] { - if purl.starts_with("pkg:npm/") { - &["npm", "yarn", "pnpm", "bun", "vlt", "unknown"] - } else if purl.starts_with("pkg:cargo/") { - &["cargo", "unknown"] - } else if purl.starts_with("pkg:gem/") { - &["gem", "unknown"] - } else if purl.starts_with("pkg:pypi/") { - &["pypi", "unknown"] - } else if purl.starts_with("pkg:composer/") { - &["composer", "unknown"] - } else if purl.starts_with("pkg:golang/") { - &["golang", "unknown"] - } else if purl.starts_with("pkg:maven/") { - &["maven", "unknown"] - } else if purl.starts_with("pkg:nuget/") { - &["nuget", "unknown"] - } else { - &["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, such as a modified trust scaffold. - 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 == "..") -} - -/// 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. -pub(super) fn remove_fragment_once(content: &str, fragment: &str) -> String { - // A CRLF file (its fragments recorded CRLF too) is inverted as LF and - // written back CRLF, so the separator bookkeeping below sees real line - // breaks instead of stranding a `\r` line. - let crlf = content.matches("\r\n").count(); - if crlf > 0 && crlf == content.matches('\n').count() && content.contains(fragment) { - return remove_fragment_once( - &content.replace("\r\n", "\n"), - &fragment.replace("\r\n", "\n"), - ) - .replace('\n', "\r\n"); - } - 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 — but only when the - // whole line is removed: a fragment spliced out from behind a - // non-whitespace prefix (the user commented the line out) leaves the - // prefix as its own line, and eating the newline would join that - // prefix onto the FOLLOWING line, commenting it out too. - if start == line_start { - if content[end..].starts_with("\r\n") { - end += 2; - } else 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(['\r', '\n']); - if trimmed.is_empty() { - return String::new(); - } - let eol = crate::vendor::common::detect_eol(content); - return format!("{trimmed}{eol}"); - } - format!("{}{}", &content[..start], &content[end..]) -} - -/// Every line break in `text` is a CRLF (and there is at least one). -fn is_all_crlf(text: &str) -> bool { - let crlf = text.matches("\r\n").count(); - crlf > 0 && crlf == text.matches('\n').count() -} - -/// One fragment-edit inverse, tolerant of a line-ending conversion between -/// the scan and the revert (git `core.autocrlf` rewrites the committed -/// files but never the JSON-escaped fragments in the ledger). -#[derive(Debug, PartialEq)] -pub(super) enum FragmentRevert { - /// `new` was found and put back to `original` (the file's own line - /// endings kept). - Reverted(String), - /// `new` is gone but `original` is present: already unwound. - AlreadyOriginal, - /// Neither fragment is present. - Drifted, -} - -/// Replace `new` with `original` in `content` — once, or at `every` -/// occurrence — matching regardless of CRLF/LF: an all-CRLF file is -/// matched as LF and written back CRLF; any other file is matched with the -/// recorded fragments, then with their LF forms. `new` is looked for -/// before `original` (an `original` may be a substring of `new`). -pub(super) fn revert_fragment_eol( - content: &str, - new: &str, - original: &str, - every: bool, -) -> FragmentRevert { - if is_all_crlf(content) { - return match revert_fragment_eol( - &content.replace("\r\n", "\n"), - &new.replace("\r\n", "\n"), - &original.replace("\r\n", "\n"), - every, - ) { - FragmentRevert::Reverted(lf) => FragmentRevert::Reverted(lf.replace('\n', "\r\n")), - other => other, - }; - } - let (lf_new, lf_original) = (new.replace("\r\n", "\n"), original.replace("\r\n", "\n")); - for (n, o) in [(new, original), (lf_new.as_str(), lf_original.as_str())] { - if content.contains(n) { - return FragmentRevert::Reverted(if every { - content.replace(n, o) - } else { - content.replacen(n, o, 1) - }); - } - } - if content.contains(original) || content.contains(&lf_original) { - FragmentRevert::AlreadyOriginal - } else { - FragmentRevert::Drifted - } -} - -/// Whether `content` holds `fragment`, ignoring CRLF/LF differences. -pub(super) fn contains_eol(content: &str, fragment: &str) -> bool { - content.contains(fragment) - || content - .replace("\r\n", "\n") - .contains(&fragment.replace("\r\n", "\n")) -} - -/// Invert the cargo rewriter's append of a `[registries.…]` block: it wrote -/// `config + "\n" + block` (just `block` into an empty config) and records -/// `block` — or `"\n" + block` when the config lacked a final newline (the -/// extra newline it had to add first). Removing the recorded fragment plus -/// the one newline before it therefore restores the config's exact bytes: -/// a missing final newline or trailing blank lines included, and anything -/// the user appended after the block kept. An all-CRLF file is inverted as -/// LF and written back CRLF; a fragment recorded with the other line -/// endings (a checkout converted them) still matches. `None` when the -/// fragment is not in the file. -pub(super) fn remove_appended_cargo_block(content: &str, fragment: &str) -> Option { - if is_all_crlf(content) { - return remove_appended_cargo_block( - &content.replace("\r\n", "\n"), - &fragment.replace("\r\n", "\n"), - ) - .map(|lf| lf.replace('\n', "\r\n")); - } - let lf_fragment = fragment.replace("\r\n", "\n"); - let (pos, len) = match content.find(fragment) { - Some(pos) => (pos, fragment.len()), - None => (content.find(&lf_fragment)?, lf_fragment.len()), - }; - let before = &content[..pos]; - let before = before - .strip_suffix("\r\n") - .or_else(|| before.strip_suffix('\n')) - .unwrap_or(before); - Some(format!("{before}{}", &content[pos + len..])) -} - -/// 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); - } - - // Where a cargo `[registries.…]` block can still be referenced from: - // the root manifest and lock, plus every manifest the ledger pinned. - let mut cargo_probes: Vec = vec!["Cargo.toml".to_string(), "Cargo.lock".to_string()]; - for edit in state - .edits - .iter() - .filter(|e| e.kind == "redirect_cargo_toml_dep") - { - if !cargo_probes.contains(&edit.path) { - cargo_probes.push(edit.path.clone()); - } - } - - 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 staged_bytes: StagedBytes = BTreeMap::new(); - let mut group_drops: BTreeSet = BTreeSet::new(); - let mut group_warnings: Vec<(String, String)> = Vec::new(); - let mut vanished_vlt: Vec = 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]; - let (_, inverse) = classify(&edit.kind, &edit.action); - if !matches!(inverse, Inverse::NoopDrop | 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::BunBinaryPackage => { - let restored = async { - let content = match staged_bytes.get(&edit.path) { - Some(bytes) => bytes.clone(), - None => crate::utils::fs::read_regular_to_bytes( - &project_root.join(&edit.path), - ) - .await - .map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - format!("{} no longer exists", edit.path) - } else { - format!("read {}: {error}", edit.path) - } - })?, - }; - super::bun_binary::restore(&content, edit) - } - .await; - match restored { - Ok(bytes) => { - staged_bytes.insert(edit.path.clone(), bytes); - group_drops.insert(idx); - } - Err(reason) => { - refuse(reason, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - } - Inverse::VltSlots => { - let content = match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => content, - Ok(None) => { - refuse(format!("{} no longer exists", edit.path), &mut outcome); - refused_groups.insert(group); - continue 'group; - } - Err(error) => { - refuse(error, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - }; - match super::vlt::revert_vlt_slots(&content, edit) { - Ok(super::vlt::SlotRevert::Restored(restored)) => { - staged.insert(edit.path.clone(), Some(restored)); - group_drops.insert(idx); - } - Ok(super::vlt::SlotRevert::Unchanged) => { - group_drops.insert(idx); - } - Ok(super::vlt::SlotRevert::Vanished) => { - vanished_vlt.push(idx); - group_drops.insert(idx); - } - Err(reason) => { - refuse(reason, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - } - 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 => { - let reason = if *group == "unknown" { - format!( - "the redirect ledger holds a {} edit this socket-patch release \ - does not understand; upgrade socket-patch", - edit.kind - ) - } else { - format!( - "no hosted-redirect revert implementation for {} — re-run \ - `scan --mode hosted` to normalize, or restore the file from \ - version control", - edit.kind - ) - }; - refuse(reason, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - Inverse::PipenvEntry => { - let restored = match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => super::pipenv::restore(&content, edit) - .map(|restored| (content, restored)), - Ok(None) => Err(format!("{} no longer exists", edit.path)), - Err(error) => Err(error), - }; - match restored { - Ok((content, restored)) => { - // An already-unwound or retired entry returns the - // text unchanged: no write, no `editedFiles` credit - // (mirrors the ReplaceFragment already-original arm). - if restored != content { - staged.insert(edit.path.clone(), Some(restored)); - } - group_drops.insert(idx); - } - Err(error) => { - refuse(error, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - } - Inverse::ReplaceFragment - | Inverse::ReplaceEveryFragment - | Inverse::HatchDocument => { - 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; - } - }; - if inverse == Inverse::HatchDocument { - match crate::vendor::restore_python_document(&content, original, new) { - Ok((restored, false)) => { - // Already at its original (the restore - // short-circuits on `live == original`): no - // write, no `editedFiles` credit; the ledger - // edit still retires (mirrors the PipenvEntry - // arm). - if restored != content { - staged.insert(edit.path.clone(), Some(restored)); - } - group_drops.insert(idx); - } - _ => { - refuse( - format!("{}: Hatch configuration drifted", edit.path), - &mut outcome, - ); - refused_groups.insert(group); - continue 'group; - } - } - continue; - } - // yarn lock fragments are whole blocks recorded in the - // lock's on-disk line endings, and a `core.autocrlf` - // checkout on another OS re-spells the lock (never the - // committed ledger): when neither fragment matches - // verbatim, try both in the live file's uniform ending. - let respelled = (super::yarn_lock_fragment_kind(&edit.kind) - && !content.contains(new) - && !content.contains(original)) - .then(|| { - crate::utils::line_endings::fragments_in_eol_of(&content, original, new) - }) - .flatten(); - let (original, new) = match &respelled { - Some((original, new)) => (original.as_str(), new.as_str()), - None => (original, new), - }; - // `new` before `original`: original may be a substring - // of new (Cargo.toml insert, maven version suffix). - // cargo: matched regardless of a CRLF/LF conversion since - // the scan (a checkout's `core.autocrlf` rewrites the - // files, never the ledger's escaped fragments). - if *group == "cargo" { - let every = inverse == Inverse::ReplaceEveryFragment; - let lf = |t: &str| t.replace("\r\n", "\n"); - // ONE scan legitimately records identical cargo - // edits (a manifest declaring the crate with the - // same line in two sections), and each one unwinds - // one occurrence — what `revert_cargo_redirect_purl` - // does over this same ledger. So the file may hold - // as many occurrences as there are identical edits - // left to spend on them; only a surplus is - // ambiguous. - let twins = indices - .iter() - .filter(|&&i| { - let other = &state.edits[i]; - !group_drops.contains(&i) - && other.path == edit.path - && other.kind == edit.kind - && other.action == edit.action - && other.key == edit.key - && other.original == edit.original - && other.new == edit.new - }) - .count() - .max(1); - if !every && lf(&content).matches(&lf(new)).count() > twins { - refuse( - format!( - "{}: the redirected fragment appears more than once — \ - ambiguous, refusing to guess", - edit.path - ), - &mut outcome, - ); - refused_groups.insert(group); - continue 'group; - } - match revert_fragment_eol(&content, new, original, every) { - FragmentRevert::Reverted(restored) => { - staged.insert(edit.path.clone(), Some(restored)); - group_drops.insert(idx); - } - // Same substring guard as below. - FragmentRevert::AlreadyOriginal if !lf(new).contains(&lf(original)) => { - group_drops.insert(idx); - } - _ => { - 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; - } - } - continue; - } - 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 { - // bun only: Bun 1.1.39–1.3.9 re-save our URL 3-tuple - // WITHOUT its sha512 on any later lock re-save, so - // the recorded `new` is on disk as a digest-less - // 2-tuple — same key, spec and meta. That spelling - // is the recorded wiring, not drift: put `original` - // back over it. Anything else still refuses. - let healed = if edit.kind == "redirect_bun_lock_package" { - crate::vendor::bun_lock_text::restore_digestless_line( - &content, new, original, - ) - } else { - Ok(None) - }; - match healed { - Ok(Some(restored)) => { - staged.insert(edit.path.clone(), Some(restored)); - group_drops.insert(idx); - } - Ok(None) => { - 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; - } - Err(ambiguous) => { - refuse(format!("{}: {ambiguous}", edit.path), &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - } - } - Inverse::RemoveAppendedCargoBlock => { - 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; - }; - // A block something still references (a hand-pinned dep) - // stays: removing it would leave that pin naming an - // undefined registry. The reverse walk has already - // unwound this ledger's own references. - let reg = edit.key.as_deref().unwrap_or_default(); - let index = new.split('"').nth(1).unwrap_or_default(); - let mut referenced = false; - for probe in &cargo_probes { - if let Ok(Some(text)) = staged_read(&staged, project_root, probe).await { - if (!reg.is_empty() && text.contains(reg)) - || (!index.is_empty() && text.contains(index)) - { - referenced = true; - break; - } - } - } - if referenced { - group_drops.insert(idx); - continue; - } - match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => { - // Absent fragment == already clean. A config the - // rewrite created ends empty and goes with it. - if let Some(restored) = remove_appended_cargo_block(&content, new) { - staged.insert( - edit.path.clone(), - (!restored.is_empty()).then_some(restored), - ); - } - group_drops.insert(idx); - } - Ok(None) => { - group_drops.insert(idx); - } - Err(e) => { - refuse(e, &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 let Some(restored) = - crate::vendor::go_sum_edit::reinsert_lines(&content, original) - { - staged.insert(edit.path.clone(), Some(restored)); - } - group_drops.insert(idx); - } - Inverse::RemoveAddedLines => { - 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 { - Ok(Some(content)) => { - if new - .lines() - .filter(|l| !l.is_empty()) - .any(|line| content.lines().filter(|l| l == &line).count() > 1) - { - refuse( - format!( - "{}: an added line appears more than once — \ - ambiguous, refusing to guess", - edit.path - ), - &mut outcome, - ); - refused_groups.insert(group); - continue 'group; - } - if let Some(removed) = - crate::vendor::go_sum_edit::remove_lines(&content, new) - { - staged.insert(edit.path.clone(), Some(removed)); - } - group_drops.insert(idx); - } - // File gone entirely: the lines are gone with it. - Ok(None) => { - group_drops.insert(idx); - } - Err(e) => { - refuse(e, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - } - 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) { - if c.matches(PNPM_TRUST_LINE).count() > 1 { - refuse( - format!( - "{}: the `{PNPM_TRUST_LINE}` line 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(&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); - } - } - } - Inverse::NpmrcAllowRemote => { - // Refuse a symlinked / non-regular `.npmrc` while - // planning — never at flush time, after sibling files - // of the group may already have landed. - if !staged.contains_key(&edit.path) { - if let Ok(meta) = - tokio::fs::symlink_metadata(project_root.join(&edit.path)).await - { - if !meta.is_file() { - refuse( - format!("{} is not a regular file", edit.path), - &mut outcome, - ); - refused_groups.insert(group); - continue 'group; - } - } - } - 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 super::npmrc::unwind_npmrc_allow_remote(&edit.action, content.as_deref()) - { - Ok(super::npmrc::NpmrcUnwind::Unchanged) => {} - Ok(super::npmrc::NpmrcUnwind::Delete) => { - staged.insert(edit.path.clone(), None); - } - Ok(super::npmrc::NpmrcUnwind::Write { - content, - modified_created, - }) => { - staged.insert(edit.path.clone(), Some(content)); - if modified_created { - group_warnings.push(super::npmrc::npmrc_modified_warning()); - } - } - Err(e) => { - refuse(e, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - group_drops.insert(idx); - } - } - } - - for &idx in &vanished_vlt { - let edit = &state.edits[idx]; - let checked = match staged_read(&staged, project_root, &edit.path).await { - Ok(Some(content)) => super::vlt::revert_vanished(&content, edit), - Ok(None) => Err(format!("{} no longer exists", edit.path)), - Err(error) => Err(error), - }; - match checked { - Ok(Some(restored)) => { - staged.insert(edit.path.clone(), Some(restored)); - } - Ok(None) => {} - Err(reason) => { - refuse(reason, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - } - - // Commit the group: flush staged files (unless dry-run) through the - // shared guarded atomic writer, 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 { - if let Err(reason) = flush_staged(project_root, &staged, &staged_bytes).await { - refuse(reason, &mut outcome); - refused_groups.insert(group); - continue 'group; - } - } - outcome.reverted_files.extend(staged.keys().cloned()); - outcome.reverted_files.extend(staged_bytes.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() - } - - #[tokio::test] - async fn hatch_documents_revert_after_checkout_newline_conversion() { - let original = "[project]\ndependencies=[\"one==1\"]\n[tool.hatch.envs.default]\n"; - let files = [("pyproject.toml".to_owned(), original.to_owned())] - .into_iter() - .collect(); - let patched = - crate::utils::hatch::rewrite(&files, "one", "1", "https://patch.test/one.whl") - .unwrap() - .remove("pyproject.toml") - .unwrap(); - for drift in [false, true] { - let dir = TempDir::new().unwrap(); - let live = if drift { - patched.replace("one.whl", "changed.whl") - } else { - patched.replace('\n', "\r\n") - }; - write(dir.path(), "pyproject.toml", &live).await; - let mut state = state_with( - vec![edit( - "pyproject.toml", - "redirect_hatch_document", - "rewritten", - Some(original), - Some(&patched), - )], - &["pkg:pypi/one@1"], - ); - let outcome = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(outcome.fully_reverted(), !drift); - if drift { - assert_eq!(read(dir.path(), "pyproject.toml").await, live); - assert_eq!(state.edits.len(), 1); - } else { - assert_eq!( - read(dir.path(), "pyproject.toml").await, - original.replace('\n', "\r\n") - ); - assert!(state.edits.is_empty()); - } - } - } - - /// yarn lock edits replay across a `core.autocrlf` checkout switch: the - /// ledger's fragments are the lock's on-disk bytes at redirect time (the - /// berry and classic rewriters record CRLF blocks for a CRLF lock), the - /// live lock may since be in the OTHER uniform ending — the revert lands - /// on the original block in the live file's ending. A mixed live file - /// proves nothing and refuses; a non-yarn kind keeps the verbatim-only - /// contract. - #[tokio::test] - async fn yarn_edits_revert_across_a_checkout_line_ending_switch() { - let head = "# yarn\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n"; - let original = "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ - resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/aaaa\n \ - languageName: node\n linkType: hard"; - let new = "\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ - resolution: \"left-pad@npm:1.3.0::__archiveUrl=http%3A%2F%2Fp.test%2Flp.tgz\"\n \ - checksum: 10c0/bbbb\n languageName: node\n linkType: hard"; - let spell = |text: &str, crlf: bool| { - if crlf { - text.replace('\n', "\r\n") - } else { - text.to_string() - } - }; - for kind in ["redirect_yarn_berry_entry", "redirect_yarn_classic_entry"] { - for (recorded_crlf, live_crlf) in - [(true, true), (true, false), (false, true), (false, false)] - { - let label = format!("{kind} recorded_crlf={recorded_crlf} live_crlf={live_crlf}"); - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "yarn.lock", - &spell(&format!("{head}{new}\n"), live_crlf), - ) - .await; - let mut state = state_with( - vec![edit( - "yarn.lock", - kind, - "rewritten", - Some(&spell(original, recorded_crlf)), - Some(&spell(new, recorded_crlf)), - )], - &["pkg:npm/left-pad@1.3.0"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{label}: {:?}", out.refusals); - assert_eq!( - read(dir.path(), "yarn.lock").await, - spell(&format!("{head}{original}\n"), live_crlf), - "{label}" - ); - assert!(state.edits.is_empty(), "{label}"); - } - } - - // A mixed live lock: refused whole, byte-untouched, edit kept. - let dir = TempDir::new().unwrap(); - let mixed = format!("{}{}\n", spell(head, true), new); - write(dir.path(), "yarn.lock", &mixed).await; - let mut state = state_with( - vec![edit( - "yarn.lock", - "redirect_yarn_berry_entry", - "rewritten", - Some(&spell(original, true)), - Some(&spell(new, true)), - )], - &["pkg:npm/left-pad@1.3.0"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(!out.fully_reverted(), "a mixed lock must refuse"); - assert_eq!(read(dir.path(), "yarn.lock").await, mixed); - assert_eq!(state.edits.len(), 1); - - // A non-yarn line-oriented kind keeps the verbatim-only contract. - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "requirements.txt", - "a==1\r\nleft-pad @ https://patch.example/x.whl\r\n", - ) - .await; - let mut state = state_with( - vec![edit( - "requirements.txt", - "redirect_requirements_line", - "rewritten", - Some("a==1\nleft-pad==1.3.0"), - Some("a==1\nleft-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(), - "only yarn blocks are respelled across line endings" - ); - } - - // ---------- 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"); - } - - // ---------- bun: digest-less re-saves (Bun 1.1.39–1.3.9, every text-lock release below 1.3.10) ---------- - - const BUN_URL: &str = - "https://patch.socket.dev/patch/npm/tok-1111/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/left-pad-1.3.0.tgz"; - const BUN_REGISTRY_LINE: &str = - " \"left-pad\": [\"left-pad@1.3.0\", \"\", {}, \"sha512-XI5M==\"],"; - - fn bun_url_line(sha: &str) -> String { - format!(" \"left-pad\": [\"left-pad@{BUN_URL}\", {{}}, \"{sha}\"],") - } - - fn bun_digestless_line() -> String { - format!(" \"left-pad\": [\"left-pad@{BUN_URL}\", {{}}],") - } - - fn bun_lock(entry: &str) -> String { - format!( - "{{\n \"lockfileVersion\": 1,\n \"packages\": {{\n \"abbrev\": [\"abbrev@1.1.1\", \ - \"\", {{}}, \"sha512-D==\"],\n\n{entry}\n }}\n}}\n" - ) - } - - fn bun_edit(original: &str, new: &str) -> FileEdit { - edit( - "bun.lock", - "redirect_bun_lock_package", - "rewritten", - Some(original), - Some(new), - ) - } - - /// The live lock carries the digest-less 2-tuple Bun < 1.3.10 re-saved - /// our URL 3-tuple as; the recorded `new` is the 3-tuple. That is the - /// recorded wiring, not drift: the registry original comes back, the - /// edit and record are consumed. - #[tokio::test] - async fn bun_digestless_live_line_replays_to_the_registry_original() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "bun.lock", &bun_lock(&bun_digestless_line())).await; - let mut state = state_with( - vec![bun_edit(BUN_REGISTRY_LINE, &bun_url_line("sha512-AAAA=="))], - &["pkg:npm/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(), "bun.lock").await, - bun_lock(BUN_REGISTRY_LINE), - "the pristine registry line is restored, the decoy untouched" - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - assert_eq!(out.dropped_records, vec!["pkg:npm/left-pad@1.3.0"]); - - // CRLF lock (ledger recorded with `\r` on both fragments, as the - // rewriter does): every line keeps its `\r\n`. - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "bun.lock", - &bun_lock(&bun_digestless_line()).replace('\n', "\r\n"), - ) - .await; - let mut state = state_with( - vec![bun_edit( - &format!("{BUN_REGISTRY_LINE}\r"), - &format!("{}\r", bun_url_line("sha512-AAAA==")), - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!( - read(dir.path(), "bun.lock").await, - bun_lock(BUN_REGISTRY_LINE).replace('\n', "\r\n") - ); - } - - /// The hosted rewriter HEALS a digest-less tuple and records that heal - /// as a second edit for the same key (`original` = the 2-tuple). The - /// chain unwinds newest-first: heal → 2-tuple, then the first edit - /// recognises the 2-tuple as its digest-less `new` → registry line. - /// Same end state when Bun has since dropped the digest AGAIN (the - /// heal edit is then "already at its original" and simply drops). - #[tokio::test] - async fn bun_heal_chain_unwinds_to_the_registry_line() { - let healed = bun_url_line("sha512-AAAA=="); - for live in [healed.clone(), bun_digestless_line()] { - let dir = TempDir::new().unwrap(); - write(dir.path(), "bun.lock", &bun_lock(&live)).await; - let mut state = state_with( - vec![ - bun_edit(BUN_REGISTRY_LINE, &healed), - bun_edit(&bun_digestless_line(), &healed), - ], - &["pkg:npm/left-pad@1.3.0"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "live={live}: {:?}", out.refusals); - assert_eq!( - read(dir.path(), "bun.lock").await, - bun_lock(BUN_REGISTRY_LINE), - "live={live}: the chain must end at the pristine registry line" - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - } - } - - /// The relaxation is exactly "our tuple minus its digest": another - /// uuid/token in the URL, a re-laid meta object, a duplicate digest-less - /// instance, or a non-bun edit kind over the same bytes all still - /// refuse, leaving the file byte-identical. - #[tokio::test] - async fn bun_digestless_relaxation_is_narrow() { - let recorded_new = bun_url_line("sha512-AAAA=="); - let other_uuid = bun_digestless_line().replace( - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - ); - let other_meta = bun_digestless_line().replace("{}", "{ \"bin\": \"x\" }"); - for (live, kind, reason) in [ - ( - bun_lock(&other_uuid), - "redirect_bun_lock_package", - "neither the redirected nor the original", - ), - ( - bun_lock(&other_meta), - "redirect_bun_lock_package", - "neither the redirected nor the original", - ), - ( - bun_lock(&format!( - "{}\n{}", - bun_digestless_line(), - bun_digestless_line() - )), - "redirect_bun_lock_package", - "more than once", - ), - ( - bun_lock(&bun_digestless_line()), - "redirect_pnpm_resolution", - "neither the redirected nor the original", - ), - ] { - let dir = TempDir::new().unwrap(); - write(dir.path(), "bun.lock", &live).await; - let mut state = state_with( - vec![edit( - "bun.lock", - kind, - "rewritten", - Some(BUN_REGISTRY_LINE), - Some(&recorded_new), - )], - &["pkg:npm/left-pad@1.3.0"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!( - out.refusals.len(), - 1, - "{kind}: exactly one refusal expected, got {}", - out.refusals.len() - ); - assert!( - out.refusals[0].reason.contains(reason), - "{kind}: {}", - out.refusals[0].reason - ); - assert_eq!(read(dir.path(), "bun.lock").await, live, "file untouched"); - assert_eq!(state.edits.len(), 1, "refused edit kept"); - assert!(state.records.contains_key("pkg:npm/left-pad@1.3.0")); - } - } - - // ---------- 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()); - } - - /// The pruned pair goes back where `go mod tidy` writes it — module - /// path, then SEMVER version (`v1.9.0/go.mod` before `v1.10.0`, though - /// bytewise greater) — so go.sum is restored byte for byte. - #[tokio::test] - async fn reinsert_restores_the_go_sorted_position() { - let pristine = "example.com/leaf v1.0.0 h1:L=\n\ - example.com/leaf v1.0.0/go.mod h1:LM=\n\ - example.com/lib v1.9.0/go.mod h1:N9=\n\ - example.com/lib v1.10.0 h1:T=\n\ - example.com/lib v1.10.0/go.mod h1:TM=\n\ - example.com/zeta v0.1.0 h1:Z=\n"; - for eol in ["\n", "\r\n"] { - let dir = TempDir::new().unwrap(); - let pruned = pristine - .lines() - .filter(|l| !l.starts_with("example.com/lib v1.10.0")) - .map(|l| format!("{l}{eol}")) - .collect::(); - write(dir.path(), "go.sum", &pruned).await; - let mut state = state_with( - vec![edit( - "go.sum", - "redirect_golang_gosum_prune", - "removed", - Some("example.com/lib v1.10.0 h1:T=\nexample.com/lib v1.10.0/go.mod h1:TM="), - None, - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!( - read(dir.path(), "go.sum").await, - pristine.replace('\n', eol), - "eol {eol:?}" - ); - } - } - - #[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 commented_out_added_fragment_removal_keeps_the_following_line() { - // The user disabled the redirect by commenting the directive out. - // Mid-line removal must not eat the line's newline — doing so - // joins the surviving comment prefix onto the NEXT line and - // comments out the `require` directive. - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "go.mod", - "module m\n// replace x v1.0.0 => gopatch.socket.dev/x v1\nrequire y v1.0.0\n", - ) - .await; - let mut state = state_with( - vec![edit( - "go.mod", - "redirect_golang_replace", - "added", - None, - Some("replace x v1.0.0 => gopatch.socket.dev/x v1"), - )], - &[], - ); - 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// \nrequire y v1.0.0\n", - "the require directive must survive on its own line" - ); - } - - #[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"); - } - - /// ONE scan records one cargo edit per OCCURRENCE, so a manifest that - /// declares the crate with the same line in two sections leaves two - /// IDENTICAL edits in the ledger. The whole-ledger replay — the - /// records-empty path a degraded (record-fetch-failed) run leaves - /// behind — must spend one edit per occurrence, exactly as the per-purl - /// `revert_cargo_redirect_purl` does over the same ledger. An - /// occurrence no edit accounts for is still ambiguous and refuses. - #[tokio::test] - async fn identical_cargo_edits_each_unwind_one_occurrence() { - let plain = "cfg-if = \"1\""; - let pinned = "cfg-if = { version = \"1\", registry = \"socket-patch-u\" }"; - let two = |line: &str| format!("[dependencies]\n{line}\n\n[dev-dependencies]\n{line}\n"); - let dir = TempDir::new().unwrap(); - write(dir.path(), "Cargo.toml", &two(pinned)).await; - let cargo_edit = || { - edit( - "Cargo.toml", - "redirect_cargo_toml_dep", - "rewritten", - Some(plain), - Some(pinned), - ) - }; - let mut state = state_with(vec![cargo_edit(), cargo_edit()], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!(read(dir.path(), "Cargo.toml").await, two(plain)); - assert!(state.edits.is_empty(), "{:?}", state.edits); - - // A THIRD occurrence with only two edits to spend: nothing says - // which one the ledger owns, so the group refuses byte-untouched. - let dir = TempDir::new().unwrap(); - let surplus = format!("{}\n[build-dependencies]\n{pinned}\n", two(pinned)); - write(dir.path(), "Cargo.toml", &surplus).await; - let mut state = state_with(vec![cargo_edit(), cargo_edit()], &[]); - 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("more than once"), - "{:?}", - out.refusals[0] - ); - assert_eq!(read(dir.path(), "Cargo.toml").await, surplus); - assert_eq!(state.edits.len(), 2); - } - - #[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); - } - - const FUTURE_LOCK: &str = - "minimist@1.2.8 https://patch.socket.dev/npm/minimist/1.2.8/t/u/minimist-1.2.8.tgz\n"; - - fn future_lock_edit() -> FileEdit { - FileEdit { - key: Some("minimist@1.2.8".into()), - ..edit( - "future.lock", - "redirect_future_lock_entry", - "rewritten", - Some("minimist@1.2.8 sha512-r"), - Some("minimist@1.2.8 https://patch.socket.dev/npm/minimist/1.2.8/t/u/minimist-1.2.8.tgz"), - ) - } - } - - #[tokio::test] - async fn unclassified_kind_holds_the_npm_record_and_every_other_record() { - for dry_run in [true, false] { - let dir = TempDir::new().unwrap(); - write(dir.path(), "future.lock", FUTURE_LOCK).await; - write(dir.path(), "composer.lock", "https://patch.example/c\n").await; - let mut state = state_with( - vec![ - future_lock_edit(), - edit( - "composer.lock", - "redirect_composer_dist", - "rewritten", - Some("https://packagist.example/c"), - Some("https://patch.example/c"), - ), - ], - &["pkg:npm/minimist@1.2.8", "pkg:composer/v/c@1.0.0"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, dry_run).await; - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].group, "unknown"); - assert_eq!( - out.refusals[0].reason, - "the redirect ledger holds a redirect_future_lock_entry edit this socket-patch \ - release does not understand; upgrade socket-patch" - ); - assert!(out.dropped_records.is_empty(), "{out:?}"); - assert!(state.records.contains_key("pkg:npm/minimist@1.2.8")); - assert!(state.records.contains_key("pkg:composer/v/c@1.0.0")); - assert_eq!(read(dir.path(), "future.lock").await, FUTURE_LOCK); - let kinds: Vec<&str> = state.edits.iter().map(|e| e.kind.as_str()).collect(); - // The composer group still unwinds on disk; only its record waits - // for the unknown group to clear. - if dry_run { - assert_eq!( - read(dir.path(), "composer.lock").await, - "https://patch.example/c\n" - ); - assert_eq!( - kinds, - ["redirect_future_lock_entry", "redirect_composer_dist"] - ); - } else { - assert_eq!( - read(dir.path(), "composer.lock").await, - "https://packagist.example/c\n" - ); - assert_eq!(kinds, ["redirect_future_lock_entry"]); - } - } - } - - const VLT_REGISTRY_ENTRY: &str = - "\"~npm~minimist@1.2.8~peer.1\": [2,\"minimist\",\"sha512-r\"]"; - const VLT_HOSTED_ENTRY: &str = "\"~npm~minimist@1.2.8~peer.1\": [2,\"minimist\",\"sha512-p\",\"https://patch.socket.dev/npm/minimist/1.2.8/t/u/minimist-1.2.8.tgz\"]"; - - fn vlt_lock(entry: &str) -> String { - format!( - "{{\r\n \"lockfileVersion\": 1,\r\n \"nodes\": {{\r\n {entry},\r\n \"~npm~zz@1.0.0\": [0,\"zz\",\"sha512-z\"]\r\n }}\r\n}}\r\n" - ) - } - - fn vlt_edit() -> FileEdit { - FileEdit { - key: Some("minimist@1.2.8~peer.1".into()), - ..edit( - "vlt-lock.json", - super::super::vlt::KIND, - "rewritten", - Some(VLT_REGISTRY_ENTRY), - Some(VLT_HOSTED_ENTRY), - ) - } - } - - #[tokio::test] - async fn vlt_node_edits_replay_by_slots_and_drop_the_npm_record() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "vlt-lock.json", &vlt_lock(VLT_HOSTED_ENTRY)).await; - let mut state = state_with(vec![vlt_edit()], &["pkg:npm/minimist@1.2.8"]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{out:?}"); - assert_eq!( - read(dir.path(), "vlt-lock.json").await, - vlt_lock(VLT_REGISTRY_ENTRY) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - assert_eq!(out.dropped_records, ["pkg:npm/minimist@1.2.8"]); - } - - #[tokio::test] - async fn a_drifted_vlt_edit_holds_the_npm_record() { - let dir = TempDir::new().unwrap(); - let drifted = VLT_HOSTED_ENTRY.replace("sha512-p", "sha512-x"); - write(dir.path(), "vlt-lock.json", &vlt_lock(&drifted)).await; - let mut state = state_with(vec![vlt_edit()], &["pkg:npm/minimist@1.2.8"]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].group, "vlt"); - assert!(out.refusals[0].reason.contains("drifted"), "{out:?}"); - assert_eq!(read(dir.path(), "vlt-lock.json").await, vlt_lock(&drifted)); - assert_eq!(state.edits.len(), 1); - assert!(state.records.contains_key("pkg:npm/minimist@1.2.8")); - } - - #[tokio::test] - async fn vlt_replay_keeps_a_relaid_flag_and_trailing_slots() { - let dir = TempDir::new().unwrap(); - let relaid = VLT_HOSTED_ENTRY.replacen("[2,", "[0,", 1).replacen( - "\"]", - "\",null,null,null,null,{ \"m\": \"bin.js\"}]", - 1, - ); - write(dir.path(), "vlt-lock.json", &vlt_lock(&relaid)).await; - let mut state = state_with(vec![vlt_edit()], &["pkg:npm/minimist@1.2.8"]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{out:?}"); - assert_eq!( - read(dir.path(), "vlt-lock.json").await, - vlt_lock( - "\"~npm~minimist@1.2.8~peer.1\": [0,\"minimist\",\"sha512-r\",null,null,null,null,null,{ \"m\": \"bin.js\"}]" - ) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - } - - #[tokio::test] - async fn a_relocked_away_vlt_variant_reverts_whatever_the_ledger_order() { - let plain_registry = VLT_REGISTRY_ENTRY.replace("~peer.1", ""); - let plain_hosted = VLT_HOSTED_ENTRY.replace("~peer.1", ""); - let plain_edit = FileEdit { - key: Some("minimist@1.2.8".into()), - ..edit( - "vlt-lock.json", - super::super::vlt::KIND, - "rewritten", - Some(&plain_registry), - Some(&plain_hosted), - ) - }; - for edits in [ - vec![plain_edit.clone(), vlt_edit()], - vec![vlt_edit(), plain_edit.clone()], - ] { - let dir = TempDir::new().unwrap(); - write(dir.path(), "vlt-lock.json", &vlt_lock(&plain_hosted)).await; - let mut state = state_with(edits, &["pkg:npm/minimist@1.2.8"]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{out:?}"); - assert_eq!( - read(dir.path(), "vlt-lock.json").await, - vlt_lock(&plain_registry) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - } - - // vlt re-keyed the only pinned node and carried the pin with it. - let dir = TempDir::new().unwrap(); - write(dir.path(), "vlt-lock.json", &vlt_lock(&plain_hosted)).await; - let mut state = state_with(vec![vlt_edit()], &["pkg:npm/minimist@1.2.8"]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{out:?}"); - assert_eq!( - read(dir.path(), "vlt-lock.json").await, - vlt_lock(&plain_registry) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - - let dir = TempDir::new().unwrap(); - let other = plain_hosted.replace("sha512-p", "sha512-x"); - write(dir.path(), "vlt-lock.json", &vlt_lock(&other)).await; - let mut state = state_with(vec![vlt_edit()], &["pkg:npm/minimist@1.2.8"]); - 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("still pins its hosted URL"), - "{out:?}" - ); - assert_eq!(read(dir.path(), "vlt-lock.json").await, vlt_lock(&other)); - } - - #[tokio::test] - async fn a_vlt_edit_whose_lock_is_gone_refuses() { - let dir = TempDir::new().unwrap(); - let mut state = state_with(vec![vlt_edit()], &["pkg:npm/minimist@1.2.8"]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals[0].reason, "vlt-lock.json no longer exists"); - assert!(state.records.contains_key("pkg:npm/minimist@1.2.8")); - } - - #[test] - fn every_ecosystem_group_list_includes_the_unknown_group() { - for purl in [ - "pkg:npm/a@1", - "pkg:cargo/a@1", - "pkg:gem/a@1", - "pkg:pypi/a@1", - "pkg:composer/v/a@1", - "pkg:golang/example.com/a@v1.0.0", - "pkg:maven/g/a@1", - "pkg:nuget/A@1", - "pkg:hex/a@1", - ] { - assert!(groups_for_record_purl(purl).contains(&"unknown"), "{purl}"); - } - assert!(is_unclassified_kind( - "redirect_future_lock_entry", - "rewritten" - )); - assert!(!is_unclassified_kind("redirect_vlt_lock_node", "rewritten")); - assert!(!is_unclassified_kind( - "redirect_bun_lock_package", - "rewritten" - )); - } - - #[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"); - } - - // ---------- npm .npmrc allow-remote ---------- - - fn npmrc_edit(action: &str) -> FileEdit { - FileEdit { - path: ".npmrc".into(), - kind: "redirect_npmrc_allow_remote".into(), - action: action.into(), - key: Some("allow-remote".into()), - original: None, - new: Some(json!("all")), - } - } - - #[tokio::test] - async fn npmrc_created_file_is_deleted_when_unmodified() { - let dir = TempDir::new().unwrap(); - write(dir.path(), ".npmrc", "allow-remote=all\n").await; - let mut state = state_with(vec![npmrc_edit("created")], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert!(!dir.path().join(".npmrc").exists()); - assert!(state.edits.is_empty()); - } - - #[tokio::test] - async fn npmrc_appended_line_is_removed_exactly_and_user_edits_survive() { - let dir = TempDir::new().unwrap(); - // The user added their own setting after our line (CRLF file). - write( - dir.path(), - ".npmrc", - "registry=https://r.example/\r\nallow-remote=all\r\nfund=false\r\n", - ) - .await; - let mut state = state_with(vec![npmrc_edit("added")], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!( - read(dir.path(), ".npmrc").await, - "registry=https://r.example/\r\nfund=false\r\n" - ); - assert!(out.warnings.is_empty(), "{:?}", out.warnings); - } - - #[tokio::test] - async fn npmrc_modified_created_file_keeps_the_file_and_warns() { - let dir = TempDir::new().unwrap(); - write(dir.path(), ".npmrc", "allow-remote=all\nfund=false\n").await; - let mut state = state_with(vec![npmrc_edit("created")], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!(read(dir.path(), ".npmrc").await, "fund=false\n"); - assert!(out - .warnings - .iter() - .any(|(code, _)| code == "redirect_npmrc_allow_remote_modified")); - } - - #[tokio::test] - async fn npmrc_edit_is_kept_while_an_npm_lock_edit_refuses() { - // A package-lock edit the per-purl revert failed to claim refuses - // the npm group — and with it the `.npmrc` setting that lock needs. - let dir = TempDir::new().unwrap(); - write(dir.path(), ".npmrc", "allow-remote=all\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": "https://registry/a-1.tgz"})), - new: Some(json!({"resolved": "https://patch.example/a-1.tgz"})), - }, - npmrc_edit("created"), - ], - &["pkg:npm/a@1"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].group, "npm"); - assert_eq!(read(dir.path(), ".npmrc").await, "allow-remote=all\n"); - assert_eq!(state.edits.len(), 2); - } - - #[tokio::test] - async fn npmrc_duplicate_line_refuses_and_dry_run_writes_nothing() { - let dir = TempDir::new().unwrap(); - write(dir.path(), ".npmrc", "allow-remote=all\nallow-remote=all\n").await; - let mut state = state_with(vec![npmrc_edit("added")], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(state.edits.len(), 1); - - write(dir.path(), ".npmrc", "allow-remote=all\n").await; - let mut state = state_with(vec![npmrc_edit("created")], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, true).await; - assert!(out.fully_reverted()); - assert!(out.reverted_files.contains(".npmrc")); - assert_eq!(read(dir.path(), ".npmrc").await, "allow-remote=all\n"); - } - - /// The replay twin of the per-purl planning guard: a symlinked `.npmrc` - /// refuses the npm group while planning (the link and its target are - /// never written), and a section-scoped copy of the line does not make - /// the unwind ambiguous. - #[cfg(unix)] - #[tokio::test] - async fn npmrc_symlink_refuses_at_plan_time_and_section_copies_are_inert() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "shared.npmrc", "allow-remote=all\n").await; - std::os::unix::fs::symlink("shared.npmrc", dir.path().join(".npmrc")).unwrap(); - let mut state = state_with(vec![npmrc_edit("created")], &[]); - 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("not a regular file"), - "{out:?}" - ); - assert_eq!(state.edits.len(), 1); - assert_eq!(read(dir.path(), "shared.npmrc").await, "allow-remote=all\n"); - - let dir = TempDir::new().unwrap(); - write( - dir.path(), - ".npmrc", - "allow-remote=all\n[sec]\nallow-remote=all\n", - ) - .await; - let mut state = state_with(vec![npmrc_edit("added")], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{out:?}"); - assert_eq!( - read(dir.path(), ".npmrc").await, - "[sec]\nallow-remote=all\n" - ); - } - - // ---------- 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" - ); - } - - #[tokio::test] - async fn duplicated_trust_line_refuses_instead_of_removing_the_wrong_copy() { - // A commented-out copy of the trust line above the live one: - // removing the FIRST occurrence would strip the comment's text and - // leave the LIVE line active while claiming full revert. Must - // refuse like the ReplaceFragment / RemoveAddedFragment ambiguity - // guards. - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "pnpm-workspace.yaml", - "packages:\n - '.'\n# trustLockfile: true — added by socket\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 before = read(dir.path(), "pnpm-workspace.yaml").await; - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].group, "pnpm"); - assert!(out.refusals[0].reason.contains("more than once")); - assert_eq!(read(dir.path(), "pnpm-workspace.yaml").await, before); - assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); - } - - #[tokio::test] - async fn commented_out_trust_line_removal_keeps_the_following_line() { - // Single (commented) occurrence: removal proceeds, but must not - // eat the newline and comment out the key on the next line. - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "pnpm-workspace.yaml", - "packages:\n - '.'\n# trustLockfile: true\nshamefullyHoist: 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(), "{:?}", out.refusals); - assert_eq!( - read(dir.path(), "pnpm-workspace.yaml").await, - "packages:\n - '.'\n# \nshamefullyHoist: true\n", - "the following key must survive on its own line" - ); - } - - // ---------- 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")); - } - - /// A FIFO squatting bun.lockb must refuse fast instead of wedging the - /// replay (the same guard every other raw read in the engine has). - #[cfg(unix)] - #[tokio::test] - async fn bun_lockb_fifo_squatting_the_path_refuses_the_group() { - let dir = TempDir::new().unwrap(); - let fifo = dir.path().join("bun.lockb"); - let c_path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); - // SAFETY: a valid NUL-terminated path; mkfifo has no other preconditions. - assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0); - let mut state = state_with( - vec![edit( - "bun.lockb", - "redirect_bun_lockb_package", - "rewritten", - None, - None, - )], - &[], - ); - let out = tokio::time::timeout( - std::time::Duration::from_secs(10), - revert_remaining_redirect_edits(dir.path(), &mut state, false), - ) - .await - .expect("the FIFO guard must not wedge the replay"); - assert_eq!(out.refusals.len(), 1, "{:?}", out.warnings); - assert!( - out.refusals[0].reason.contains("bun.lockb"), - "{}", - out.refusals[0].reason - ); - assert_eq!(state.edits.len(), 1); - } - - /// 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_package", "rewritten"), - ("redirect_vlt_lock_node", "rewritten"), - ("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_npmrc_allow_remote", "created"), - ("redirect_npmrc_allow_remote", "added"), - ("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_source", "added"), - ("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" - ); - } - } - - // ---------- payload corruption (tampered / partially-written ledger) ---------- - - #[tokio::test] - async fn replace_arm_missing_payload_refuses_and_keeps_the_edit() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "composer.lock", "https://patch.example/a\n").await; - let mut state = state_with( - vec![edit( - "composer.lock", - "redirect_composer_dist", - "rewritten", - None, - Some("https://patch.example/a"), - )], - &[], - ); - 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("missing its recorded fragments")); - assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); - assert_eq!( - read(dir.path(), "composer.lock").await, - "https://patch.example/a\n" - ); - } - - #[tokio::test] - async fn replace_arm_non_string_payload_refuses() { - // A hand-edited or corrupted ledger can carry a non-string Value - // where the inverse table requires a fragment string — str_payload - // must reject it, not coerce. - let dir = TempDir::new().unwrap(); - write(dir.path(), "composer.lock", "https://patch.example/a\n").await; - let mut state = state_with( - vec![FileEdit { - path: "composer.lock".into(), - kind: "redirect_composer_dist".into(), - action: "rewritten".into(), - key: Some("k".into()), - original: Some(json!(42)), - new: Some(Value::String("https://patch.example/a".into())), - }], - &[], - ); - 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("missing its recorded fragments")); - assert_eq!(state.edits.len(), 1); - assert_eq!( - read(dir.path(), "composer.lock").await, - "https://patch.example/a\n" - ); - } - - #[tokio::test] - async fn remove_added_arm_missing_payload_refuses() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "go.sum", "gopatch.socket.dev/x v1 h1:a\n").await; - let mut state = state_with( - vec![edit("go.sum", "redirect_golang_gosum", "added", None, None)], - &[], - ); - 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("missing its recorded fragment")); - assert_eq!(state.edits.len(), 1); - assert_eq!( - read(dir.path(), "go.sum").await, - "gopatch.socket.dev/x v1 h1:a\n" - ); - } - - #[tokio::test] - async fn reinsert_arm_missing_payload_refuses() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "go.sum", "x v1 h1:a\n").await; - let mut state = state_with( - vec![edit( - "go.sum", - "redirect_golang_gosum_prune", - "removed", - None, - None, - )], - &[], - ); - 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("missing its recorded lines")); - assert_eq!(state.edits.len(), 1); - assert_eq!(read(dir.path(), "go.sum").await, "x v1 h1:a\n"); - } - - // ---------- read failures (FIFO / directory squats) ---------- - - #[tokio::test] - async fn directory_squatting_a_lockfile_refuses_each_arm_fail_fast() { - // A directory planted at the lockfile path makes open_regular_file - // return InvalidInput (open + fstat) — the fail-fast posture the - // `staged` module documents for its guarded reads. Every per-arm - // read must refuse the group with the read error and keep the ledger. - #[allow(clippy::type_complexity)] - let cases: [(&str, &str, &str, Option<&str>, Option<&str>); 4] = [ - ( - "composer.lock", - "redirect_composer_dist", - "rewritten", - Some("https://upstream.example/a"), - Some("https://patch.example/a"), - ), - ( - "go.sum", - "redirect_golang_gosum", - "added", - None, - Some("gopatch.socket.dev/x v1 h1:a"), - ), - ( - "go.sum", - "redirect_golang_gosum_prune", - "removed", - Some("x v0.9 h1:o"), - None, - ), - ( - "pnpm-workspace.yaml", - "redirect_pnpm_workspace_trust", - "added", - None, - Some("true"), - ), - ]; - for (path, kind, action, original, new) in cases { - let dir = TempDir::new().unwrap(); - tokio::fs::create_dir_all(dir.path().join(path)) - .await - .unwrap(); - let mut state = state_with(vec![edit(path, kind, action, original, new)], &[]); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!( - out.refusals.len(), - 1, - "{kind}/{action} must refuse: {out:?}" - ); - assert!( - out.refusals[0].reason.starts_with(&format!("read {path}:")), - "{kind}/{action}: {}", - out.refusals[0].reason - ); - // The fstat guard's "not a regular file" text is unix-only: on - // Windows, opening a directory fails at CreateFileW with - // ERROR_ACCESS_DENIED before the guard runs. The refusal itself - // (count, `read {path}:` prefix, kept ledger) is platform-neutral. - #[cfg(unix)] - assert!( - out.refusals[0].reason.contains("not a regular file"), - "{kind}/{action}: {}", - out.refusals[0].reason - ); - assert_eq!(state.edits.len(), 1, "{kind}/{action} must keep its edit"); - } - } - - #[cfg(unix)] - #[tokio::test] - async fn fifo_squatting_a_lockfile_refuses_instead_of_wedging() { - use std::os::unix::ffi::OsStrExt; - let dir = TempDir::new().unwrap(); - let path = dir.path().join("composer.lock"); - let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); - assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o644) }, 0); - 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, "{out:?}"); - assert!( - out.refusals[0].reason.starts_with("read composer.lock:"), - "{}", - out.refusals[0].reason - ); - assert!(out.refusals[0].reason.contains("not a regular file")); - assert_eq!(state.edits.len(), 1); - } - - // ---------- RemoveAddedFragment already-clean edges ---------- - - #[tokio::test] - async fn added_fragment_with_file_gone_drops_without_recreating_it() { - let dir = TempDir::new().unwrap(); - let mut state = state_with( - vec![edit( - "go.sum", - "redirect_golang_gosum", - "added", - None, - Some("gopatch.socket.dev/x v1 h1:a"), - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert!(state.edits.is_empty()); - assert!(out.reverted_files.is_empty(), "nothing was written"); - assert!( - !dir.path().join("go.sum").exists(), - "the deleted file must not be recreated" - ); - } - - #[tokio::test] - async fn added_fragment_already_absent_is_a_noop_drop() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "go.mod", "module m\n").await; - let mut state = state_with( - vec![edit( - "go.mod", - "redirect_golang_replace", - "added", - None, - Some("replace x => gopatch.socket.dev/x v1"), - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert!(state.edits.is_empty()); - assert!(out.reverted_files.is_empty(), "nothing was written"); - assert_eq!(read(dir.path(), "go.mod").await, "module m\n"); - } - - #[tokio::test] - async fn duplicated_added_fragment_refuses_instead_of_guessing() { - // The RemoveAddedFragment twin of the ReplaceFragment ambiguity - // guard: two occurrences of the recorded fragment mean removal - // could hit the wrong one — refuse byte-untouched. - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "go.sum", - "gopatch.socket.dev/x v1 h1:a\ngopatch.socket.dev/x v1 h1:a\n", - ) - .await; - let mut state = state_with( - vec![edit( - "go.sum", - "redirect_golang_gosum", - "added", - None, - Some("gopatch.socket.dev/x v1 h1:a"), - )], - &[], - ); - let before = read(dir.path(), "go.sum").await; - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert_eq!(out.refusals[0].group, "golang"); - assert!(out.refusals[0].reason.contains("more than once")); - assert_eq!(read(dir.path(), "go.sum").await, before); - assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); - } - - // ---------- ReinsertRemoved edge shapes ---------- - - #[tokio::test] - async fn reinsert_into_unterminated_file_adds_a_separating_newline() { - // A go.sum whose last line lost its trailing newline (hand-edited - // or tool-truncated): the re-inserted pruned lines must not - // concatenate onto it. - let dir = TempDir::new().unwrap(); - write(dir.path(), "go.sum", "x v1 h1:abc").await; - let mut state = state_with( - vec![edit( - "go.sum", - "redirect_golang_gosum_prune", - "removed", - Some("y v0.9 h1:o"), - None, - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!( - read(dir.path(), "go.sum").await, - "x v1 h1:abc\ny v0.9 h1:o\n" - ); - } - - #[tokio::test] - async fn reinsert_recreates_a_deleted_gosum() { - // The user deleted go.sum entirely; the pruned upstream lines must - // still come back — the staged write lands on a nonexistent path - // (the flush-side symlink_metadata Err edge) and creates the file. - let dir = TempDir::new().unwrap(); - let mut state = state_with( - vec![edit( - "go.sum", - "redirect_golang_gosum_prune", - "removed", - Some("y v0.9 h1:o"), - None, - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert!(out.reverted_files.contains("go.sum")); - assert_eq!(read(dir.path(), "go.sum").await, "y v0.9 h1:o\n"); - assert!(state.edits.is_empty()); - } - - // ---------- pnpm trust already-clean edges ---------- - - #[tokio::test] - async fn trust_edit_with_workspace_file_gone_drops_without_recreating_it() { - let dir = TempDir::new().unwrap(); - 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!(state.edits.is_empty()); - assert!( - !dir.path().join("pnpm-workspace.yaml").exists(), - "the deleted workspace file must not be recreated" - ); - assert!(out.reverted_files.is_empty(), "nothing was written"); - } - - #[tokio::test] - async fn trust_line_already_absent_leaves_the_file_untouched() { - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "pnpm-workspace.yaml", - "packages:\n - 'apps/*'\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(), "{:?}", out.refusals); - assert!(state.edits.is_empty()); - assert_eq!( - read(dir.path(), "pnpm-workspace.yaml").await, - "packages:\n - 'apps/*'\n" - ); - assert!( - out.warnings.is_empty(), - "no scaffold_modified warning: {:?}", - out.warnings - ); - assert!(out.reverted_files.is_empty(), "nothing was written"); - } - - // ---------- flush-side guards ---------- - - #[cfg(unix)] - #[tokio::test] - async fn symlinked_lockfile_reads_fine_but_refuses_at_flush() { - // open_regular_file follows the symlink at read time (open+fstat), - // but the flush-side symlink_metadata guard does not — a symlinked - // lockfile must refuse fail-closed rather than write through the - // link. - let dir = TempDir::new().unwrap(); - write(dir.path(), "real.lock", "https://patch.example/a\n").await; - std::os::unix::fs::symlink( - dir.path().join("real.lock"), - dir.path().join("composer.lock"), - ) - .unwrap(); - 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, "{out:?}"); - assert_eq!( - out.refusals[0].reason, - "composer.lock is not a regular file" - ); - assert_eq!( - read(dir.path(), "real.lock").await, - "https://patch.example/a\n", - "the symlink target must stay byte-identical" - ); - assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); - } - - #[cfg(unix)] - #[tokio::test] - async fn write_failure_at_flush_refuses_late_and_keeps_the_ledger() { - // Root bypasses mode bits (CI containers) — skip there. - if unsafe { libc::geteuid() } == 0 { - return; - } - use std::os::unix::fs::PermissionsExt; - let dir = TempDir::new().unwrap(); - write(dir.path(), "composer.lock", "https://patch.example/a\n").await; - // The flush is an atomic stage + rename, so a read-only TARGET no - // longer blocks it (rename needs only the parent): make the parent - // directory read-only so the stage file cannot be created. - let writable = std::fs::metadata(dir.path()).unwrap().permissions(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); - 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; - // Restore before asserting so the TempDir can clean up on failure. - std::fs::set_permissions(dir.path(), writable).unwrap(); - assert_eq!(out.refusals.len(), 1, "{out:?}"); - assert!( - out.refusals[0].reason.starts_with("write composer.lock:"), - "{}", - out.refusals[0].reason - ); - assert_eq!(state.edits.len(), 1, "the edit must survive for a retry"); - assert_eq!( - read(dir.path(), "composer.lock").await, - "https://patch.example/a\n", - "the redirected fragment must still be present" - ); - let litter: Vec = std::fs::read_dir(dir.path()) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .filter(|n| n.starts_with(".socket-stage-")) - .collect(); - assert!(litter.is_empty(), "no stage litter on failure: {litter:?}"); - } - - /// The text flush goes through the mode-preserving atomic writer: a - /// `0600` lockfile keeps its bits across the revert (the plain writer - /// would swap in a fresh umask-mode inode). - #[cfg(unix)] - #[tokio::test] - async fn flush_keeps_the_lockfile_mode() { - use std::os::unix::fs::PermissionsExt; - let dir = TempDir::new().unwrap(); - write(dir.path(), "composer.lock", "https://patch.example/a\n").await; - let path = dir.path().join("composer.lock"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - 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(), "{:?}", out.refusals); - assert_eq!( - read(dir.path(), "composer.lock").await, - "https://upstream.example/a\n" - ); - assert_eq!( - std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600, - "the lockfile's mode must survive the atomic rewrite" - ); - } - - /// A Hatch document already at its recorded original (an interrupted - /// earlier revert, or a hand-fix) retires its ledger edit without a - /// byte-identical rewrite or an `editedFiles` credit — the same rule the - /// PipenvEntry and ReplaceFragment arms follow. - #[tokio::test] - async fn hatch_document_already_at_original_retires_without_a_write() { - let original = "[project]\nname = \"app\"\ndependencies = [\"one==1\"]\n"; - let redirected = - "[project]\nname = \"app\"\ndependencies = [\"one @ https://patch.example/one.whl\"]\n"; - let dir = TempDir::new().unwrap(); - write(dir.path(), "pyproject.toml", original).await; - let mut state = state_with( - vec![edit( - "pyproject.toml", - "redirect_hatch_document", - "rewritten", - Some(original), - Some(redirected), - )], - &[], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert!( - out.reverted_files.is_empty(), - "nothing was written: {out:?}" - ); - assert!(state.edits.is_empty(), "the edit still retires"); - assert_eq!(read(dir.path(), "pyproject.toml").await, original); - } - - // ---------- record hold/drop per purl ecosystem ---------- - - #[tokio::test] - async fn cargo_and_composer_records_drop_when_their_groups_replay_clean() { - let dir = TempDir::new().unwrap(); - write( - dir.path(), - "Cargo.lock", - "source = \"sparse+https://patch.example/\"\n", - ) - .await; - write(dir.path(), "composer.lock", "https://patch.example/a\n").await; - let mut state = state_with( - vec![ - edit( - "Cargo.lock", - "redirect_cargo_lock_entry", - "rewritten", - Some("source = \"registry+https://github.com/rust-lang/crates.io-index\""), - Some("source = \"sparse+https://patch.example/\""), - ), - edit( - "composer.lock", - "redirect_composer_dist", - "rewritten", - Some("https://upstream.example/a"), - Some("https://patch.example/a"), - ), - ], - &["pkg:cargo/cfg-if@1.0.0", "pkg:composer/a/b@1"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert!(out.fully_reverted(), "{:?}", out.refusals); - assert_eq!( - out.dropped_records, - vec!["pkg:cargo/cfg-if@1.0.0", "pkg:composer/a/b@1"] - ); - assert!(state.records.is_empty()); - assert!(state.edits.is_empty()); - } - - #[tokio::test] - async fn nuget_and_unknown_ecosystem_records_are_held_by_their_refusals() { - let dir = TempDir::new().unwrap(); - write(dir.path(), "packages.lock.json", "{}\n").await; - let mut state = state_with( - vec![ - edit( - "packages.lock.json", - "redirect_nuget_lock", - "rewritten", - Some("a"), - Some("b"), - ), - edit( - "f", - "redirect_future_thing", - "rewritten", - Some("a"), - Some("b"), - ), - ], - &["pkg:nuget/A@1", "pkg:hex/x@1"], - ); - let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(out.refusals.len(), 2, "{out:?}"); - assert!( - state.records.contains_key("pkg:nuget/A@1"), - "a nuget record must be held while its Unsupported edits refuse" - ); - assert!( - state.records.contains_key("pkg:hex/x@1"), - "an unknown-ecosystem record is tied to the reserved unknown group" - ); - assert!(out.dropped_records.is_empty()); - assert_eq!(state.edits.len(), 2); - } - - // ---------- remove_fragment_once unit pins ---------- - - #[test] - fn remove_fragment_once_keeps_crlf_separators_straight() { - assert_eq!( - remove_fragment_once("[net]\r\nretry = 2\r\n\r\nF\r\nG\r\n", "F\r\nG\r\n"), - "[net]\r\nretry = 2\r\n" - ); - assert_eq!( - remove_fragment_once("a\r\n\r\nF\r\n\r\nb\r\n", "F\r\n"), - "a\r\n\r\nb\r\n" - ); - } - - #[test] - fn remove_appended_cargo_block_inverts_exactly_what_was_appended() { - let block = "[registries.r]\nindex = \"i\"\n"; - for (written, fragment, want) in [ - // Empty config: the block alone (a created file ends empty). - (block.to_string(), block.to_string(), ""), - // One separator after a config ending in newline(s). - (format!("a\n\n{block}"), block.to_string(), "a\n"), - (format!("a\n\n\n{block}"), block.to_string(), "a\n\n"), - // No final newline: the added newline rides in the fragment. - (format!("a\n\n{block}"), format!("\n{block}"), "a"), - // The user appended after the block: kept. - ( - format!("a\n\n{block}b = 1\n"), - block.to_string(), - "a\nb = 1\n", - ), - // CRLF file, and a CRLF-recorded fragment against an LF file. - ( - format!("a\r\n\r\n{}", block.replace('\n', "\r\n")), - block.replace('\n', "\r\n"), - "a\r\n", - ), - (format!("a\n\n{block}"), block.replace('\n', "\r\n"), "a\n"), - ( - format!("a\r\n\r\n{}", block.replace('\n', "\r\n")), - block.to_string(), - "a\r\n", - ), - ] { - assert_eq!( - remove_appended_cargo_block(&written, &fragment).as_deref(), - Some(want), - "{written:?}" - ); - } - assert_eq!(remove_appended_cargo_block("a\n", block), None); - } - - #[test] - fn remove_fragment_once_absent_fragment_is_identity() { - // Defensive edge: callers check contains() first, so the not-found - // arm must be a pure no-op if that invariant ever breaks. - assert_eq!(remove_fragment_once("a\nb\n", "zzz"), "a\nb\n"); - } - - #[test] - fn remove_fragment_once_sole_content_collapses_to_empty() { - // EOF removal of the only content: the trailing-separator collapse - // must yield an empty file, not a lone newline. - assert_eq!(remove_fragment_once("F\n", "F"), ""); - assert_eq!(remove_fragment_once("\nF\n", "F"), ""); - } - #[tokio::test] - async fn pipenv_replay_restores_categories_and_refuses_drift_atomically() { - use crate::patch::redirect::{rewrite_registry_redirect, DepOverride}; - let dep: DepOverride=serde_json::from_value(serde_json::json!({"ecosystem":"pypi","name":"urllib3","version":"1.26.18","patchUuid":"one","token":"token","artifactUrl":"https://patch.socket.dev/patch/pypi/urllib3/1.26.18/token/one/urllib3-1.26.18-py3-none-any.whl","integrity":{"sha256":"a".repeat(64)}})).unwrap(); - let original="{\"_meta\":{\"pipfile-spec\":6},\"default\":{\"urllib3\":{\"version\":\"==1.26.18\"}},\"tests\":{\"urllib3\":{\"version\":\"==1.26.18\"}}}"; - let result = rewrite_registry_redirect( - &BTreeMap::from([("Pipfile.lock".into(), original.into())]), - &[dep], - ); - for drift in [false, true] { - let dir = TempDir::new().unwrap(); - let text = result.files["Pipfile.lock"].clone(); - // Drift = the REFERENCE itself changed (its `#sha256=` pin here); - // a hashes-only change next to an intact reference is what a - // Pipenv relock does and rolls back (see pipenv::restore). - let live = if drift { - text.replacen("#sha256=", "#sha256=0", 1) - } else { - text - }; - write(dir.path(), "Pipfile.lock", &live).await; - let mut state = state_with(result.edits.clone(), &["pkg:pypi/urllib3@1.26.18"]); - let before = state.edits.len(); - let preview = revert_remaining_redirect_edits(dir.path(), &mut state, true).await; - assert_eq!(preview.fully_reverted(), !drift); - assert_eq!(read(dir.path(), "Pipfile.lock").await, live); - assert_eq!(state.edits.len(), before); - let outcome = revert_remaining_redirect_edits(dir.path(), &mut state, false).await; - assert_eq!(outcome.fully_reverted(), !drift); - assert_eq!( - read(dir.path(), "Pipfile.lock").await, - if drift { live.as_str() } else { original } - ); - assert_eq!(state.edits.is_empty(), !drift); - } - } -} diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index 5e0b921c0..d6d64c3c0 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -7,14 +7,14 @@ use super::{DepOverride, FileEdit, RewriteResult, RewriteWarning}; use crate::crawlers::python_crawler::canonicalize_pypi_name; use crate::utils::purl::percent_decode_purl_component; -struct LogicalRequirement { - original: String, - text: String, - ending: String, - unterminated: bool, +pub(super) struct LogicalRequirement { + pub(super) original: String, + pub(super) text: String, + pub(super) ending: String, + pub(super) unterminated: bool, } -fn logical_requirements(content: &str) -> Vec { +pub(super) fn logical_requirements(content: &str) -> Vec { let physical: Vec<&str> = content.split_inclusive('\n').collect(); let mut requirements = Vec::new(); let mut index = 0; @@ -62,7 +62,7 @@ fn logical_requirements(content: &str) -> Vec { requirements } -fn unquoted_index(text: &str, target: char, after_whitespace: bool) -> Option { +pub(super) fn unquoted_index(text: &str, target: char, after_whitespace: bool) -> Option { let mut quote = None; let mut escaped = false; let mut previous = None; @@ -87,7 +87,7 @@ fn unquoted_index(text: &str, target: char, after_whitespace: bool) -> Option Vec<&str> { +pub(super) fn requirement_tokens(text: &str) -> Vec<&str> { let mut tokens = Vec::new(); let mut start = None; let mut quote = None; diff --git a/crates/socket-patch-core/src/patch/redirect/staged.rs b/crates/socket-patch-core/src/patch/redirect/staged.rs index cb3444967..d57675577 100644 --- a/crates/socket-patch-core/src/patch/redirect/staged.rs +++ b/crates/socket-patch-core/src/patch/redirect/staged.rs @@ -33,22 +33,6 @@ pub(super) async fn read_rel(project_root: &Path, rel: &str) -> Result Result, String> { - match staged.get(rel) { - Some(pending) => Ok(pending.clone()), - None => read_rel(project_root, rel).await, - } -} - /// Commit the staged files. Only reached once every inverse resolved, so a /// drift refusal never gets here; an I/O fault partway through is the one /// remaining way to stop mid-set, and it surfaces as `Err` naming the path diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index d5a2da881..6d1b2f5d0 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -1,18 +1,11 @@ -//! The hosted-mode ledger (`.socket/vendor/redirect-state.json`), written by -//! hosted-mode `scan` (the default mode; also `--mode hosted` / the legacy -//! `--redirect`). +//! The retired pre-v5 hosted-mode ledger (`.socket/vendor/redirect-state.json`). //! -//! Mirrors the vendor `state.json` shape but records a REMOTE per-dependency -//! redirect (no local artifact bytes). It carries the recorded [`FileEdit`]s -//! (which `rollback` and the hosted→vendored takeover replay in reverse to -//! restore the pre-redirect files) plus, per redirected PURL, the manifest -//! [`PatchRecord`] (file hashes + vulnerability metadata) so a post-install -//! `socket-patch vex` can attest the redirected patches against the installed -//! tree exactly as it does for `apply` / `vendor`. VEX folds `records` -//! into its record view (keyed by PURL, the same key the manifest uses) — -//! but only while the lockfile still wires each record's patch (the -//! CLI's `commands::vex_sources` liveness gate): a stale ledger alone never -//! attests. +//! socket-patch v5 derives hosted state from the lockfiles (see +//! `upstream::HostedPin` / `vex::discover`) and never writes this file. It +//! is read only for migration: `list` and `vex` may borrow a record's patch +//! details (vulnerabilities, file hashes) for a pin that is still wired in a +//! lockfile, and `rollback` / `remove` delete it once no hosted pin remains. +//! Its recorded edits are never replayed. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -20,11 +13,10 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use super::FileEdit; -use crate::constants::SOCKET_DIR; use crate::manifest::schema::PatchRecord; use crate::utils::fs::read_regular_to_bytes; -use crate::utils::purl::{canonical_purl, purl_name_version}; -use crate::utils::socket_dir::{remove_file_and_prune, write_json_ledger}; + +use crate::utils::socket_dir::write_json_ledger; /// Repo-relative path of the redirect ledger. pub const REDIRECT_STATE_REL: &str = ".socket/vendor/redirect-state.json"; @@ -65,71 +57,6 @@ impl RedirectState { } } - /// The stored record keys whose canonical purl (qualifiers stripped, - /// percent-decoded — [`canonical_purl`]) matches `purl`, in ledger - /// order. Normally zero or one; a hand-edited ledger may carry the same - /// package under two spellings, and every caller must drop them all. - pub(crate) fn record_keys_for(&self, purl: &str) -> Vec { - let target = canonical_purl(purl); - self.records - .keys() - .filter(|k| canonical_purl(k) == target) - .cloned() - .collect() - } - - /// The first `redirect_*` edit this release cannot classify (a newer - /// socket-patch's writer) whose `key`, `original` or `new` names - /// `@` at a package-name boundary. Anything that claims - /// that package's ledger data must refuse while one exists: dropping the - /// record or its known edits would strand the unknown one. - pub(crate) fn unclassified_edit_naming(&self, name: &str, version: &str) -> Option<&FileEdit> { - let needle = format!("{name}@{version}"); - let scoped = name.starts_with('@'); - let names = |v: &Option| match v { - Some(serde_json::Value::String(s)) => names_at_boundary(s, &needle, scoped), - Some(other) => names_at_boundary(&other.to_string(), &needle, scoped), - None => false, - }; - self.edits.iter().find(|e| { - is_unclassified_redirect_edit(e) - && (e - .key - .as_deref() - .is_some_and(|k| names_at_boundary(k, &needle, scoped)) - || names(&e.original) - || names(&e.new)) - }) - } -} - -fn is_unclassified_redirect_edit(edit: &FileEdit) -> bool { - edit.kind.starts_with("redirect_") - && super::replay::is_unclassified_kind(&edit.kind, &edit.action) -} - -fn is_name_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') -} - -/// Does `text` contain `needle` starting at a package-name boundary? A -/// match glued to a longer name (`left-pad@…` for `pad@…`) or to a scope -/// (`@scope/a@…` for an unscoped `a@…`) names a different package. -fn names_at_boundary(text: &str, needle: &str, scoped: bool) -> bool { - text.match_indices(needle).any(|(at, _)| { - let before = &text[..at]; - match before.chars().next_back() { - None => true, - Some('/') => { - scoped - || !before[..before.len() - 1] - .rsplit(|c: char| !(is_name_char(c) || c == '@')) - .next() - .is_some_and(|segment| segment.starts_with('@')) - } - Some(c) => !is_name_char(c), - } - }) } impl Default for RedirectState { @@ -138,65 +65,31 @@ impl Default for RedirectState { } } -/// A redirect ledger that exists on disk but cannot be loaded (torn write, -/// truncation, hand-editing gone wrong, or an unreadable file). The ledger is -/// the ONLY store of the pre-redirect lockfile originals a future revert -/// needs, so a loader that shrugged this off as "no ledger" would let the -/// next hosted run start fresh and silently overwrite that revert data. -/// Instead every load distinguishes absent (fine, fresh start) from malformed -/// (this error), and the hosted writer refuses to proceed. +/// A pre-v5 redirect ledger that exists on disk but cannot be loaded (torn +/// write, truncation, hand-editing gone wrong, or an unreadable file). Every +/// load distinguishes absent from malformed so read-only consumers can +/// surface it (as the `redirect_ledger_corrupt` warning) instead of silently +/// treating it as "no ledger". #[derive(Debug)] pub struct CorruptRedirectState { /// Absolute path of the malformed ledger. pub path: PathBuf, /// What went wrong reading/parsing it. pub detail: String, - /// Where [`CorruptRedirectState::quarantine`] moved the file, when it did. - pub quarantined_to: Option, /// True when the ledger could not be READ (an I/O error, or a directory / /// FIFO squatting the path) rather than parsed. The bytes on disk may be - /// perfectly valid revert data — or not a file at all — so - /// [`CorruptRedirectState::quarantine`] leaves them where they are and the - /// message asks for the I/O problem to be fixed, not for JSON repair. + /// perfectly valid pre-v5 data — or not a file at all — so the message + /// asks for the I/O problem to be fixed, not for JSON repair. pub unreadable: bool, } -impl CorruptRedirectState { - /// Move the malformed ledger aside to `redirect-state.json.corrupt` so no - /// later run can overwrite the revert data it may still hold. Never - /// clobbers an existing `.corrupt` file (an earlier quarantine may hold - /// older revert data); on any failure the original file simply stays put - /// — the caller's hard error already prevents overwriting it. An - /// UNREADABLE ledger is never moved: it is not known to be malformed. - /// - /// The quarantine file is the one sanctioned `.socket/vendor/` residue: - /// the empty-directory prunes are non-recursive and leave both it and the - /// directory in place until the user resolves it. - pub async fn quarantine(&mut self) { - if self.unreadable { - return; - } - let target = match self.path.parent() { - Some(parent) => parent.join("redirect-state.json.corrupt"), - None => return, - }; - if !matches!(tokio::fs::try_exists(&target).await, Ok(false)) { - return; - } - if tokio::fs::rename(&self.path, &target).await.is_ok() { - self.quarantined_to = Some(target); - } - } -} - impl std::fmt::Display for CorruptRedirectState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.unreadable { return write!( f, - "the redirect ledger {} cannot be read ({}); it may hold the \ - pre-redirect lockfile values a future revert needs, so it was \ - left in place and will not be overwritten. Fix the file's \ + "the pre-v5 redirect ledger {} cannot be read ({}); it was left \ + in place. Fix the file's \ permissions (or move a stray directory or special file at that \ path aside), then re-run.", self.path.display(), @@ -205,39 +98,21 @@ impl std::fmt::Display for CorruptRedirectState { } write!( f, - "the redirect ledger {} is malformed ({}); it records the \ - pre-redirect lockfile values a future revert needs, so it will \ - not be overwritten. ", + "the pre-v5 redirect ledger {} is malformed ({}); socket-patch v5 \ + only reads it for migration and never overwrites it. Repair its \ + JSON or restore it from version control, or delete it if you no \ + longer need its patch details.", self.path.display(), self.detail - )?; - match &self.quarantined_to { - Some(target) => write!( - f, - "The unreadable file was moved aside to {}; to recover, repair \ - its JSON and rename it back to redirect-state.json, or restore \ - the ledger and the rewritten files from version control. If \ - the revert data is expendable, delete the moved-aside file and \ - re-run.", - target.display() - ), - None => write!( - f, - "To recover, repair its JSON, restore it from version control, \ - or move it aside if the revert data is expendable, then re-run." - ), - } + ) } } impl std::error::Error for CorruptRedirectState {} /// Load the redirect ledger. Missing → `Ok(None)` (a fresh start is fine). -/// Present but unreadable/malformed → [`CorruptRedirectState`], so no caller -/// can mistake a torn ledger for "no ledger" and overwrite the revert data it -/// still holds (see the type's docs). Read-only consumers may degrade a -/// malformed ledger to "nothing to consult", but must surface it; the hosted -/// writer must abort. +/// Present but unreadable/malformed → [`CorruptRedirectState`]. Consumers +/// degrade a malformed ledger to "nothing to consult", but must surface it. /// /// The bytes come from the (untrusted) project tree through the FIFO-safe /// [`read_regular_to_bytes`] — non-blocking on Unix, rejecting FIFOs / @@ -255,7 +130,6 @@ pub async fn load_redirect_state( return Err(CorruptRedirectState { path, detail: e.to_string(), - quarantined_to: None, unreadable: true, }); } @@ -265,19 +139,15 @@ pub async fn load_redirect_state( Err(e) => Err(CorruptRedirectState { path, detail: format!("invalid JSON: {e}"), - quarantined_to: None, unreadable: false, }), } } -/// Persist the redirect ledger atomically (stage + fsync + rename, the same -/// hardened writer the sibling vendor ledger uses). A bare `fs::write` -/// truncates the target first, so a crash or `ENOSPC` mid-write would tear -/// the only store of the pre-redirect originals a future revert needs. A -/// byte-identical ledger already on disk (an idempotent hosted re-run) is -/// left untouched. Always a write, never a delete — see -/// [`persist_redirect_state`] for the emptied-ledger rule. +/// Write a redirect ledger atomically. socket-patch v5 never writes this +/// file: this exists only so tests (and migration tooling) can lay down a +/// pre-v5 ledger fixture in the exact on-disk shape older releases wrote. +#[doc(hidden)] pub async fn save_redirect_state( project_root: &Path, state: &RedirectState, @@ -285,166 +155,13 @@ pub async fn save_redirect_state( write_json_ledger(&project_root.join(REDIRECT_STATE_REL), state).await } -/// Drop one PURL's superseded takeover leftovers from the ledger: its -/// `records` entry (canonical-purl match, qualifiers stripped and -/// percent-decoded) and every recorded edit keyed to that package. This is -/// the npm-family half of the hosted→vendored takeover reconciliation: the -/// vendored flows call it ONLY after the LIVE lockfile provably wires the -/// package to the committed `.socket/vendor/` artifact and no longer -/// resolves the hosted URL — at that point the vendor ledger's wiring -/// `original` embeds the hosted-spliced lock fragment, so `vendor --revert` -/// stays lossless without these ledger edits, and keeping them would feed -/// VEX/updates stale records and re-fire the takeover warning on every -/// later run. CARGO purls are refused (returns `false`, drops nothing): a -/// cargo takeover must revert the hosted edits ON DISK first — that path is -/// [`revert_cargo_redirect_purl`](super::revert_cargo_redirect_purl), which -/// does its own ledger drop. -/// -/// The edit matcher is ARTIFACT-ANCHORED, never name-anchored. An edit is -/// claimed when either: -/// -/// * its `new` content references THIS purl's hosted artifact — every hosted -/// artifact URL embeds the patch uuid (on ANY patch-server host; the same -/// invariant the takeover classifier's `redirect_record_live` proof rests -/// on), and a uuid is hex-and-dashes so it spells identically raw, -/// `\/`-escaped (old composer) and percent-encoded (yarn-berry -/// `::__archiveUrl=`). The uuid(s) come from this purl's own `records` -/// entry, captured before it is removed. This is what claims the -/// version-blind key shapes: npm `node_modules/…` path keys, legacy -/// `dependencies` bare-name keys, bun `/` keys. -/// * (secondary guard, for when the record — and with it the artifact URL — -/// is unavailable) its key is a VERSION-EXACT instance key: -/// `"name@version"`, pnpm v6 peer-suffixed `"name@version(peer…)"`, or the -/// pnpm-v5 respelling `"name@version_peer…"`. -/// -/// Never claim by NAME alone (`key == name`, key ends with `"/name"`): with -/// two versions of one package hosted, vendoring one would delete BOTH -/// versions' path-keyed edits, destroying the other version's revert -/// originals. Version-blind keys with no artifact anchor are KEPT (fail-closed — they may be the other version's -/// only revert data). Consequence for the CLI's takeover-overlap fallback -/// matcher (which still matches edit keys by bare name, but ONLY when -/// `records` is empty — the degraded record-fetch-failed ledger): a normal -/// record-carrying ledger reconciles fully here (the record removal alone -/// ends the overlap), while a degraded ledger's unattributable path-keyed -/// edits stay and its takeover warning keeps advising the manual per-package -/// cleanup — the correct outcome when the ledger lacks the records needed to -/// attribute edits to a version safely. -/// -/// Edits that are not package-keyed (e.g. the pnpm workspace-trust edit, -/// keyed `"trustLockfile"`) stay: they belong to the hosted flow's own -/// config surface and other still-redirected package(s) may ride on them. -/// -/// A `redirect_*` edit kind this release cannot classify that names the -/// purl or would be claimed by the rules above drops nothing and returns -/// `false`: its lockfile may still resolve the hosted artifact. -/// -/// Returns whether anything was removed. The caller persists the mutated -/// ledger via [`persist_redirect_state`] (atomic; an emptied ledger is -/// deleted). -pub fn drop_superseded_purl(state: &mut RedirectState, purl: &str) -> bool { - let target = canonical_purl(purl); - if target.starts_with("pkg:cargo/") { - return false; - } - let Some((name, version)) = purl_name_version(&target) else { - return false; - }; - let (name, version) = (name.to_string(), version.to_string()); - - if state.unclassified_edit_naming(&name, &version).is_some() { - return false; - } - - let record_keys = state.record_keys_for(purl); - // THIS purl's patch uuid(s), captured before the records are removed — - // the artifact anchor (see the doc comment). Distinct purls (including - // two versions of one package) carry distinct patch uuids, so a uuid - // match is version-exact by construction. - let uuids: Vec = record_keys - .iter() - .filter_map(|k| state.records.get(k)) - .map(|r| r.uuid.clone()) - // An empty uuid (hand-repaired or degraded ledger) is no anchor at - // all: `contains("")` matches EVERY edit, claiming other packages' - // revert data. Fail closed to the version-exact-only path instead. - .filter(|u| !u.is_empty()) - .collect(); - - let name_at_version = format!("{name}@{version}"); - let claims = |e: &FileEdit| { - let Some(key) = e.key.as_deref() else { - // No key ⇒ not attributable to any package; keep. - return false; - }; - // Version-exact instance keys: `name@version`, pnpm v6 peer-suffixed - // `name@version(peer…)`, pnpm v5 respelled `name@version_peer…`, vlt - // peer/modifier variants `name@version~extra`. - let version_exact = key == name_at_version - || key - .strip_prefix(name_at_version.as_str()) - .is_some_and(|rest| rest.starts_with(['(', '_', '~'])); - // Artifact anchor: the edit's rewritten (`new`) content references - // this purl's hosted artifact (its patch uuid — spelling-invariant - // across raw / `\/`-escaped / percent-encoded URL forms). - let anchored = !uuids.is_empty() - && e.new.as_ref().is_some_and(|new| { - // A text-fragment payload is probed in place; only an object - // payload (a whole JSON lock entry) needs re-serializing. - let text: std::borrow::Cow<'_, str> = match new { - serde_json::Value::String(s) => std::borrow::Cow::Borrowed(s.as_str()), - other => std::borrow::Cow::Owned(other.to_string()), - }; - uuids.iter().any(|uuid| text.contains(uuid.as_str())) - }); - version_exact || anchored - }; - if state - .edits - .iter() - .any(|e| is_unclassified_redirect_edit(e) && claims(e)) - { - return false; - } - - for key in &record_keys { - state.records.remove(key); - } - let edits_before = state.edits.len(); - state.edits.retain(|e| !claims(e)); - - !record_keys.is_empty() || state.edits.len() != edits_before -} - -/// Persist the redirect ledger via [`save_redirect_state`]'s atomic writer. -/// An EMPTY ledger (no edits, no records) is DELETED instead: a residual -/// empty file would keep takeover-overlap detection and VEX reading a ledger -/// that asserts nothing. The delete then prunes a now-empty `.socket/vendor/` -/// (best-effort, non-recursive — the vendor ledger, artifacts or a `.corrupt` -/// quarantine keep it), so a fully unwound hosted project leaves no residue -/// below `.socket/` itself, which the lock guard owns. A failed unlink -/// propagates before any prune. -pub async fn persist_redirect_state( - project_root: &Path, - state: &RedirectState, -) -> std::io::Result<()> { - if state.edits.is_empty() && state.records.is_empty() { - return remove_file_and_prune( - &project_root.join(REDIRECT_STATE_REL), - &project_root.join(SOCKET_DIR), - ) - .await; - } - save_redirect_state(project_root, state).await -} - #[cfg(test)] mod tests { use super::*; use crate::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; - /// The sample record's patch uuid — hosted artifact URLs embed it (the - /// artifact anchor `drop_superseded_purl` claims edits by). + /// The sample record's patch uuid. const SAMPLE_UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; fn sample_record() -> PatchRecord { @@ -481,12 +198,6 @@ mod tests { } } - /// The hosted artifact URL shape the patch server serves: the patch uuid - /// is a path segment, exactly the anchor `drop_superseded_purl` matches. - fn hosted_url(name: &str, version: &str, uuid: &str) -> String { - format!("https://patch.test/patch/npm/{name}/{version}/{uuid}/{name}-{version}.tgz") - } - #[test] fn round_trips_records_through_json() { let mut state = RedirectState::new(); @@ -584,540 +295,6 @@ mod tests { assert_eq!(loaded.edits[1].kind, "redirect_kind_from_the_future"); } - fn edit(path: &str, kind: &str, key: Option<&str>) -> FileEdit { - FileEdit { - path: path.to_string(), - kind: kind.to_string(), - action: "rewritten".to_string(), - key: key.map(str::to_string), - original: Some(serde_json::json!("orig")), - new: Some(serde_json::json!("new")), - } - } - - /// An edit whose rewritten content points at a hosted artifact URL — the - /// shape the npm rewriter records (`new` = the spliced resolved/integrity - /// pair), carrying the artifact anchor. - fn edit_resolved(path: &str, kind: &str, key: &str, url: &str) -> FileEdit { - FileEdit { - path: path.to_string(), - kind: kind.to_string(), - action: "rewritten".to_string(), - key: Some(key.to_string()), - original: Some(serde_json::json!({ - "resolved": "https://registry.npmjs.org/upstream.tgz", - "integrity": "sha512-UPSTREAM==" - })), - new: Some(serde_json::json!({ "resolved": url, "integrity": "sha512-P==" })), - } - } - - /// The takeover reconciliation drops exactly the superseded package's - /// halves — its `records` entry and every edit keyed to it (pnpm - /// `name@version`, pnpm v6 peer-suffixed and v5 `_`-suffixed instances, - /// npm `node_modules/…` paths whose rewritten content carries this purl's - /// hosted artifact) — while other packages' data and non-package-keyed - /// edits (the pnpm workspace-trust edit) survive verbatim. - #[test] - fn drop_superseded_purl_removes_both_halves_and_only_them() { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - state - .records - .insert("pkg:npm/minimist@1.2.2".to_string(), sample_record()); - state.edits = vec![ - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0"), - ), - // pnpm v6 peer-suffixed instance key for the SAME package. - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0(react@18.2.0)"), - ), - // pnpm v5 `_`-suffixed instance key (the rewriter's own respelled - // `/left-pad/1.3.0_react@18.2.0` key) for the same package. - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0_react@18.2.0"), - ), - // npm nested node_modules path for the same package: the key is - // version-blind, so the claim rides the artifact anchor in `new`. - edit_resolved( - "package-lock.json", - "redirect_npm_lock_entry", - "node_modules/a/node_modules/left-pad", - &hosted_url("left-pad", "1.3.0", SAMPLE_UUID), - ), - // Another package's edit — must survive. - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("minimist@1.2.2"), - ), - // Non-package-keyed workspace-trust edit — must survive. - edit( - "pnpm-workspace.yaml", - "redirect_pnpm_workspace_trust", - Some("trustLockfile"), - ), - ]; - - assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - - assert!( - !state.records.contains_key("pkg:npm/left-pad@1.3.0"), - "the superseded record must be dropped" - ); - assert!( - state.records.contains_key("pkg:npm/minimist@1.2.2"), - "other packages' records must survive" - ); - let keys: Vec<&str> = state - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .collect(); - assert_eq!( - keys, - vec!["minimist@1.2.2", "trustLockfile"], - "only the superseded package's edits may be dropped: {keys:?}" - ); - - // Idempotent: a second drop finds nothing and reports it. - assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - } - - /// TWO versions of one package hosted at once: dropping the vendored one - /// must not touch the other version's halves. The npm path keys - /// (`node_modules/…/left-pad`) and legacy `dependencies` bare-name keys - /// carry NO version, so a name-anchored matcher would claim BOTH - /// versions' edits here — destroying left-pad@2.0.0's pre-redirect - /// originals (its only revert data) when left-pad@1.3.0 was vendored. - /// Only edits whose rewritten content references the dropped purl's own - /// hosted artifact go. - #[test] - fn drop_superseded_purl_never_claims_the_other_hosted_versions_edits() { - const UUID_V2: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0f9e8d7c6b5a"; - let url_v1 = hosted_url("left-pad", "1.3.0", SAMPLE_UUID); - let url_v2 = hosted_url("left-pad", "2.0.0", UUID_V2); - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - state.records.insert( - "pkg:npm/left-pad@2.0.0".to_string(), - record_with_uuid(UUID_V2), - ); - state.edits = vec![ - // v1's edits: a version-blind path key (anchored via `new`) and - // a version-exact pnpm key. - edit_resolved( - "package-lock.json", - "redirect_npm_lock_entry", - "node_modules/left-pad", - &url_v1, - ), - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0"), - ), - // v2's edits: a nested path key, a legacy bare-name key, and a - // version-exact pnpm key — ALL must survive dropping v1. - edit_resolved( - "package-lock.json", - "redirect_npm_lock_entry", - "node_modules/a/node_modules/left-pad", - &url_v2, - ), - edit_resolved( - "package-lock.json", - "redirect_npm_lock_dep", - "left-pad", - &url_v2, - ), - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@2.0.0"), - ), - ]; - - assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - - assert!( - !state.records.contains_key("pkg:npm/left-pad@1.3.0"), - "the vendored version's record must be dropped" - ); - assert!( - state.records.contains_key("pkg:npm/left-pad@2.0.0"), - "the still-hosted version's record must survive" - ); - let keys: Vec<&str> = state - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .collect(); - assert_eq!( - keys, - vec![ - "node_modules/a/node_modules/left-pad", - "left-pad", - "left-pad@2.0.0" - ], - "the other hosted version's edits are its only revert data and \ - must survive verbatim: {keys:?}" - ); - } - - /// A DEGRADED ledger (record fetch failed: `records` empty, edits only) - /// offers no artifact anchor. The secondary guard must stay version-exact - /// — `name@version` plus the `(`/`_` instance suffixes — and version-blind - /// path/bare-name keys must be KEPT (they cannot be attributed to a - /// version, and dropping them could destroy another version's revert - /// originals). Fail closed: leftover keys mean the takeover warning's - /// manual advisory keeps firing, which is the correct degraded outcome. - #[test] - fn drop_superseded_purl_without_a_record_claims_only_version_exact_keys() { - let mut state = RedirectState::new(); - state.edits = vec![ - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0"), - ), - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0_react@18.2.0"), - ), - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0(react@18.2.0)"), - ), - // A LONGER version sharing the prefix: `1.3.0` must not claim - // `1.3.01`'s instances (the `_`/`(` boundary is load-bearing). - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.01_react@18.2.0"), - ), - // Version-blind keys: unattributable without the anchor — keep. - edit_resolved( - "package-lock.json", - "redirect_npm_lock_entry", - "node_modules/left-pad", - "https://patch.test/no-uuid-here/left-pad-1.3.0.tgz", - ), - edit_resolved( - "package-lock.json", - "redirect_npm_lock_dep", - "left-pad", - "https://patch.test/no-uuid-here/left-pad-1.3.0.tgz", - ), - ]; - - assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - - let keys: Vec<&str> = state - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .collect(); - assert_eq!( - keys, - vec![ - "left-pad@1.3.01_react@18.2.0", - "node_modules/left-pad", - "left-pad" - ], - "without an artifact anchor only version-exact instance keys may \ - be claimed: {keys:?}" - ); - } - - fn future_lock_edit(name: &str, version: &str, url: &str) -> FileEdit { - FileEdit { - path: "future.lock".to_string(), - kind: "redirect_future_lock_entry".to_string(), - action: "rewritten".to_string(), - key: Some(format!("{name}@{version}")), - original: Some(serde_json::json!(format!("{name}@{version} sha512-r"))), - new: Some(serde_json::json!(format!("{name}@{version} {url}"))), - } - } - - #[test] - fn drop_superseded_purl_drops_nothing_beside_an_unclassified_edit_naming_it() { - let url = hosted_url("left-pad", "1.3.0", SAMPLE_UUID); - let unanchored = hosted_url("left-pad", "1.3.0", "0e0e0e0e-0000-4000-8000-000000000000"); - for (with_record, future_key, future_url) in [ - (true, "left-pad@1.3.0", url.as_str()), - (false, "left-pad@1.3.0", url.as_str()), - (false, "left-pad@1.3.0~custom", unanchored.as_str()), - ] { - let mut state = RedirectState::new(); - if with_record { - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - } - state.edits = vec![ - edit_resolved( - "yarn.lock", - "redirect_yarn_classic_entry", - "left-pad@1.3.0", - &url, - ), - FileEdit { - key: Some(future_key.to_string()), - ..future_lock_edit("left-pad", "1.3.0", future_url) - }, - ]; - let before = serde_json::to_value(&state).unwrap(); - assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - assert_eq!( - serde_json::to_value(&state).unwrap(), - before, - "{with_record} {future_key}" - ); - } - } - - #[test] - fn drop_superseded_purl_drops_nothing_when_an_unclassified_edit_is_anchored() { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - state.edits = vec![FileEdit { - key: Some("nodes/0".to_string()), - ..edit_resolved( - "future.lock", - "redirect_future_lock_entry", - "unused", - &hosted_url("left-pad", "1.3.0", SAMPLE_UUID), - ) - }]; - let before = serde_json::to_value(&state).unwrap(); - assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - assert_eq!(serde_json::to_value(&state).unwrap(), before); - } - - #[test] - fn drop_superseded_purl_ignores_an_unclassified_edit_for_another_package() { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/pad@1.3.0".to_string(), sample_record()); - state.edits = vec![ - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("pad@1.3.0"), - ), - future_lock_edit( - "left-pad", - "1.3.0", - &hosted_url("left-pad", "1.3.0", "0e0e0e0e-0000-4000-8000-000000000000"), - ), - future_lock_edit( - "@scope/pad", - "1.3.0", - &hosted_url( - "@scope/pad", - "1.3.0", - "1e1e1e1e-0000-4000-8000-000000000000", - ), - ), - ]; - assert!(drop_superseded_purl(&mut state, "pkg:npm/pad@1.3.0")); - assert!(state.records.is_empty()); - let kinds: Vec<&str> = state.edits.iter().map(|e| e.kind.as_str()).collect(); - assert_eq!( - kinds, - ["redirect_future_lock_entry", "redirect_future_lock_entry"] - ); - } - - #[test] - fn drop_superseded_purl_claims_vlt_variant_keys() { - let mut state = RedirectState::new(); - state.edits = vec![ - edit( - "vlt-lock.json", - "redirect_vlt_lock_node", - Some("left-pad@1.3.0"), - ), - edit( - "vlt-lock.json", - "redirect_vlt_lock_node", - Some("left-pad@1.3.0~peer.0df72515a50372ba"), - ), - edit( - "vlt-lock.json", - "redirect_vlt_lock_node", - Some("left-pad@1.3.0-rc.1~peer.1"), - ), - ]; - assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - let keys: Vec<&str> = state - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .collect(); - assert_eq!(keys, ["left-pad@1.3.0-rc.1~peer.1"]); - } - - /// A version-boundary key (`left-pad@1.3.10`) and a different package - /// whose name merely ends with the target's (`not-left-pad`) must never - /// be claimed — the `/`-boundary and `(`-boundary checks are load-bearing. - #[test] - fn drop_superseded_purl_respects_name_and_version_boundaries() { - let mut state = RedirectState::new(); - state.edits = vec![ - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.10"), - ), - edit( - "package-lock.json", - "redirect_npm_lock_entry", - Some("node_modules/not-left-pad"), - ), - ]; - assert!(!drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.1")); - assert_eq!(state.edits.len(), 2, "no foreign edit may be claimed"); - } - - /// Scoped names: the record key may carry the percent-encoded API form - /// while the caller passes the canonical decoded purl; both halves must - /// still be claimed (the path-keyed edit via the artifact anchor its - /// rewritten content carries). - #[test] - fn drop_superseded_purl_matches_percent_encoded_scoped_records() { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/%40scope%2Fpkg@1.0.0".to_string(), sample_record()); - state.edits = vec![edit_resolved( - "package-lock.json", - "redirect_npm_lock_entry", - "node_modules/@scope/pkg", - &hosted_url("%40scope%2Fpkg", "1.0.0", SAMPLE_UUID), - )]; - assert!(drop_superseded_purl(&mut state, "pkg:npm/@scope/pkg@1.0.0")); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// Cargo purls are refused: their takeover must revert the hosted edits - /// ON DISK first (`revert_cargo_redirect_purl`), so a bare ledger drop - /// would destroy the only revert data. Fail closed by dropping nothing. - #[test] - fn drop_superseded_purl_refuses_cargo() { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:cargo/cfg-if@1.0.4".to_string(), sample_record()); - state.edits = vec![edit( - "Cargo.lock", - "redirect_cargo_lock_entry", - Some("cfg-if@1.0.4"), - )]; - assert!(!drop_superseded_purl(&mut state, "pkg:cargo/cfg-if@1.0.4")); - assert_eq!(state.records.len(), 1); - assert_eq!(state.edits.len(), 1); - } - - /// A purl that cannot name one exact package instance — versionless - /// (`pkg:npm/left-pad`) or empty-named (`pkg:npm/@1.0.0`, whose only `@` - /// is at index 0) — is refused outright: nothing is dropped and `false` - /// is reported. Fail closed — without a `(name, version)` pair the - /// matcher could only claim by name, the exact over-deletion the - /// artifact anchor exists to prevent. - #[test] - fn drop_superseded_purl_unversioned_purl_drops_nothing() { - for bogus in ["pkg:npm/left-pad", "pkg:npm/@1.0.0"] { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - state.edits = vec![edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0"), - )]; - assert!( - !drop_superseded_purl(&mut state, bogus), - "{bogus} names no exact instance and must report false" - ); - assert_eq!( - state.records.len(), - 1, - "{bogus} must drop no record (fail closed)" - ); - assert_eq!( - state.edits.len(), - 1, - "{bogus} must drop no edit (fail closed)" - ); - } - } - - /// An edit with NO key is not attributable to any package, so the retain - /// pass keeps it BEFORE consulting the artifact anchor — even when its - /// rewritten content happens to reference the dropped purl's own hosted - /// artifact. The documented fail-closed contract: a keyless edit may be - /// some other surface's only revert data, and keeping a stale edit is - /// recoverable where destroying revert originals is not. - #[test] - fn drop_superseded_purl_keeps_keyless_edits_even_when_anchored() { - let keyless = FileEdit { - path: "package-lock.json".to_string(), - kind: "redirect_npm_lock_entry".to_string(), - action: "rewritten".to_string(), - key: None, - original: Some(serde_json::json!("orig")), - new: Some(serde_json::json!({ - "resolved": hosted_url("left-pad", "1.3.0", SAMPLE_UUID), - "integrity": "sha512-P==" - })), - }; - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - state.edits = vec![ - keyless.clone(), - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0"), - ), - ]; - - assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - - assert!( - state.records.is_empty(), - "the superseded record must still be dropped" - ); - assert_eq!( - state.edits, - vec![keyless], - "the keyless edit must survive verbatim even though its rewritten \ - content carries the dropped purl's artifact uuid" - ); - } - #[tokio::test] async fn load_missing_ledger_is_none() { let tmp = tempfile::tempdir().unwrap(); @@ -1165,9 +342,8 @@ mod tests { #[tokio::test] async fn load_malformed_ledger_is_a_hard_error_naming_the_file() { - // A torn/hand-mangled ledger must NOT load as "no ledger": a - // tolerant `None` would let the next hosted run start a fresh ledger - // and silently overwrite the only copy of the pre-redirect revert data. + // A torn/hand-mangled ledger must NOT load as "no ledger": callers + // surface it rather than silently ignoring it. let tmp = tempfile::tempdir().unwrap(); let dir = tmp.path().join(".socket/vendor"); tokio::fs::create_dir_all(&dir).await.unwrap(); @@ -1182,72 +358,14 @@ mod tests { "error must name the file: {message}" ); assert!( - message.contains("revert"), - "error must explain what is at stake: {message}" + message.contains("pre-v5") && message.contains("never overwrites"), + "error must say the file is a read-only pre-v5 leftover: {message}" ); // The pure load never mutates the project. assert!(dir.join("redirect-state.json").exists()); assert!(!dir.join("redirect-state.json.corrupt").exists()); } - #[tokio::test] - async fn quarantine_moves_the_malformed_ledger_aside_preserving_bytes() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join(".socket/vendor"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("redirect-state.json"), b"{ torn ledger") - .await - .unwrap(); - let mut err = load_redirect_state(tmp.path()).await.unwrap_err(); - err.quarantine().await; - assert_eq!( - err.quarantined_to.as_deref(), - Some(dir.join("redirect-state.json.corrupt").as_path()) - ); - assert!( - err.to_string().contains("redirect-state.json.corrupt"), - "error must point at the moved-aside file: {err}" - ); - assert!(!dir.join("redirect-state.json").exists()); - assert_eq!( - tokio::fs::read(dir.join("redirect-state.json.corrupt")) - .await - .unwrap(), - b"{ torn ledger", - "quarantine must preserve the corrupt bytes verbatim" - ); - } - - #[tokio::test] - async fn quarantine_never_clobbers_an_earlier_corrupt_snapshot() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join(".socket/vendor"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write( - dir.join("redirect-state.json.corrupt"), - b"older revert data", - ) - .await - .unwrap(); - tokio::fs::write(dir.join("redirect-state.json"), b"{ newer torn") - .await - .unwrap(); - let mut err = load_redirect_state(tmp.path()).await.unwrap_err(); - err.quarantine().await; - assert!(err.quarantined_to.is_none()); - assert_eq!( - tokio::fs::read(dir.join("redirect-state.json.corrupt")) - .await - .unwrap(), - b"older revert data", - "an earlier quarantine snapshot must never be overwritten" - ); - assert!( - dir.join("redirect-state.json").exists(), - "with the quarantine slot taken the malformed file stays put" - ); - } - /// mkfifo(2) directly, not the /usr/bin/mkfifo binary: spawning a child /// flakes under heavy parallel load (fork/exec starvation) and the /// syscall needs no process at all. @@ -1290,30 +408,23 @@ mod tests { let _ = std::fs::OpenOptions::new().write(true).open(&fifo); panic!("load_redirect_state must complete promptly with a FIFO ledger"); }; - let mut err = result.unwrap_err(); + let err = result.unwrap_err(); assert_eq!(err.path, fifo, "the error must name the planted path"); assert!(err.unreadable, "a non-regular file is an I/O problem"); // The pure load never mutates the project — the FIFO stays put. assert!(fifo.exists()); - // And neither does the quarantine: a file we could not read is not - // known to be malformed, so it is never moved aside. - err.quarantine().await; - assert!(err.quarantined_to.is_none()); - assert!(fifo.exists()); - assert!(!dir.join("redirect-state.json.corrupt").exists()); } /// An I/O failure (here: a directory squatting the ledger path) is /// classified as UNREADABLE, not malformed: the message names the I/O - /// problem and does not tell the user to "repair its JSON", and - /// `quarantine` refuses to move the path aside. + /// problem and does not tell the user to "repair its JSON". #[tokio::test] - async fn load_unreadable_ledger_is_not_quarantined_or_called_malformed() { + async fn load_unreadable_ledger_is_not_called_malformed() { let tmp = tempfile::tempdir().unwrap(); let squatter = tmp.path().join(REDIRECT_STATE_REL); tokio::fs::create_dir_all(&squatter).await.unwrap(); - let mut err = load_redirect_state(tmp.path()).await.unwrap_err(); + let err = load_redirect_state(tmp.path()).await.unwrap_err(); assert!(err.unreadable); let message = err.to_string(); assert!( @@ -1324,16 +435,9 @@ mod tests { !message.contains("malformed") && !message.contains("repair its JSON"), "an unreadable ledger must not be described as malformed: {message}" ); - err.quarantine().await; - assert!(err.quarantined_to.is_none()); assert!(squatter.is_dir(), "the squatting path is left in place"); - assert!(!tmp - .path() - .join(".socket/vendor/redirect-state.json.corrupt") - .exists()); - // The malformed classification is unchanged: parse failures still - // say so and still quarantine. + // Parse failures are classified as malformed. tokio::fs::remove_dir(&squatter).await.unwrap(); tokio::fs::write(&squatter, b"{ torn").await.unwrap(); let err = load_redirect_state(tmp.path()).await.unwrap_err(); @@ -1341,63 +445,6 @@ mod tests { assert!(err.to_string().contains("malformed")); } - /// A record carrying an EMPTY uuid (a hand-repaired ledger — a workflow - /// the corrupt-ledger message itself instructs — or a degraded record - /// fetch) must not turn the artifact anchor into a match-everything - /// wildcard: `text.contains("")` is true for EVERY edit with rewritten - /// content, so dropping one purl would claim every other package's edits - /// and destroy their only revert data. No usable anchor ⇒ fall back to - /// the version-exact-only claim, exactly like the recordless ledger. - #[test] - fn drop_superseded_purl_empty_uuid_record_claims_no_anchored_edits() { - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), record_with_uuid("")); - state - .records - .insert("pkg:npm/minimist@1.2.2".to_string(), sample_record()); - state.edits = vec![ - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("left-pad@1.3.0"), - ), - // ANOTHER package's edits — its path-keyed lock entry (rewritten - // content carrying its own artifact URL) and its version-exact - // pnpm key. Both must survive dropping left-pad. - edit_resolved( - "package-lock.json", - "redirect_npm_lock_entry", - "node_modules/minimist", - &hosted_url("minimist", "1.2.2", SAMPLE_UUID), - ), - edit( - "pnpm-lock.yaml", - "redirect_pnpm_resolution", - Some("minimist@1.2.2"), - ), - ]; - - assert!(drop_superseded_purl(&mut state, "pkg:npm/left-pad@1.3.0")); - - assert!( - state.records.contains_key("pkg:npm/minimist@1.2.2"), - "the other package's record must survive" - ); - let keys: Vec<&str> = state - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .collect(); - assert_eq!( - keys, - vec!["node_modules/minimist", "minimist@1.2.2"], - "an empty uuid offers no anchor and may claim only the dropped \ - purl's version-exact keys: {keys:?}" - ); - } - #[tokio::test] async fn save_writes_atomically_and_round_trips() { let tmp = tempfile::tempdir().unwrap(); @@ -1472,124 +519,4 @@ mod tests { ); } - /// Persisting an EMPTY state into a project with no ledger must succeed - /// as a pure no-op: the delete-instead-of-write path tolerates NotFound - /// (a fresh project has nothing to delete) and must not scaffold - /// `.socket/` or leave a residual empty ledger behind. - #[tokio::test] - async fn persist_empty_state_with_no_ledger_is_a_no_op() { - let tmp = tempfile::tempdir().unwrap(); - persist_redirect_state(tmp.path(), &RedirectState::new()) - .await - .unwrap(); - assert!( - !tmp.path().join(REDIRECT_STATE_REL).exists(), - "no ledger may be created by an empty persist" - ); - assert!( - !tmp.path().join(".socket").exists(), - "an empty persist must not scaffold .socket/" - ); - } - - /// Emptying the ledger deletes it AND prunes the now-empty - /// `.socket/vendor/` it lived in — but never `.socket/` itself (the lock - /// guard owns that level). - #[tokio::test] - async fn persist_empty_state_prunes_the_emptied_vendor_dir() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - save_redirect_state(tmp.path(), &state).await.unwrap(); - // Something else lives in `.socket/` (the lock, a manifest…). - tokio::fs::write(tmp.path().join(".socket/apply.lock"), b"") - .await - .unwrap(); - - persist_redirect_state(tmp.path(), &RedirectState::new()) - .await - .unwrap(); - - assert!(!tmp.path().join(REDIRECT_STATE_REL).exists()); - assert!( - !tmp.path().join(".socket/vendor").exists(), - "an emptied hosted ledger leaves no .socket/vendor/ husk" - ); - assert!( - tmp.path().join(".socket").exists(), - ".socket/ itself is never pruned here" - ); - } - - /// The prune is non-recursive: a `.corrupt` quarantine (the one - /// sanctioned residue) or the sibling vendor ledger keeps `.socket/vendor/`. - #[tokio::test] - async fn persist_empty_state_keeps_vendor_dir_with_siblings() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - save_redirect_state(tmp.path(), &state).await.unwrap(); - let dir = tmp.path().join(".socket/vendor"); - tokio::fs::write(dir.join("redirect-state.json.corrupt"), b"older") - .await - .unwrap(); - - persist_redirect_state(tmp.path(), &RedirectState::new()) - .await - .unwrap(); - - assert!(!dir.join("redirect-state.json").exists()); - assert!(dir.join("redirect-state.json.corrupt").exists()); - assert!(dir.exists(), "a non-empty vendor dir is kept"); - } - - /// A FAILED delete of the emptied ledger (anything but NotFound) must - /// propagate, never report success: callers treat `Ok` as "the ledger no - /// longer asserts anything", and a swallowed error would leave a live - /// ledger feeding VEX and takeover detection stale state. - #[cfg(unix)] - #[tokio::test] - async fn persist_empty_state_propagates_non_notfound_delete_errors() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join(".socket/vendor"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - let ledger = dir.join("redirect-state.json"); - let mut nonempty = RedirectState::new(); - nonempty - .records - .insert("pkg:npm/left-pad@1.3.0".to_string(), sample_record()); - tokio::fs::write(&ledger, serde_json::to_string_pretty(&nonempty).unwrap()) - .await - .unwrap(); - // A read-only parent dir makes the unlink fail with EACCES. - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap(); - // Root ignores mode bits; skip there (CI containers sometimes run as root). - if std::fs::File::create(dir.join("probe")).is_ok() { - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)); - let _ = std::fs::remove_file(dir.join("probe")); - eprintln!("skipping: running as root, 0555 does not block writes"); - return; - } - - let err = persist_redirect_state(tmp.path(), &RedirectState::new()) - .await - .unwrap_err(); - assert_eq!( - err.kind(), - std::io::ErrorKind::PermissionDenied, - "the delete failure must propagate verbatim: {err}" - ); - - // Restore so the tempdir can clean up, then confirm nothing was lost. - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert!( - ledger.exists(), - "a failed delete must leave the ledger in place" - ); - } } diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs deleted file mode 100644 index 268bec132..000000000 --- a/crates/socket-patch-core/src/patch/redirect/takeover.rs +++ /dev/null @@ -1,5896 +0,0 @@ -//! Cross-mode takeover: per-purl revert of a HOSTED redirect, driven by the -//! redirect ledger's recorded [`FileEdit`]s. -//! -//! The vendored flows (`vendor`, `scan --mode vendored`) call this BEFORE -//! vendoring a package the hosted redirect ledger still claims, so a -//! hosted→vendored migration leaves the project FULLY in vendored mode. -//! -//! Cargo: Cargo.toml loses its `registry = "socket-patch-…"` pin, Cargo.lock -//! gets its original crates.io `source`/`checksum` back (so the subsequent -//! vendor detach records the PRISTINE originals in the vendor ledger, not the -//! hosted values), and the now-unused `[registries.socket-patch-…]` block is -//! dropped. Without this, `[patch.crates-io]` cannot even apply (it only -//! patches crates-io-sourced deps) and the project is unbuildable in both -//! modes. -//! -//! npm family (package-lock/npm-shrinkwrap, yarn classic, yarn berry, pnpm, -//! bun): each recorded lock edit's `original` fragment is replayed over its -//! `new` fragment. For most flavors the follow-up vendor rewire happens to -//! succeed either way (the vendored wiring replaces whatever resolution is -//! present), but WITHOUT the pre-revert the vendor ledger records the -//! grant-tokenized hosted fragment as its unrecoverable pre-vendor -//! "original" (so `vendor --revert` restores an expiring hosted URL with no -//! CLI path back to registry state), and the superseded redirect -//! records/edits survive forever — a stale ledger that VEX/audits keep -//! reading and a replay hazard for any later redirect revert. bun is -//! stricter still: its hosted rewrite REPLACES the `name@version` spec the -//! bun vendor backend keys on (registry 4-tuple → URL 3-tuple), so without -//! the pre-revert the package cannot be vendored at all -//! (`vendor_lock_entry_not_found`). -//! -//! golang: the module's go.mod `replace` and the socket module's go.sum -//! lines are removed and the pruned upstream go.sum lines come back in -//! go's sort order, so the vendor backend wires its `replace` over the -//! pristine files instead of taking over the hosted directive. -//! -//! FAIL CLOSED: a file that matches neither the recorded redirected fragment -//! nor the recorded original has drifted — the revert refuses (`Err`) rather -//! than half-applying, and the caller must then refuse to vendor that purl. -//! Refusing has to leave the project byte-identical across ALL the files the -//! ledger claims, not just the one that drifted: the caller reports the purl -//! as untouched ("cannot vendor over the live hosted redirect"), so an -//! already-rewritten Cargo.lock behind that message would be a half-hosted -//! project nobody is told about, and every retry refuses on the same drift. -//! So each inverse is resolved against a staged view and NOTHING reaches disk -//! until all of them have resolved. - -use std::collections::{BTreeMap, HashSet}; -use std::path::Path; -use std::sync::LazyLock; - -use regex::Regex; -use serde_json::Value; - -use crate::utils::purl::{ - parse_cargo_purl, parse_golang_purl, parse_name_version, strip_purl_qualifiers, -}; -use crate::vendor::go_mod_edit::{ - is_hosted_module_path, parse_replace_entries, HOSTED_GO_MODULE_PREFIX, -}; - -use super::replay::FragmentRevert; -use super::staged::{flush_staged, read_rel, staged_read, Staged, StagedBytes}; -use super::state::RedirectState; -use super::FileEdit; - -/// What a redirect revert rewrote. -#[derive(Debug, Default)] -pub struct RedirectRevert { - /// Repo-relative files this revert actually rewrote or removed. - pub reverted_files: Vec, - /// Advisory (code, detail) pairs — e.g. a redirect-created `.npmrc` - /// that was modified since (`redirect_npmrc_allow_remote_modified`). - pub warnings: Vec<(String, String)>, -} - -/// Does [`revert_redirect_purl`] have an implementation for this purl's -/// ecosystem? Callers (the vendor dispatch loop's cross-mode takeover gate) -/// must consult this instead of hardcoding `pkg:cargo/`. -pub fn redirect_revert_supported(purl: &str) -> bool { - purl.starts_with("pkg:cargo/") - || purl.starts_with("pkg:npm/") - || purl.starts_with("pkg:golang/") -} - -/// Revert every hosted-redirect edit the ledger records for `purl`, then -/// 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 -/// and skips ONLY the disk flush: the purl's record and edits are still -/// dropped from `state`, so a composed preview (the whole-ledger replay run -/// after the per-purl reverts inside one rollback) sees the post-claim -/// ledger. Callers pass a throwaway clone and never persist it on a dry run -/// (rollback.rs / vendor.rs do). Contrast `revert_remaining_redirect_edits`, -/// whose dry run leaves its `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, dry_run).await - } else if purl.starts_with("pkg:npm/") { - revert_npm_redirect_purl(project_root, state, purl, dry_run).await - } else if purl.starts_with("pkg:golang/") { - revert_golang_redirect_purl(project_root, state, purl, dry_run).await - } else { - Err(format!( - "no hosted-redirect revert implementation for {purl}" - )) - } -} - -/// The ledger record whose canonical purl (qualifiers stripped, -/// percent-decoded) matches `purl`: `(record key as stored, canonical purl)`. -/// Refused when the ledger records no hosted redirect for the purl. -fn find_record_key(state: &RedirectState, purl: &str) -> Result<(String, String), String> { - let Some(record_key) = state.record_keys_for(purl).into_iter().next() else { - return Err(format!( - "the redirect ledger records no hosted redirect for {purl}" - )); - }; - // The target keeps the purl's ORIGINAL (percent-encoded) spelling minus - // its qualifiers: `parse_cargo_purl` / `parse_name_version` decode the - // components themselves, and `canonical_purl` here would decode a second - // time (a literal `%2B` in a name would become `+`). - Ok((record_key, strip_purl_qualifiers(purl).to_string())) -} - -/// Refuse a per-purl claim while the ledger holds a `redirect_*` edit this -/// release cannot classify that names `@`: claiming the rest -/// and dropping the record would strand that edit (half a takeover). -fn refuse_unclassified_edits( - state: &RedirectState, - name: &str, - version: &str, -) -> Result<(), String> { - match state.unclassified_edit_naming(name, version) { - Some(e) => Err(format!( - "the redirect ledger holds a {} edit this socket-patch release does not \ - understand; upgrade socket-patch", - e.kind - )), - None => Ok(()), - } -} - -/// Drop the claimed edits (by ledger index) and the purl's record from the -/// ledger — only after every inverse applied cleanly. The caller persists. -fn drop_claimed(state: &mut RedirectState, claimed: Vec, record_key: &str) { - let drop: HashSet = claimed.into_iter().collect(); - let mut idx = 0usize; - state.edits.retain(|_| { - let keep = !drop.contains(&idx); - idx += 1; - keep - }); - state.records.remove(record_key); -} - -/// `socket-patch-` registry names as they appear in Cargo.toml pins, -/// Cargo.lock sources and `[registries.…]` headers. -static SOCKET_REGISTRY_UUID: LazyLock = LazyLock::new(|| { - Regex::new(r"socket-patch-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})") - .expect("static registry-uuid regex is valid") -}); - -/// The `socket-patch-` registry uuids a recorded fragment names. -fn registry_uuids(fragment: Option<&Value>) -> impl Iterator + '_ { - fragment - .and_then(Value::as_str) - .into_iter() - .flat_map(|s| SOCKET_REGISTRY_UUID.captures_iter(s)) - .map(|c| c[1].to_string()) -} - -/// A Cargo.lock edit (keyed `@`): the entry / `[metadata]` -/// fragments, or the dependents' full-id reference. -fn is_cargo_lock_edit(e: &FileEdit) -> bool { - e.kind == "redirect_cargo_lock_entry" || e.kind == super::CARGO_LOCK_REFERENCE_KIND -} - -/// Every patch uuid `name@version` was redirected at: its record's, plus -/// each managed registry whose index URL one of its (version-keyed) -/// Cargo.lock edits names — the older links of a re-redirect chain. -fn cargo_lineage(state: &RedirectState, name: &str, version: &str, uuid: &str) -> HashSet { - let lock_key = format!("{name}@{version}"); - let lock_fragments: Vec<&str> = state - .edits - .iter() - .filter(|e| is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) - .flat_map(|e| [e.original.as_ref(), e.new.as_ref()]) - .flatten() - .filter_map(Value::as_str) - .collect(); - let mut lineage: HashSet = HashSet::from([uuid.to_string()]); - for e in state - .edits - .iter() - .filter(|e| e.kind == "redirect_cargo_registry") - { - let Some(u) = e - .key - .as_deref() - .and_then(|k| k.strip_prefix("socket-patch-")) - else { - continue; - }; - let index = e - .new - .as_ref() - .and_then(Value::as_str) - .and_then(|block| block.split('"').nth(1)); - if index.is_some_and(|index| lock_fragments.iter().any(|f| f.contains(index))) { - lineage.insert(u.to_string()); - } - } - lineage -} - -/// Revert every hosted-redirect edit the ledger records for `purl` (a cargo -/// package), then drop that purl's record and edits from `state`. The caller -/// persists the mutated ledger (see `persist_redirect_state`). -/// -/// Chained re-redirects (the same purl redirected at successive patch uuids) -/// unwind newest-first: each edit's `new` fragment is replaced by its -/// `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` skips only the disk flush; the in-memory ledger claim still -/// happens (see [`revert_redirect_purl`]). -pub async fn revert_cargo_redirect_purl( - project_root: &Path, - state: &mut RedirectState, - purl: &str, - dry_run: bool, -) -> Result { - let (record_key, target) = find_record_key(state, purl)?; - let Some((name, version)) = parse_cargo_purl(&target) else { - return Err(format!("not a cargo purl: {purl}")); - }; - let (name, version) = (name.into_owned(), version.into_owned()); - refuse_unclassified_edits(state, &name, &version)?; - let lock_key = format!("{name}@{version}"); - - // Manifest edits are keyed by crate NAME (the shared golden ledger - // shape), so when another version of the crate is redirected too, its - // pins carry the same key. Attribute each such edit to the version its - // declaration's requirement selects — the planner's own rule, and the - // only one that also holds for ledgers an older, name-only CLI wrote - // (a declaration of one version pinned to, then re-pinned from, the - // other version's registry). An edit whose line carries no readable - // requirement (a table-form header / registry line) falls back to the - // registry it pins: skipped when that is a sibling lineage's. Without a - // sibling the claim stays name-wide, as before. - let siblings: Vec<(String, HashSet)> = state - .records - .iter() - .filter(|(key, _)| **key != record_key) - .filter_map(|(key, rec)| { - let (n, v) = parse_cargo_purl(strip_purl_qualifiers(key))?; - (n == name && v != version) - .then(|| (v.to_string(), cargo_lineage(state, &n, &v, &rec.uuid))) - }) - .collect(); - let sibling_versions: Vec = siblings.iter().map(|(v, _)| v.clone()).collect(); - let sibling_uuids: HashSet = siblings.into_iter().flat_map(|(_, u)| u).collect(); - let is_my_manifest_edit = |e: &FileEdit| { - if sibling_versions.is_empty() { - return true; - } - let req = cargo_declared_req(e.original.as_ref()); - match super::cargo_req_selects(req.as_deref(), &version, &sibling_versions) { - super::CargoReqMatch::Ours if req.is_some() => true, - super::CargoReqMatch::NotOurs => false, - _ => !registry_uuids(e.new.as_ref()).any(|u| sibling_uuids.contains(&u)), - } - }; - let is_wiring_edit = |e: &FileEdit| { - (e.kind == "redirect_cargo_toml_dep" - && e.key.as_deref() == Some(name.as_str()) - && is_my_manifest_edit(e)) - || (is_cargo_lock_edit(e) && e.key.as_deref() == Some(lock_key.as_str())) - }; - // Registry blocks tie to this purl via the `socket-patch-` names in - // its record + wiring edits (a patch uuid is per purl, so this cannot - // claim another package's block) — never a sibling version's, which a - // re-pinned legacy declaration also names. - let mut uuids: HashSet = HashSet::new(); - uuids.insert(state.records[&record_key].uuid.clone()); - for e in state.edits.iter().filter(|e| is_wiring_edit(e)) { - for v in [&e.original, &e.new] { - if let Some(s) = v.as_ref().and_then(Value::as_str) { - for c in SOCKET_REGISTRY_UUID.captures_iter(s) { - if !sibling_uuids.contains(&c[1]) { - uuids.insert(c[1].to_string()); - } - } - } - } - } - let is_registry_edit = |e: &FileEdit| { - e.kind == "redirect_cargo_registry" - && e.key - .as_deref() - .and_then(|k| k.strip_prefix("socket-patch-")) - .is_some_and(|u| uuids.contains(u)) - }; - - let mine: Vec = state - .edits - .iter() - .enumerate() - .filter(|(_, e)| is_wiring_edit(e) || is_registry_edit(e)) - .map(|(i, _)| i) - .collect(); - - // Every manifest the ledger ever pinned (workspace members included), - // plus the lock: where a registry block can still be referenced from. - let mut probes: Vec = vec!["Cargo.toml".to_string(), "Cargo.lock".to_string()]; - for e in state - .edits - .iter() - .filter(|e| e.kind == "redirect_cargo_toml_dep") - { - if !probes.contains(&e.path) { - probes.push(e.path.clone()); - } - } - - let mut out = RedirectRevert::default(); - let mut staged: Staged = Staged::new(); - // A block kept because it is still referenced normally leaves the - // ledger (it now belongs to whatever hand pin references it). The - // exception is a block another, still-recorded wiring edit pins to (an - // older CLI's cross-version pin): that edit stays in the ledger so the - // removal that retires the last such pin also removes the block. - let still_pinned: HashSet = state - .edits - .iter() - .enumerate() - .filter(|(i, e)| e.kind == "redirect_cargo_toml_dep" && !mine.contains(i)) - .flat_map(|(_, e)| registry_uuids(e.new.as_ref()).collect::>()) - .collect(); - let mut kept: HashSet = HashSet::new(); - // Newest-first: the hosted flow appends edits, so reverse index order - // unwinds re-redirect chains correctly (each step's `original` is the - // previous step's `new`), and the registry-block removals — recorded - // before their wiring edits — run last, after the references are gone. - for &i in mine.iter().rev() { - let edit = &state.edits[i]; - match edit.kind.as_str() { - "redirect_cargo_toml_dep" - | "redirect_cargo_lock_entry" - | super::CARGO_LOCK_REFERENCE_KIND => { - let (Some(new), Some(orig)) = ( - edit.new.as_ref().and_then(Value::as_str), - edit.original.as_ref().and_then(Value::as_str), - ) else { - return Err(format!( - "the redirect ledger edit for {} in {} records no original \ - fragment; cannot revert the hosted redirect", - name, edit.path - )); - }; - let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { - return Err(format!( - "{} no longer exists; cannot revert the recorded hosted \ - redirect for {name}@{version}", - edit.path - )); - }; - // A full-id reference edit stands for every dependent's - // occurrence of that exact id. Matching ignores a CRLF/LF - // conversion since the scan (a Windows checkout's CRLF - // fragments against an LF checkout, and vice versa). - let every = edit.kind == super::CARGO_LOCK_REFERENCE_KIND; - match super::replay::revert_fragment_eol(&content, new, orig, every) { - FragmentRevert::Reverted(reverted) => { - staged.insert(edit.path.clone(), Some(reverted)); - out.reverted_files.push(edit.path.clone()); - } - // Already at (or unwound to) the pre-redirect fragment. - FragmentRevert::AlreadyOriginal => {} - FragmentRevert::Drifted => { - return Err(format!( - "the {} entry for {name}@{version} has drifted from the \ - recorded hosted redirect (neither the redirected nor the \ - original fragment is present); refusing to touch it — \ - re-run `scan --mode hosted` to normalize the redirect, \ - or restore the crates.io wiring manually, then re-run", - edit.path - )); - } - } - } - "redirect_cargo_registry" => { - let Some(block) = edit.new.as_ref().and_then(Value::as_str) else { - continue; // nothing recorded to remove — leave the config - }; - let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { - continue; // config already gone - }; - if !super::replay::contains_eol(&content, block) { - continue; // block already removed - } - // Keep the block while anything still references its registry - // name or index URL (defensive — a hand-edited project may - // have pinned another dep to it). - let reg = edit.key.as_deref().unwrap_or_default(); - let index = block - .split('"') - .nth(1) - .map(str::to_string) - .unwrap_or_default(); - let mut referenced = false; - for probe in &probes { - if let Some(text) = staged_read(&staged, project_root, probe).await? { - if (!reg.is_empty() && text.contains(reg)) - || (!index.is_empty() && text.contains(&index)) - { - referenced = true; - break; - } - } - } - if referenced { - if reg - .strip_prefix("socket-patch-") - .is_some_and(|u| still_pinned.contains(u)) - { - kept.insert(i); - } - continue; - } - // A REGENERATED block (`action: "rewritten"` — the rewriter - // replaced a degraded/commented region in place and recorded - // it as `original`) restores that pre-existing region instead - // of deleting it: the original bytes are the user's. - if let Some(orig) = edit.original.as_ref().and_then(Value::as_str) { - if let FragmentRevert::Reverted(reverted) = - super::replay::revert_fragment_eol(&content, block, orig, false) - { - staged.insert(edit.path.clone(), Some(reverted)); - out.reverted_files.push(edit.path.clone()); - } - continue; - } - // The block leaves with exactly the blank separator the - // rewrite put before it, so the user's config comes back - // byte-for-byte — its trailing newlines (or missing final - // newline) included. A config the rewrite created ends - // empty and is deleted. - let Some(restored) = super::replay::remove_appended_cargo_block(&content, block) - else { - continue; - }; - staged.insert( - edit.path.clone(), - (!restored.is_empty()).then_some(restored), - ); - out.reverted_files.push(edit.path.clone()); - } - _ => {} - } - } - - // Every inverse resolved — only now does any of it reach disk, so a - // 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, &StagedBytes::new()).await?; - } - - let mine: Vec = mine.into_iter().filter(|i| !kept.contains(i)).collect(); - drop_claimed(state, mine, &record_key); - Ok(out) -} - -/// Revert one Go module's hosted redirect: its go.mod `replace`, the -/// socket module's go.sum lines, and the pruned upstream go.sum pair, then -/// drop its record and edits from `state`. The claimed edits unwind through -/// the whole-ledger replay's golang inverses, staged all-or-nothing. A -/// go.mod whose directive for the module is no longer the recorded one has -/// drifted and refuses byte-untouched. -pub async fn revert_golang_redirect_purl( - project_root: &Path, - state: &mut RedirectState, - purl: &str, - dry_run: bool, -) -> Result { - let (record_key, target) = find_record_key(state, purl)?; - let Some((module, version)) = parse_golang_purl(&target) else { - return Err(format!("not a golang purl: {purl}")); - }; - let (module, version) = (module.into_owned(), version.into_owned()); - refuse_unclassified_edits(state, &module, &version)?; - let lhs = format!("{module} {version} =>"); - let is_replace_edit = |e: &FileEdit| { - matches!( - e.kind.as_str(), - "redirect_golang_replace" | "redirect_golang_stale_replace_removed" - ) && e.key.as_deref() == Some(module.as_str()) - && [&e.new, &e.original].iter().any(|v| { - v.as_ref() - .and_then(Value::as_str) - .is_some_and(|t| t.contains(&lhs)) - }) - }; - // The socket modules this purl's directives pointed at: go.sum edits - // key by those, never by the upstream module. - let mut socket_modules: HashSet = HashSet::new(); - socket_modules.insert(format!( - "{HOSTED_GO_MODULE_PREFIX}{}", - state.records[&record_key].uuid - )); - for e in state.edits.iter().filter(|e| is_replace_edit(e)) { - for text in [&e.new, &e.original] - .into_iter() - .flatten() - .filter_map(Value::as_str) - { - for entry in parse_replace_entries(text) { - if let Some(rhs) = entry.rhs_module.filter(|m| is_hosted_module_path(m)) { - socket_modules.insert(rhs); - } - } - } - } - let prune_key = format!("{module}@{version}"); - let mine: Vec = state - .edits - .iter() - .enumerate() - .filter(|(_, e)| match e.kind.as_str() { - "redirect_golang_replace" | "redirect_golang_stale_replace_removed" => { - is_replace_edit(e) - } - "redirect_golang_gosum_prune" => e.key.as_deref() == Some(prune_key.as_str()), - "redirect_golang_gosum" => e - .key - .as_deref() - .and_then(|k| k.rsplit_once('@')) - .is_some_and(|(m, _)| socket_modules.contains(m)), - "redirect_golang_stale_gosum_removed" => { - e.key.as_deref().is_some_and(|k| socket_modules.contains(k)) - } - _ => false, - }) - .map(|(i, _)| i) - .collect(); - - // Drift: the newest recorded directive must still be live, or the - // module must carry no replace at all (already unwound). - let newest = mine - .iter() - .rev() - .map(|&i| &state.edits[i]) - .find(|e| e.kind == "redirect_golang_replace"); - if let Some(directive) = newest.and_then(|e| e.new.as_ref()).and_then(Value::as_str) { - let go_mod = read_rel(project_root, "go.mod").await?.unwrap_or_default(); - if !go_mod.contains(directive) - && parse_replace_entries(&go_mod) - .iter() - .any(|e| e.module == module) - { - return Err(format!( - "go.mod's replace for {module} has drifted from the recorded hosted \ - redirect; refusing to touch it — re-run `scan --mode hosted` to \ - normalize the redirect, or remove the replace manually, then re-run" - )); - } - } - - let mut claimed = RedirectState::new(); - claimed.edits = mine.iter().map(|&i| state.edits[i].clone()).collect(); - claimed - .records - .insert(record_key.clone(), state.records[&record_key].clone()); - let replay = - super::replay::revert_remaining_redirect_edits(project_root, &mut claimed, dry_run).await; - if let Some(refusal) = replay.refusals.first() { - return Err(refusal.reason.clone()); - } - drop_claimed(state, mine, &record_key); - Ok(RedirectRevert { - reverted_files: replay.reverted_files.into_iter().collect(), - warnings: replay.warnings, - }) -} - -/// The version requirement a recorded Cargo.toml declaration line carries: -/// the `version = "…"` of an inline table, or the value of a plain -/// `name = "…"` entry. `None` for a table-form header or `registry` line. -fn cargo_declared_req(fragment: Option<&Value>) -> Option { - static PLAIN_ENTRY: LazyLock = LazyLock::new(|| { - Regex::new(r#"^\s*(?:"[^"]+"|'[^']+'|[A-Za-z0-9_-]+)\s*=\s*"([^"]+)""#) - .expect("static plain-entry regex is valid") - }); - static INLINE_VERSION: LazyLock = LazyLock::new(|| { - Regex::new(r#"\bversion\s*=\s*"([^"]*)""#).expect("static inline-version regex is valid") - }); - let line = fragment.and_then(Value::as_str)?; - if line.contains('\n') || line.trim_start().starts_with('[') { - return None; - } - let req = if line.contains('{') { - INLINE_VERSION.captures(line)?[1].to_string() - } else { - PLAIN_ENTRY.captures(line)?[1].to_string() - }; - semver::VersionReq::parse(req.trim()).is_ok().then_some(req) -} - -/// The npm-family text-fragment edit kinds CLAIMED BY KEY: `original`/`new` -/// hold the whole lock fragment as a string, the edit's `key` embeds -/// `@`, and the revert is a `replacen(new, original)`. -const NPM_TEXT_KINDS: [&str; 4] = [ - "redirect_yarn_classic_entry", - "redirect_yarn_berry_entry", - "redirect_pnpm_resolution", - super::vlt::KIND, -]; - -/// The bun hosted rewriter's edit kind (`rewrite_bun_lock`): `original`/`new` -/// hold the whole `packages` entry LINE, so it replays exactly like the -/// [`NPM_TEXT_KINDS`]. It is CLAIMED differently: bun edits key by the -/// lock's package map key — `minimist`, a nested `other/minimist`, or the -/// alias of an `alias@npm:minimist@1.2.2` install — never by -/// `name@version`, so ownership is read from the recorded line's spec -/// (`elems[0]`), the field the rewriter itself matched on. -const BUN_TEXT_KIND: &str = "redirect_bun_lock_package"; - -/// Does this edit kind replay as a text fragment, fail-closed on drift? -/// Most replace the whole fragment (`content.replacen(new, original, 1)`); -/// vlt node edits revert slot by slot ([`super::vlt::revert_vlt_slots`]). -fn replays_as_text_fragment(kind: &str) -> bool { - NPM_TEXT_KINDS.contains(&kind) || kind == BUN_TEXT_KIND -} - -/// Ownership verdict for one [`BUN_TEXT_KIND`] edit. -#[derive(Debug, PartialEq)] -enum BunClaim { - /// The edit rewrote an instance of exactly this `name@version`. - Ours, - /// Another package, or another version of this one (a nested - /// `other/minimist` instance at 1.2.8 while reverting 1.2.2): not ours - /// to touch, and no reason to refuse. - Foreign, - /// The recorded fragments mention this package but neither one parses - /// under bun's entry grammar (hand-edited or truncated ledger), so - /// ownership cannot be decided. Deciding "foreign" would drop this - /// purl's record while stranding an edit that may be its own — half a - /// takeover — so the caller refuses. - Undecidable, -} - -/// Attribute a bun.lock edit to `name@version` the way the hosted rewriter -/// matched it: by the spec of the recorded line. -/// -/// A registry 4-tuple's spec is exactly `@`; a hosted URL -/// 3-tuple's spec is `@`. Either fragment may be the -/// hosted URL (a re-redirect chain records `original` = the PRIOR hosted -/// line, `new` = the current one), so both are consulted and one match -/// claims. The URL half is discriminated by version through its tarball -/// leaf — see [`hosted_url_names`] — never by the name substring alone, -/// which would claim a sibling version's edit and silently un-host it. -fn bun_edit_ownership(edit: &FileEdit, name: &str, version: &str) -> BunClaim { - use crate::vendor::bun_lock_text::{decode_json_string, parse_entry_line}; - fn fragment(v: &Option) -> Option<&str> { - v.as_ref().and_then(Value::as_str) - } - let spec_of = |line: &str| -> Option { - let entry = parse_entry_line(line).ok()?; - decode_json_string(entry.elems.first()?) - }; - let mut parsed_any = false; - for line in [fragment(&edit.original), fragment(&edit.new)] - .into_iter() - .flatten() - { - if let Some(spec) = spec_of(line) { - parsed_any = true; - if bun_spec_names(&spec, name, version) { - return BunClaim::Ours; - } - } - } - if parsed_any { - return BunClaim::Foreign; - } - // Neither fragment is a bun entry line. Only refuse when the raw text - // so much as mentions this package; an edit naming nothing of ours is - // someone else's problem and must not block this purl's takeover. - let probe = format!("\"{name}@"); - let mentions = |v: &Option| fragment(v).is_some_and(|s| s.contains(&probe)); - if mentions(&edit.original) || mentions(&edit.new) { - BunClaim::Undecidable - } else { - BunClaim::Foreign - } -} - -/// Is `spec` (a bun.lock entry's decoded `elems[0]`) the registry spec -/// `@` or a hosted artifact URL spec for that exact -/// `name@version`? -fn bun_spec_names(spec: &str, name: &str, version: &str) -> bool { - use crate::vendor::bun_lock_text::split_name_spec; - let Some((spec_name, rest)) = split_name_spec(spec) else { - return false; - }; - spec_name == name && (rest == version || hosted_url_names(rest, name, version)) -} - -/// True when `url` is an http(s) artifact URL whose last path segment is -/// `-.tgz` — the leaf every hosted artifact URL for this -/// `name@version` ends in. `` is the name without its `@scope/`: the -/// vendor path layer (`tgz_rel_leaf`) keeps a scope as a directory level -/// (`@scope/pkg-1.0.0.tgz`), and the hosted rewriter's prior-URL match -/// (`is_prior_hosted_bun_spec`) compares the same last path segment, so -/// `pkg-1.0.0.tgz` is the one spelling both agree on. Anything that fails -/// to parse fails the match (closed). The exact-leaf comparison is the -/// version discriminator: `pkg-1.3.0.tgz` never equals `pkg-11.3.0.tgz` -/// or `pkg-1.3.0-rc1.tgz`. -pub(crate) fn hosted_url_names(url: &str, name: &str, version: &str) -> bool { - if !url.starts_with("https://") && !url.starts_with("http://") { - return false; - } - let scheme_end = url - .find("://") - .expect("url starts with http(s):// — checked above") - + 3; - let Some(path_start) = url[scheme_end..].find('/').map(|i| i + scheme_end) else { - return false; - }; - let leaf = url[path_start..].rsplit('/').next().unwrap_or_default(); - let bare = name.rsplit('/').next().unwrap_or(name); - !leaf.is_empty() && leaf == format!("{bare}-{version}.tgz") -} - -/// The version a hosted artifact `url` names for `name`: its last path -/// segment is `-.tgz` with a semver ``, confirmed by -/// [`hosted_url_names`]. How a hosted bun binary redirect's version is -/// recovered (`bun_binary::names`) and how lockfile discovery reads a bun -/// hosted ref's version. -pub(crate) fn hosted_url_version<'u>(url: &'u str, name: &str) -> Option<&'u str> { - let bare = name.rsplit('/').next().unwrap_or(name); - let version = url - .rsplit('/') - .next()? - .strip_prefix(bare)? - .strip_prefix('-')? - .strip_suffix(".tgz")?; - (semver::Version::parse(version).is_ok() && hosted_url_names(url, name, version)) - .then_some(version) -} - -/// Revert every hosted-redirect edit the ledger records for `purl` (an npm -/// package), then drop that purl's record and edits from `state`. The caller -/// persists the mutated ledger (see `persist_redirect_state`). -/// -/// 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. `dry_run` skips only the disk -/// flush; the in-memory ledger claim still happens (see -/// [`revert_redirect_purl`]). -pub async fn revert_npm_redirect_purl( - project_root: &Path, - state: &mut RedirectState, - purl: &str, - dry_run: bool, -) -> Result { - let (record_key, target) = find_record_key(state, purl)?; - // The parse percent-decodes both components; the name keeps its `@scope/`. - let Some((name, version)) = parse_name_version(&target, "pkg:npm/") else { - return Err(format!("not an npm purl: {purl}")); - }; - let (name, version) = (name.into_owned(), version.into_owned()); - refuse_unclassified_edits(state, &name, &version)?; - let lock_key = format!("{name}@{version}"); - - // The package-lock/shrinkwrap files any `redirect_npm_lock_entry` edits - // touch, read ONCE from disk: the raw text is kept (`disk_texts`) so the - // replay below never re-reads a lock this attribution pass already - // loaded, and the parse is used for ownership — an ALIAS install (`npm i - // alias@npm:name`) keys its entry by the alias, so ownership is resolved - // through the entry's `name` field, exactly how the rewriter matched it - // (the rewrite never touches name/version, so the probe is symmetric). - let mut disk_texts: BTreeMap> = BTreeMap::new(); - let mut disk_locks: BTreeMap> = BTreeMap::new(); - for e in &state.edits { - if e.kind == "redirect_npm_lock_entry" && !disk_texts.contains_key(&e.path) { - let text = read_rel(project_root, &e.path).await?; - let parsed = text - .as_deref() - .and_then(|c| serde_json::from_str::(c).ok()); - disk_texts.insert(e.path.clone(), text); - disk_locks.insert(e.path.clone(), parsed); - } - } - - // Claim this purl's edits. The berry/classic rewriters key edits by - // `@`; the pnpm rewriter keys by the canonical INSTANCE - // key — `@` for a plain instance, but one edit per - // resolved-peer instance keyed `@(@)…` (v6) or - // `@_` (v5) — so pnpm claims accept a `(`/`_` - // peer boundary after the exact version (never `-`/`.`/alnum, which - // would extend the version into a sibling's, e.g. 1.3.0 vs 1.3.0-rc1). - // The legacy npm v2 `dependencies` tree keys by bare name; the v3 - // `packages` map keys by the lock path. The package-lock JSON kinds carry no version in their - // key, so ownership is version-discriminated the way the rewriter - // matched (entry `name`+`version`, mod.rs) — name-only would claim a - // SIBLING purl's edits (left-pad@1.2.0 vs @1.3.0 both hosted-redirected, - // or `npm i name@npm:other` aliasing another package onto this key path) - // and replaying those silently un-hosts the other purl while dropping - // its edits. bun edits key by the lock's package MAP key (`minimist`, - // nested `other/minimist`, an install alias) — never `name@version` — - // so they are attributed by the spec of the recorded line, the field - // the rewriter matched on (`bun_edit_ownership`); a sibling version's - // line is foreign, and a fragment that mentions the package but cannot - // be parsed at all refuses rather than guess (dropping the record while - // stranding a possibly-own edit would be half a takeover). - let mut mine: Vec = Vec::new(); - for (i, e) in state.edits.iter().enumerate() { - let key = e.key.as_deref().unwrap_or_default(); - let claimed = match e.kind.as_str() { - super::vlt::KIND => super::vlt::claims_key(key, &name, &version), - k if NPM_TEXT_KINDS.contains(&k) => { - key == lock_key - || (k == "redirect_pnpm_resolution" - && key - .strip_prefix(lock_key.as_str()) - .is_some_and(|peer| peer.starts_with('(') || peer.starts_with('_'))) - } - "redirect_npm_lock_dep" => key == name && edit_references_version(e, &version), - "redirect_npm_lock_entry" => { - let key_name = key - .rsplit_once("node_modules/") - .map(|(_, n)| n) - .unwrap_or(key); - match disk_locks - .get(&e.path) - .and_then(|l| l.as_ref()) - .and_then(|l| l.get("packages")) - .and_then(|p| p.get(key)) - { - // The entry is live: attribute it exactly the way the - // rewriter matched it — effective name (the `name` field - // npm writes for alias installs, else the key's trailing - // path; the rewrite never touches either, so the probe - // is symmetric) AND version. - Some(entry) => { - let entry_name = entry - .get("name") - .and_then(Value::as_str) - .unwrap_or(key_name); - entry_name == name - && match entry.get("version").and_then(Value::as_str) { - Some(v) => v == version, - // Version field gone (hand-edited lock): fall - // back to the recorded URLs, which must name - // this exact package AND version — version - // alone would claim a same-version alias - // collision (`npm i @npm:other`, name - // field stripped too) whose live values ARE - // its edit's `new` values, so the replay - // would NOT fail closed and the sibling would - // be silently un-hosted. - None => edit_references_package(e, &name, &version), - } - } - // Entry (or the whole lock) gone: keep the fail-closed - // "no longer exists" refusal for edits attributable to - // this purl — by key path + recorded URLs, or (an alias - // install OF this package keys its entry by the ALIAS, - // so the key path exonerates nothing) by recorded URLs - // naming this exact package. Leaving the alias edit - // unclaimed would drop the record while stranding it — - // half a takeover. A sibling purl's edit is still not - // ours to claim. - None => { - (key_name == name && edit_references_version(e, &version)) - || edit_references_package(e, &name, &version) - } - } - } - super::bun_binary::KIND => super::bun_binary::names(e, &name, &version)?, - BUN_TEXT_KIND => match bun_edit_ownership(e, &name, &version) { - BunClaim::Ours => true, - BunClaim::Foreign => false, - // The WORKING remedy is the whole-ledger replay: a plain - // `bun install` keeps a hosted URL tuple byte-identically - // (it re-locks nothing), and hand-editing the ledger is - // exactly what the hosted flow tells users never to do. - BunClaim::Undecidable => { - return Err(format!( - "the redirect ledger records a {} hosted redirect edit \ - that mentions {name} but is not a bun packages entry \ - line, so it cannot be attributed to {lock_key}; run an \ - unscoped `socket-patch rollback` (the whole-ledger \ - replay unwinds bun.lock hosted edits), then re-run; do \ - not edit .socket/vendor/redirect-state.json by hand", - e.path - )); - } - }, - _ => false, - }; - if claimed { - mine.push(i); - } - } - - let mut out = RedirectRevert::default(); - let mut staged: Staged = Staged::new(); - let mut staged_bytes: StagedBytes = StagedBytes::new(); - let mut vanished_vlt: Vec = Vec::new(); - // Newest-first: the hosted flow appends edits, so reverse index order - // unwinds re-redirect chains correctly (each step's `original` is the - // previous step's `new`). - for &i in mine.iter().rev() { - let edit = &state.edits[i]; - if edit.kind == super::bun_binary::KIND { - if edit.path != "bun.lockb" { - return Err("unexpected binary lock edit path".into()); - } - let path = project_root.join("bun.lockb"); - let content = match staged_bytes.remove(&edit.path) { - Some(pending) => pending, - None => { - let metadata = tokio::fs::symlink_metadata(&path) - .await - .map_err(|e| format!("cannot inspect bun.lockb: {e}"))?; - if !metadata.is_file() { - return Err("bun.lockb is not a regular file".into()); - } - crate::utils::fs::read_regular_to_bytes(&path) - .await - .map_err(|e| format!("cannot read bun.lockb: {e}"))? - } - }; - staged_bytes.insert( - edit.path.clone(), - super::bun_binary::restore(&content, edit)?, - ); - if !out.reverted_files.iter().any(|p| p == "bun.lockb") { - out.reverted_files.push("bun.lockb".into()); - } - } else if replays_as_text_fragment(&edit.kind) { - // Whole-fragment replay. For bun the fragments are whole lines - // (a CRLF lock's carry their trailing `\r`), so a - // `contains`/`replacen` on the raw content restores the line - // byte-exactly whatever the line ending. - let (Some(new), Some(orig)) = ( - edit.new.as_ref().and_then(Value::as_str), - edit.original.as_ref().and_then(Value::as_str), - ) else { - return Err(format!( - "the redirect ledger edit for {name} in {} records no \ - original fragment; cannot revert the hosted redirect", - edit.path - )); - }; - let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { - return Err(format!( - "{} no longer exists; cannot revert the recorded hosted \ - redirect for {lock_key}", - edit.path - )); - }; - if edit.kind == super::vlt::KIND { - match super::vlt::revert_vlt_slots(&content, edit)? { - super::vlt::SlotRevert::Restored(restored) => { - staged.insert(edit.path.clone(), Some(restored)); - out.reverted_files.push(edit.path.clone()); - } - super::vlt::SlotRevert::Unchanged => {} - super::vlt::SlotRevert::Vanished => vanished_vlt.push(i), - } - continue; - } - // A yarn block recorded on a CRLF checkout, replayed on an LF - // one (or the reverse — `core.autocrlf` re-spells the lock on - // every OS switch, never the committed ledger): when neither - // fragment matches verbatim, try both in the file's ending. - let respelled = (super::yarn_lock_fragment_kind(&edit.kind) - && !content.contains(new) - && !content.contains(orig)) - .then(|| crate::utils::line_endings::fragments_in_eol_of(&content, orig, new)) - .flatten(); - let (orig, new) = match &respelled { - Some((orig, new)) => (orig.as_str(), new.as_str()), - None => (orig, new), - }; - if content.contains(new) { - staged.insert(edit.path.clone(), Some(content.replacen(new, orig, 1))); - out.reverted_files.push(edit.path.clone()); - } else if content.contains(orig) { - // Already at (or unwound to) the pre-redirect fragment. - } else { - // bun only: Bun 1.1.39–1.3.9 re-save our URL 3-tuple WITHOUT - // its sha512 on any later lock re-save, so the recorded - // `new` is on disk as a digest-less 2-tuple (same key, spec - // and meta). That spelling IS the recorded wiring — restore - // `orig` over it; a stale ledger of its own making must not - // block the takeover, scoped rollback or remove. Anything - // else still refuses (fail closed). - let healed = if edit.kind == BUN_TEXT_KIND { - crate::vendor::bun_lock_text::restore_digestless_line(&content, new, orig) - .map_err(|ambiguous| format!("{}: {ambiguous}", edit.path))? - } else { - None - }; - match healed { - Some(restored) => { - staged.insert(edit.path.clone(), Some(restored)); - out.reverted_files.push(edit.path.clone()); - } - None => { - return Err(format!( - "the {} entry for {lock_key} has drifted from the recorded \ - hosted redirect (neither the redirected nor the original \ - fragment is present); refusing to touch it — re-run \ - `scan --mode hosted` to normalize the redirect, or \ - restore the registry wiring manually, then re-run", - edit.path - )); - } - } - } - } else { - revert_npm_json_edit( - project_root, - &mut staged, - &disk_texts, - edit, - &name, - &version, - &mut out, - ) - .await?; - } - } - - for &i in &vanished_vlt { - let edit = &state.edits[i]; - let Some(content) = staged_read(&staged, project_root, &edit.path).await? else { - return Err(format!( - "{} no longer exists; cannot revert the recorded hosted \ - redirect for {lock_key}", - edit.path - )); - }; - if let Some(restored) = super::vlt::revert_vanished(&content, edit)? { - staged.insert(edit.path.clone(), Some(restored)); - if !out.reverted_files.contains(&edit.path) { - out.reverted_files.push(edit.path.clone()); - } - } - } - - // LAST ONE OUT: the `.npmrc` `allow-remote=all` auto-config exists only - // for package-lock / shrinkwrap hosted entries (npm >= 12 refuses them - // without it). When this purl's revert leaves no such entry in the - // ledger, unwind the recorded `.npmrc` edit(s) in the SAME transaction — - // scoped rollback / remove of the last npm purl and the vendored - // takeover then leave no loosened install policy behind. An ambiguous - // `.npmrc` refuses the whole revert (nothing written), like any drift. - let mut npmrc_staged: Option> = None; - { - let dropping: HashSet = mine.iter().copied().collect(); - // Checked BEFORE the read: the read refuses a symlinked / non-regular - // `.npmrc` here, at plan time — so the whole revert refuses with - // nothing written (flush_npmrc refusing it after flush_staged had - // already written the lock would strand a reverted lock behind a - // ledger that still records the redirect) — but only when the - // unwind is actually due. - if super::npmrc::npmrc_unwind_due(&state.edits, &dropping) { - let current = super::npmrc::read_project_npmrc(project_root)?; - if let Some(plan) = - super::npmrc::plan_unneeded_npmrc_unwind(&state.edits, &dropping, current)? - { - if plan.staged.is_some() { - out.reverted_files.push(super::npmrc::NPMRC_REL.to_string()); - } - npmrc_staged = plan.staged; - out.warnings.extend(plan.warnings); - mine.extend(plan.indices); - } - } - } - - // Every inverse resolved — only now does any of it reach disk, so a - // 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, &staged_bytes).await?; - // After the lock: an I/O fault here leaves the (reverted) lock plus - // a still-present `allow-remote=all` — never a hosted lock entry - // whose `.npmrc` setting was already taken away. - if let Some(npmrc) = &npmrc_staged { - super::npmrc::flush_npmrc(project_root, npmrc).await?; - } - } - - drop_claimed(state, mine, &record_key); - Ok(out) -} - -/// Does one of this edit's recorded `resolved` URLs reference `version`? -/// -/// Version discriminator for the package-lock JSON edit kinds, whose keys -/// carry no version (`redirect_npm_lock_dep` keys by bare name, -/// `redirect_npm_lock_entry` by lock path): both the hosted artifact URL -/// (`…/npm///…/-.tgz`) and the registry -/// tarball URL (`…/-/-.tgz`) embed the version behind a -/// `//` or `-.tgz` delimiter, so sibling versions of the -/// same package never -/// match each other (`/1.3.0/` is not a substring of `/11.3.0/`, nor -/// `-1.3.0.tgz` of `-11.3.0.tgz`). Checked against `new` and `original` so -/// every link of a re-redirect chain (each hosted URL names this purl's -/// version) attributes correctly. A false positive here is safe — the -/// replay itself fails closed on any value mismatch — while name-only -/// claiming silently un-hosts the sibling purl. -fn edit_references_version(edit: &FileEdit, version: &str) -> bool { - let path_seg = format!("/{version}/"); - let tarball = format!("-{version}.tgz"); - [&edit.new, &edit.original].into_iter().any(|v| { - v.as_ref() - .and_then(|o| o.get("resolved")) - .and_then(Value::as_str) - .is_some_and(|s| s.contains(&path_seg) || s.contains(&tarball)) - }) -} - -/// Does one of this edit's recorded `resolved` URLs reference BOTH `name` -/// and `version`? -/// -/// Name-discriminated twin of [`edit_references_version`], for the claims -/// where the lock key path cannot vouch for the name (an alias install keys -/// its entry by the alias, and `npm i @npm:other` keys ANOTHER package -/// by this name's path). The probes are the two URL shapes whole, not -/// independent name/version substrings: the hosted artifact URL embeds the -/// name and version as adjacent path segments -/// (`…/npm///…/-.tgz`) and the registry -/// tarball URL as `…//-/-.tgz` — where a scoped name's -/// `@scope/` prefix is dropped from the BASENAME only, never from the path. -/// A match for an UNSCOPED name whose preceding path segment is a scope -/// (`…/@scope//…` — the slash closing `@scope` starts the probe) is -/// rejected: it is a scoped sibling's URL, whose path and basename would -/// otherwise satisfy independent substring probes at an identical version. -/// So a sibling purl of a different name — bare or scoped — never matches -/// even at an identical version. -fn edit_references_package(edit: &FileEdit, name: &str, version: &str) -> bool { - let bare = name.rsplit('/').next().unwrap_or(name); - let hosted = format!("/{name}/{version}/"); - let registry = format!("/{name}/-/{bare}-{version}.tgz"); - let scoped_sibling = |s: &str, at: usize| { - !name.starts_with('@') - && s[..at] - .rsplit('/') - .next() - .is_some_and(|seg| seg.starts_with('@')) - }; - let references = |s: &str| { - [&hosted, ®istry].into_iter().any(|probe| { - let mut from = 0; - while let Some(pos) = s[from..].find(probe.as_str()) { - let at = from + pos; - if !scoped_sibling(s, at) { - return true; - } - from = at + 1; - } - false - }) - }; - [&edit.new, &edit.original].into_iter().any(|v| { - v.as_ref() - .and_then(|o| o.get("resolved")) - .and_then(Value::as_str) - .is_some_and(references) - }) -} - -/// Replay one recorded package-lock JSON edit (`redirect_npm_lock_entry` / -/// `redirect_npm_lock_dep`) through the staged view. `disk_texts` is the -/// attribution pass's read of the lock (`None` = missing on disk), consulted -/// before touching the disk again; `staged` still wins over both. -async fn revert_npm_json_edit( - project_root: &Path, - staged: &mut Staged, - disk_texts: &BTreeMap>, - edit: &FileEdit, - name: &str, - version: &str, - out: &mut RedirectRevert, -) -> Result<(), String> { - let content = match (staged.get(&edit.path), disk_texts.get(&edit.path)) { - (Some(pending), _) => pending.clone(), - (None, Some(on_disk)) => on_disk.clone(), - (None, None) => read_rel(project_root, &edit.path).await?, - }; - let Some(content) = content else { - return Err(format!( - "{} no longer exists; cannot revert the recorded hosted redirect \ - for {name}@{version}", - edit.path - )); - }; - let mut lock: Value = serde_json::from_str(&content).map_err(|e| { - format!( - "{} is not valid JSON ({e}); cannot revert the recorded hosted \ - redirect for {name}@{version}", - edit.path - ) - })?; - let key = edit.key.as_deref().unwrap_or_default(); - let changed = match edit.kind.as_str() { - "redirect_npm_lock_entry" => { - let Some(entry) = lock.get_mut("packages").and_then(|p| p.get_mut(key)) else { - return Err(format!( - "the {} entry `{key}` for {name}@{version} no longer \ - exists; cannot revert the recorded hosted redirect", - edit.path - )); - }; - replay_resolved_integrity(entry, edit, &edit.path, key)? - } - "redirect_npm_lock_dep" => { - let Some(deps) = lock.get_mut("dependencies").and_then(Value::as_object_mut) else { - return Err(format!( - "{} no longer holds a `dependencies` tree; cannot revert \ - the recorded hosted redirect for {name}@{version}", - edit.path - )); - }; - let mut any_found = false; - let mut changed = false; - revert_v2_deps( - deps, - name, - version, - edit, - &edit.path, - &mut any_found, - &mut changed, - )?; - if !any_found { - return Err(format!( - "the {} `dependencies` entry for {name}@{version} no \ - longer exists; cannot revert the recorded hosted redirect", - edit.path - )); - } - changed - } - other => { - return Err(format!( - "no revert implementation for redirect edit kind `{other}`" - )); - } - }; - if changed { - staged.insert(edit.path.clone(), Some(super::serialize_json(&lock))); - out.reverted_files.push(edit.path.clone()); - } - Ok(()) -} - -/// Replace an entry's `resolved`/`integrity` with the edit's recorded -/// originals. `Ok(false)` when the entry already holds the originals; -/// `Err` (drift, fail closed) when it holds neither the recorded redirected -/// values nor the originals. -fn replay_resolved_integrity( - entry: &mut Value, - edit: &FileEdit, - path: &str, - key: &str, -) -> Result { - let field = |v: &Option, f: &str| -> Value { - v.as_ref() - .and_then(|o| o.get(f)) - .cloned() - .unwrap_or(Value::Null) - }; - let orig_res = field(&edit.original, "resolved"); - let orig_int = field(&edit.original, "integrity"); - let cur = |f: &str| entry.get(f).cloned().unwrap_or(Value::Null); - if cur("resolved") == orig_res && cur("integrity") == orig_int { - return Ok(false); // already at (or unwound to) the pre-redirect values - } - if cur("resolved") != field(&edit.new, "resolved") - || cur("integrity") != field(&edit.new, "integrity") - { - return Err(format!( - "the {path} entry `{key}` has drifted from the recorded hosted \ - redirect (neither the redirected nor the original \ - resolved/integrity is present); refusing to touch it — re-run \ - `scan --mode hosted` to normalize the redirect, or restore the \ - registry wiring manually, then re-run" - )); - } - let Some(obj) = entry.as_object_mut() else { - return Err(format!("the {path} entry `{key}` is not an object")); - }; - for (f, orig) in [("resolved", orig_res), ("integrity", orig_int)] { - if orig.is_null() { - obj.remove(f); - } else { - obj.insert(f.to_string(), orig); - } - } - Ok(true) -} - -/// Recursive twin of the rewriter's `rewrite_npm_v2_deps` walk: replay the -/// edit's originals over every legacy `dependencies` node for this -/// name+version. Bundled nodes mirror the rewriter's skip — they were never -/// rewritten, so their registry-shaped (or absent) values must not read as -/// drift. -fn revert_v2_deps( - deps: &mut serde_json::Map, - name: &str, - version: &str, - edit: &FileEdit, - path: &str, - any_found: &mut bool, - changed: &mut bool, -) -> Result<(), String> { - for (dep_name, entry) in deps.iter_mut() { - if dep_name == name - && entry.get("version").and_then(Value::as_str) == Some(version) - && entry.get("bundled").and_then(Value::as_bool) != Some(true) - { - *any_found = true; - if replay_resolved_integrity(entry, edit, path, dep_name)? { - *changed = true; - } - } - if let Some(nested) = entry.get_mut("dependencies").and_then(Value::as_object_mut) { - revert_v2_deps(nested, name, version, edit, path, any_found, changed)?; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::manifest::schema::PatchRecord; - use std::collections::BTreeMap; - use std::collections::HashMap; - - const UUID: &str = "6b7c8d9e-0f1a-4a1b-8c2d-3e4f5a6b7c8d"; - const PURL: &str = "pkg:cargo/cfg-if@1.0.4"; - const INDEX: &str = "sparse+http://127.0.0.1:5555/index/"; - const CRATES_IO: &str = "registry+https://github.com/rust-lang/crates.io-index"; - - fn record() -> PatchRecord { - PatchRecord { - uuid: UUID.to_string(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - } - } - - fn pristine_toml() -> String { - "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = \"1.0\"\n" - .to_string() - } - - fn pristine_lock_block() -> String { - format!( - "[[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\nchecksum = \"{}\"", - "9".repeat(64) - ) - } - - /// Run the real hosted rewriter over a pristine project, write its output - /// to a tempdir, and return the resulting ledger — the exact state the - /// takeover revert consumes in production. - async fn redirected_fixture() -> (tempfile::TempDir, RedirectState) { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let lock = format!( - "# This file is automatically @generated by Cargo.\nversion = 4\n\n{}\n", - pristine_lock_block() - ); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), pristine_toml()); - files.insert("Cargo.lock".into(), lock.clone()); - let dep: crate::patch::redirect::DepOverride = serde_json::from_value(serde_json::json!({ - "ecosystem": "cargo", - "name": "cfg-if", - "version": "1.0.4", - "token": "tok", - "patchUuid": UUID, - "artifactUrl": format!("http://127.0.0.1:5555/cfg-if-1.0.4.crate"), - "registryOverride": { - "kind": "cargo-sparse", - "indexUrl": INDEX, - "identifiers": { - "name": "cfg-if", "version": "1.0.4", - "cargoCksumSha256": "a".repeat(64), - }, - }, - "integrity": { "sha256": "a".repeat(64) }, - })) - .unwrap(); - let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); - tokio::fs::write(root.join("Cargo.toml"), &pristine_toml()) - .await - .unwrap(); - tokio::fs::write(root.join("Cargo.lock"), &lock) - .await - .unwrap(); - for (rel, content) in &rewrite.files { - let path = root.join(rel); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await.unwrap(); - } - tokio::fs::write(&path, content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - state.records.insert(PURL.to_string(), record()); - (tmp, state) - } - - /// Two redirected versions of one crate share the manifest edit key - /// (the crate name). Removing one version must revert ONLY its own - /// declaration + lock entry + registry block — claiming the sibling's - /// manifest edit would revert the other pin while its lock entry stayed - /// hosted (a broken build), and dropping the sibling's registry edit - /// would leave the created `.cargo/config.toml` behind on the second - /// removal. - #[tokio::test] - async fn multi_version_removes_each_version_independently() { - const UUID_OLD: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; - const PURL_OLD: &str = "pkg:cargo/cfg-if@0.1.10"; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ - cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; - let lock = format!( - "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n\ - source = \"{CRATES_IO}\"\nchecksum = \"{}\"\n\n{}\n", - "8".repeat(64), - pristine_lock_block() - ); - let dep = |version: &str, uuid: &str| -> crate::patch::redirect::DepOverride { - serde_json::from_value(serde_json::json!({ - "ecosystem": "cargo", "name": "cfg-if", "version": version, "token": "tok", - "patchUuid": uuid, - "artifactUrl": format!("http://127.0.0.1:5555/cfg-if-{version}.crate"), - "registryOverride": { - "kind": "cargo-sparse", - "indexUrl": format!("sparse+http://127.0.0.1:5555/{uuid}/index/"), - "identifiers": { - "name": "cfg-if", "version": version, - "cargoCksumSha256": "a".repeat(64), - }, - }, - "integrity": { "sha256": "a".repeat(64) }, - })) - .unwrap() - }; - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), toml.to_string()); - files.insert("Cargo.lock".into(), lock.clone()); - let rewrite = crate::patch::redirect::rewrite_registry_redirect( - &files, - &[dep("1.0.4", UUID), dep("0.1.10", UUID_OLD)], - ); - assert_eq!( - rewrite.confirmed_cargo_uuids.len(), - 2, - "{:?}", - rewrite.warnings - ); - tokio::fs::write(root.join("Cargo.toml"), toml) - .await - .unwrap(); - tokio::fs::write(root.join("Cargo.lock"), &lock) - .await - .unwrap(); - for (rel, content) in &rewrite.files { - let path = root.join(rel); - tokio::fs::create_dir_all(path.parent().unwrap()) - .await - .unwrap(); - tokio::fs::write(&path, content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - state.records.insert(PURL.to_string(), record()); - let mut old = record(); - old.uuid = UUID_OLD.to_string(); - state.records.insert(PURL_OLD.to_string(), old); - - revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("1.0.4 reverts"); - let t = tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(); - assert!( - t.contains("cfg-if = \"1.0\"\n") - && t.contains(&format!("registry = \"socket-patch-{UUID_OLD}\"")), - "only the 1.0.4 pin is reverted: {t}" - ); - let l = tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(); - assert!( - l.contains(&format!("sparse+http://127.0.0.1:5555/{UUID_OLD}/index/")), - "{l}" - ); - let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - assert!( - !cfg.contains(&format!("socket-patch-{UUID}]")) && cfg.contains(UUID_OLD), - "{cfg}" - ); - - revert_cargo_redirect_purl(root, &mut state, PURL_OLD, false) - .await - .expect("0.1.10 reverts"); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - toml - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(), - lock - ); - assert!( - !root.join(".cargo/config.toml").exists(), - "the created config goes with the last block" - ); - assert!( - state.edits.is_empty() && state.records.is_empty(), - "{:?}", - state.edits - ); - } - - /// A hosted override for `name@version` at patch `uuid`. - fn cargo_dep(name: &str, version: &str, uuid: &str) -> crate::patch::redirect::DepOverride { - serde_json::from_value(serde_json::json!({ - "ecosystem": "cargo", "name": name, "version": version, "token": "tok", - "patchUuid": uuid, - "artifactUrl": format!("http://127.0.0.1:5555/{name}-{version}.crate"), - "registryOverride": { - "kind": "cargo-sparse", - "indexUrl": format!("sparse+http://127.0.0.1:5555/{uuid}/index/"), - "identifiers": { - "name": name, "version": version, - "cargoCksumSha256": "a".repeat(64), - }, - }, - "integrity": { "sha256": "a".repeat(64) }, - })) - .unwrap() - } - - /// Redirect `deps` (applied in order) over a pristine project with the - /// real rewriter, write the output to a tempdir, and return the ledger - /// with one record per purl. - async fn redirect_on_disk( - toml: &str, - lock: &str, - deps: &[(&str, &str, &str)], - ) -> (tempfile::TempDir, RedirectState) { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), toml.to_string()); - files.insert("Cargo.lock".into(), lock.to_string()); - let overrides: Vec<_> = deps - .iter() - .map(|(name, version, uuid)| cargo_dep(name, version, uuid)) - .collect(); - let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &overrides); - assert_eq!( - rewrite.confirmed_cargo_uuids.len(), - deps.len(), - "{:?}", - rewrite.warnings - ); - for (rel, content) in files.iter().chain(rewrite.files.iter()) { - let path = root.join(rel); - tokio::fs::create_dir_all(path.parent().unwrap()) - .await - .unwrap(); - tokio::fs::write(&path, content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - for (name, version, uuid) in deps { - let mut rec = record(); - rec.uuid = uuid.to_string(); - state - .records - .insert(format!("pkg:cargo/{name}@{version}"), rec); - } - (tmp, state) - } - - /// Redirect `deps`, then remove the purls in EVERY order: each order - /// must succeed and restore the manifest and lock byte-for-byte, with no - /// config and no ledger left. - async fn assert_removes_in_every_order(toml: &str, lock: &str, deps: &[(&str, &str, &str)]) { - let purls: Vec = deps - .iter() - .map(|(name, version, _)| format!("pkg:cargo/{name}@{version}")) - .collect(); - for order in [purls.clone(), purls.iter().rev().cloned().collect()] { - let (tmp, mut state) = redirect_on_disk(toml, lock, deps).await; - let root = tmp.path(); - for purl in &order { - revert_cargo_redirect_purl(root, &mut state, purl, false) - .await - .unwrap_or_else(|e| panic!("remove {purl} (order {order:?}): {e}")); - } - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - toml, - "{order:?}" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(), - lock, - "{order:?}" - ); - assert!(!root.join(".cargo/config.toml").exists(), "{order:?}"); - assert!( - state.edits.is_empty() && state.records.is_empty(), - "{order:?}: {:?}", - state.edits - ); - } - } - - /// A v1 lock names every dependency by its full id, so the root block - /// references BOTH patched cfg-if versions. Removing the versions in any - /// order must succeed — not only in exact reverse apply order. - #[tokio::test] - async fn v1_lock_multi_version_removes_in_any_order() { - const UUID_OLD: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; - let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ - cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; - let lock = format!( - "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ - \"cfg-if 0.1.10 ({CRATES_IO})\",\n \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ - [[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\nsource = \"{CRATES_IO}\"\n\n\ - [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ - [metadata]\n\"checksum cfg-if 0.1.10 ({CRATES_IO})\" = \"{}\"\n\ - \"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n", - "8".repeat(64), - "9".repeat(64) - ); - assert_removes_in_every_order( - toml, - &lock, - &[("cfg-if", "1.0.4", UUID), ("cfg-if", "0.1.10", UUID_OLD)], - ) - .await; - } - - const UUID_LEGACY: &str = "3c5d7e9f-2a4b-4c6d-8e0f-1a3b5c7d9e1f"; - - /// The two-declaration cfg-if project (1.0.4 as `cfg-if`, 0.1.10 as a - /// renamed `cfg-if-legacy`) and its crates.io lock. - fn multi_version_project() -> (String, String) { - let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ - cfg-if = \"1.0\"\ncfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }\n"; - let lock = format!( - "version = 4\n\n[[package]]\nname = \"cfg-if\"\nversion = \"0.1.10\"\n\ - source = \"{CRATES_IO}\"\nchecksum = \"{}\"\n\n{}\n", - "8".repeat(64), - pristine_lock_block() - ); - (toml.to_string(), lock) - } - - /// Remove `order` from `state` over `root`, then require the pristine - /// project back with no config and an empty ledger. - async fn remove_all_and_expect_pristine( - root: &Path, - mut state: RedirectState, - order: &[&str], - toml: &str, - lock: &str, - ) { - for purl in order { - revert_cargo_redirect_purl(root, &mut state, purl, false) - .await - .unwrap_or_else(|e| panic!("remove {purl} (order {order:?}): {e}")); - } - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - toml, - "{order:?}" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(), - lock, - "{order:?}" - ); - assert!(!root.join(".cargo/config.toml").exists(), "{order:?}"); - assert!( - state.edits.is_empty() && state.records.is_empty(), - "{order:?}: {:?}", - state.edits - ); - } - - /// A ledger an older CLI wrote with its name-only manifest matcher for - /// both cfg-if versions: each run pinned BOTH declarations to its own - /// registry, so the file ends with both on the 0.1.10 registry and the - /// ledger holds plain→1.0.4 then 1.0.4→0.1.10 edits for both lines. - /// Removing either version first must neither refuse as drifted (by - /// claiming only half of a declaration's chain) nor drop a - /// still-referenced registry edit and leave the config behind. - #[tokio::test] - async fn legacy_name_only_multi_version_ledger_removes_in_any_order() { - let (toml, lock) = multi_version_project(); - let reg = |uuid: &str| format!("socket-patch-{uuid}"); - let index = |uuid: &str| format!("sparse+http://127.0.0.1:5555/{uuid}/index/"); - let line1 = |r: Option<&str>| match r { - None => "cfg-if = \"1.0\"".to_string(), - Some(r) => format!("cfg-if = {{ version = \"1.0\", registry = \"{r}\" }}"), - }; - let line2 = |r: Option<&str>| { - match r { - None => "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }".to_string(), - Some(r) => format!( - "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"{r}\" }}" - ), - } - }; - let block = |version: &str, source: &str, cksum: &str| { - format!( - "[[package]]\nname = \"cfg-if\"\nversion = \"{version}\"\nsource = \"{source}\"\n\ - checksum = \"{cksum}\"" - ) - }; - let (a, b) = (reg(UUID), reg(UUID_LEGACY)); - let edit = - |path: &str, kind: &str, key: &str, orig: Option, new: String| FileEdit { - path: path.to_string(), - kind: kind.to_string(), - action: if orig.is_some() { "rewritten" } else { "added" }.to_string(), - key: Some(key.to_string()), - original: orig.map(Value::String), - new: Some(Value::String(new)), - }; - let registry = |uuid: &str| { - edit( - ".cargo/config.toml", - "redirect_cargo_registry", - ®(uuid), - None, - format!("[registries.{}]\nindex = \"{}\"\n", reg(uuid), index(uuid)), - ) - }; - let lock_edit = |version: &str, uuid: &str, crates_cksum: &str| { - edit( - "Cargo.lock", - "redirect_cargo_lock_entry", - &format!("cfg-if@{version}"), - Some(block(version, CRATES_IO, crates_cksum)), - block(version, &index(uuid), &"a".repeat(64)), - ) - }; - let toml_edit = |orig: String, new: String| { - edit( - "Cargo.toml", - "redirect_cargo_toml_dep", - "cfg-if", - Some(orig), - new, - ) - }; - let edits = vec![ - registry(UUID), - toml_edit(line1(None), line1(Some(&a))), - toml_edit(line2(None), line2(Some(&a))), - lock_edit("1.0.4", UUID, &"9".repeat(64)), - registry(UUID_LEGACY), - toml_edit(line1(Some(&a)), line1(Some(&b))), - toml_edit(line2(Some(&a)), line2(Some(&b))), - lock_edit("0.1.10", UUID_LEGACY, &"8".repeat(64)), - ]; - let live_toml = toml - .replace(&line1(None), &line1(Some(&b))) - .replace(&line2(None), &line2(Some(&b))); - let live_lock = lock - .replace( - &block("1.0.4", CRATES_IO, &"9".repeat(64)), - &block("1.0.4", &index(UUID), &"a".repeat(64)), - ) - .replace( - &block("0.1.10", CRATES_IO, &"8".repeat(64)), - &block("0.1.10", &index(UUID_LEGACY), &"a".repeat(64)), - ); - let live_config = format!( - "{}\n{}", - edits[0].new.as_ref().and_then(Value::as_str).unwrap(), - edits[4].new.as_ref().and_then(Value::as_str).unwrap() - ); - for order in [ - ["pkg:cargo/cfg-if@1.0.4", "pkg:cargo/cfg-if@0.1.10"], - ["pkg:cargo/cfg-if@0.1.10", "pkg:cargo/cfg-if@1.0.4"], - ] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - tokio::fs::create_dir_all(root.join(".cargo")) - .await - .unwrap(); - tokio::fs::write(root.join("Cargo.toml"), &live_toml) - .await - .unwrap(); - tokio::fs::write(root.join("Cargo.lock"), &live_lock) - .await - .unwrap(); - tokio::fs::write(root.join(".cargo/config.toml"), &live_config) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.edits = edits.clone(); - state.records.insert(PURL.to_string(), record()); - let mut old = record(); - old.uuid = UUID_LEGACY.to_string(); - state - .records - .insert("pkg:cargo/cfg-if@0.1.10".to_string(), old); - remove_all_and_expect_pristine(root, state, &order, &toml, &lock).await; - } - } - - /// An older CLI redirected only 1.0.4 and its name-only matcher also - /// pinned the 0.1.10 declaration to 1.0.4's registry; the current - /// planner then repaired it (superseding that pin with 0.1.10's own - /// registry) while redirecting 0.1.10. Removing 1.0.4 must not claim the - /// superseded mis-pin edit (whose fragments are both gone) and refuse as - /// drifted. - #[tokio::test] - async fn repaired_legacy_mispin_removes_in_any_order() { - let (toml, lock) = multi_version_project(); - let legacy = "cfg-if-legacy = { package = \"cfg-if\", version = \"0.1.10\" }"; - let mispinned = format!( - "cfg-if-legacy = {{ package = \"cfg-if\", version = \"0.1.10\", registry = \"socket-patch-{UUID}\" }}" - ); - // The old CLI's run: 1.0.4 only, plus its mis-pin of the legacy line. - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), toml.clone()); - files.insert("Cargo.lock".into(), lock.clone()); - let old_run = crate::patch::redirect::rewrite_registry_redirect( - &files, - &[cargo_dep("cfg-if", "1.0.4", UUID)], - ); - let mut edits = old_run.edits.clone(); - let at = edits - .iter() - .position(|e| e.kind == "redirect_cargo_toml_dep") - .unwrap(); - let mut mispin = edits[at].clone(); - mispin.original = Some(Value::String(legacy.to_string())); - mispin.new = Some(Value::String(mispinned.clone())); - edits.insert(at + 1, mispin); - let mut live = files.clone(); - live.extend(old_run.files.clone()); - let t = live["Cargo.toml"].replace(legacy, &mispinned); - live.insert("Cargo.toml".into(), t); - // The current CLI's run with both patches repairs the mis-pin. - let new_run = crate::patch::redirect::rewrite_registry_redirect( - &live, - &[ - cargo_dep("cfg-if", "1.0.4", UUID), - cargo_dep("cfg-if", "0.1.10", UUID_LEGACY), - ], - ); - assert!(new_run.warnings.is_empty(), "{:?}", new_run.warnings); - edits.extend(new_run.edits.clone()); - live.extend(new_run.files.clone()); - for order in [ - ["pkg:cargo/cfg-if@1.0.4", "pkg:cargo/cfg-if@0.1.10"], - ["pkg:cargo/cfg-if@0.1.10", "pkg:cargo/cfg-if@1.0.4"], - ] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - for (rel, content) in &live { - let path = root.join(rel); - tokio::fs::create_dir_all(path.parent().unwrap()) - .await - .unwrap(); - tokio::fs::write(&path, content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = edits.clone(); - state.records.insert(PURL.to_string(), record()); - let mut old = record(); - old.uuid = UUID_LEGACY.to_string(); - state - .records - .insert("pkg:cargo/cfg-if@0.1.10".to_string(), old); - remove_all_and_expect_pristine(root, state, &order, &toml, &lock).await; - } - } - - /// The whole-ledger replay (rollback) inverts a v1 full-id reference - /// named by several dependents at every occurrence. - #[tokio::test] - async fn v1_lock_reference_named_by_several_dependents_replays_clean() { - let toml = "[workspace]\nmembers = [\"a\", \"b\"]\n\n\ - [workspace.dependencies]\ncfg-if = \"1.0\"\n"; - let member = |name: &str| { - format!( - "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n\ - [dependencies]\ncfg-if = {{ workspace = true }}\n" - ) - }; - let lock = format!( - "[[package]]\nname = \"a\"\nversion = \"0.1.0\"\ndependencies = [\n \ - \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ - [[package]]\nname = \"b\"\nversion = \"0.1.0\"\ndependencies = [\n \ - \"cfg-if 1.0.4 ({CRATES_IO})\",\n]\n\n\ - [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ - [metadata]\n\"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n", - "9".repeat(64) - ); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), toml.to_string()); - files.insert("Cargo.lock".into(), lock.clone()); - files.insert("a/Cargo.toml".into(), member("a")); - files.insert("b/Cargo.toml".into(), member("b")); - let rewrite = crate::patch::redirect::rewrite_registry_redirect( - &files, - &[cargo_dep("cfg-if", "1.0.4", UUID)], - ); - assert_eq!( - rewrite.confirmed_cargo_uuids.len(), - 1, - "{:?}", - rewrite.warnings - ); - let references: Vec<&FileEdit> = rewrite - .edits - .iter() - .filter(|e| e.kind == super::super::CARGO_LOCK_REFERENCE_KIND) - .collect(); - assert_eq!( - references.len(), - 1, - "one edit per full id: {:?}", - rewrite.edits - ); - for (rel, content) in files.iter().chain(rewrite.files.iter()) { - let path = root.join(rel); - tokio::fs::create_dir_all(path.parent().unwrap()) - .await - .unwrap(); - tokio::fs::write(&path, content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - state.records.insert(PURL.to_string(), record()); - let outcome = - crate::patch::redirect::revert_remaining_redirect_edits(root, &mut state, false).await; - assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(), - lock - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - toml - ); - } - - /// Two different patched crates with one shared v1 dependent block. - #[tokio::test] - async fn v1_lock_two_crates_sharing_a_dependent_remove_in_any_order() { - const UUID_ITOA: &str = "4d6e8f0a-3b5c-4d7e-9f1a-2b4c6d8e0f2a"; - let toml = "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n\ - cfg-if = \"1.0\"\nitoa = \"1.0.11\"\n"; - let lock = format!( - "[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \ - \"cfg-if 1.0.4 ({CRATES_IO})\",\n \"itoa 1.0.11 ({CRATES_IO})\",\n]\n\n\ - [[package]]\nname = \"cfg-if\"\nversion = \"1.0.4\"\nsource = \"{CRATES_IO}\"\n\n\ - [[package]]\nname = \"itoa\"\nversion = \"1.0.11\"\nsource = \"{CRATES_IO}\"\n\n\ - [metadata]\n\"checksum cfg-if 1.0.4 ({CRATES_IO})\" = \"{}\"\n\ - \"checksum itoa 1.0.11 ({CRATES_IO})\" = \"{}\"\n", - "8".repeat(64), - "9".repeat(64) - ); - assert_removes_in_every_order( - toml, - &lock, - &[("cfg-if", "1.0.4", UUID), ("itoa", "1.0.11", UUID_ITOA)], - ) - .await; - } - - #[tokio::test] - async fn reverts_toml_lock_and_registry_block_and_drops_ledger_entries() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - // Sanity: the fixture really is hosted-wired. - let toml = tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(); - assert!(toml.contains("socket-patch-"), "{toml}"); - - let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - assert!(!out.reverted_files.is_empty()); - - let toml = tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(); - assert_eq!(toml, pristine_toml(), "Cargo.toml restored byte-identical"); - let lock = tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(); - assert!( - lock.contains(CRATES_IO), - "crates.io source restored: {lock}" - ); - assert!(!lock.contains("sparse+"), "hosted index gone: {lock}"); - assert!( - !root.join(".cargo/config.toml").exists(), - "socket-only config removed" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - #[tokio::test] - async fn preserves_user_config_content_when_removing_the_registry_block() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - // Prepend user content to the config the rewrite created. - let cfg_path = root.join(".cargo/config.toml"); - let cfg = tokio::fs::read_to_string(&cfg_path).await.unwrap(); - tokio::fs::write(&cfg_path, format!("[net]\nretry = 2\n{cfg}")) - .await - .unwrap(); - - revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - let cfg = tokio::fs::read_to_string(&cfg_path).await.unwrap(); - assert!(cfg.contains("[net]"), "user content kept: {cfg}"); - assert!(!cfg.contains("socket-patch-"), "block removed: {cfg}"); - } - - #[tokio::test] - async fn refuses_on_drifted_lock_fail_closed() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - // 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, false) - .await - .expect_err("drifted lock must refuse"); - 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); - } - - /// The unwind runs newest-first (edits are recorded config, manifest, - /// lock), so Cargo.lock's inverse resolves BEFORE Cargo.toml's. Drifting - /// only Cargo.toml therefore refuses at a point where the lock's inverse - /// has already been decided — and the caller reports the purl as - /// untouched ("cannot vendor over the live hosted redirect"), so a - /// revert that had written the lock by then would leave the project - /// half-hosted behind a message saying nothing happened. - #[tokio::test] - async fn a_later_drifted_edit_leaves_every_earlier_file_untouched() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - let drifted_toml = - "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\ncfg-if = { version = \"1.0\", registry = \"corp-mirror\" }\n"; - tokio::fs::write(root.join("Cargo.toml"), drifted_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(); - assert!( - lock_before.contains("sparse+"), - "fixture is hosted-wired: {lock_before}" - ); - - let err = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect_err("drifted manifest must refuse"); - assert!(err.contains("drifted"), "{err}"); - - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(), - lock_before, - "Cargo.lock must be untouched — its inverse resolved before the refusal" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - drifted_toml, - "Cargo.toml untouched" - ); - assert_eq!( - tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(), - cfg_before, - ".cargo/config.toml untouched" - ); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert!(!state.edits.is_empty(), "ledger keeps the edits"); - } - - #[tokio::test] - 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, 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 (the in-memory claim is checked below; the - // persisted ledger is the caller's and is never written on a dry run). - 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"; - const NPM_URL: &str = - "http://127.0.0.1:5555/patch/npm/left-pad/1.3.0/tok/6b7c/left-pad-1.3.0.tgz"; - - fn npm_dep_for(name: &str, version: &str) -> crate::patch::redirect::DepOverride { - serde_json::from_value(serde_json::json!({ - "ecosystem": "npm", - "name": name, - "version": version, - "token": "tok", - "patchUuid": UUID, - "artifactUrl": format!( - "http://127.0.0.1:5555/patch/npm/{name}/{version}/tok/6b7c/{name}-{version}.tgz" - ), - "integrity": { - "sha512": format!("sha512-{}==", "B".repeat(86)), - "sha1": "1".repeat(40), - "yarnBerry10c0": format!("10c0/{}", "b".repeat(128)), - }, - })) - .unwrap() - } - - fn npm_dep() -> crate::patch::redirect::DepOverride { - npm_dep_for("left-pad", "1.3.0") - } - - /// Run the real hosted rewriter over one pristine lock (redirecting every - /// purl in `deps`), write its output to a tempdir, and return the - /// resulting ledger — the exact state the takeover revert consumes in - /// production. - async fn npm_redirected_fixture_multi( - rel: &str, - pristine: &str, - deps: &[(&str, crate::patch::redirect::DepOverride)], - ) -> (tempfile::TempDir, RedirectState) { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let mut files: BTreeMap = BTreeMap::new(); - files.insert(rel.to_string(), pristine.to_string()); - let overrides: Vec<_> = deps.iter().map(|(_, d)| d.clone()).collect(); - let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &overrides); - let rewritten = rewrite - .files - .get(rel) - .unwrap_or_else(|| panic!("rewriter must rewrite {rel}: {:?}", rewrite.warnings)); - tokio::fs::write(root.join(rel), rewritten).await.unwrap(); - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - for (purl, _) in deps { - state.records.insert(purl.to_string(), record()); - } - (tmp, state) - } - - /// Run the real hosted rewriter over one pristine lock, write its output - /// to a tempdir, and return the resulting ledger — the exact state the - /// takeover revert consumes in production. - async fn npm_redirected_fixture( - rel: &str, - pristine: &str, - ) -> (tempfile::TempDir, RedirectState) { - npm_redirected_fixture_multi(rel, pristine, &[(NPM_PURL, npm_dep())]).await - } - - fn classic_pristine() -> String { - "# yarn lockfile v1\n\n\nleft-pad@1.3.0:\n version \"1.3.0\"\n resolved \ - \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a\"\n \ - integrity sha512-original==\n" - .to_string() - } - - // ---------- shared staging guards (twins of the replay's) ---------- - - /// A FIFO planted at a lockfile the ledger claims refuses fast (`read - /// : … not a regular file`) instead of wedging the takeover in a - /// blocking open; the ledger is untouched for a retry. - #[cfg(unix)] - #[tokio::test] - async fn npm_fifo_squatting_the_lock_refuses_instead_of_wedging() { - use std::os::unix::ffi::OsStrExt; - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - let path = root.join("yarn.lock"); - tokio::fs::remove_file(&path).await.unwrap(); - let cpath = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); - assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o644) }, 0); - let edits_before = state.edits.len(); - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("a FIFO must refuse"); - assert!( - err.starts_with("read yarn.lock:") && err.contains("not a regular file"), - "{err}" - ); - assert_eq!(state.edits.len(), edits_before, "ledger untouched"); - assert!(state.records.contains_key(NPM_PURL), "record kept"); - } - - /// A symlinked lockfile reads fine (the opener follows it) but refuses - /// at flush: a rename-over would replace the link with a detached - /// regular file. Nothing is written and the ledger is untouched. - #[cfg(unix)] - #[tokio::test] - async fn npm_symlinked_lock_reads_fine_but_refuses_at_flush() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - let redirected = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - tokio::fs::rename(root.join("yarn.lock"), root.join("real.lock")) - .await - .unwrap(); - std::os::unix::fs::symlink(root.join("real.lock"), root.join("yarn.lock")).unwrap(); - let edits_before = state.edits.len(); - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("a symlinked lock must refuse"); - assert_eq!(err, "yarn.lock is not a regular file"); - assert_eq!( - tokio::fs::read_to_string(root.join("real.lock")) - .await - .unwrap(), - redirected, - "the symlink target must stay byte-identical" - ); - assert!( - std::fs::symlink_metadata(root.join("yarn.lock")) - .unwrap() - .file_type() - .is_symlink(), - "the link itself must survive" - ); - assert_eq!(state.edits.len(), edits_before, "ledger untouched"); - assert!(state.records.contains_key(NPM_PURL), "record kept"); - } - - /// The text flush is the mode-preserving atomic writer: a `0600` lock - /// keeps its bits through the takeover revert. - #[cfg(unix)] - #[tokio::test] - async fn npm_text_lock_revert_keeps_the_file_mode() { - use std::os::unix::fs::PermissionsExt; - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - let path = root.join("yarn.lock"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(&path).await.unwrap(), - classic_pristine() - ); - assert_eq!( - std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600, - "the lockfile's mode must survive the atomic rewrite" - ); - } - - fn berry_pristine() -> String { - "# This file is generated by running \"yarn install\" inside your project.\n\n\ - __metadata:\n version: 8\n cacheKey: 10c0\n\n\ - \"left-pad@npm:1.3.0\":\n version: 1.3.0\n resolution: \"left-pad@npm:1.3.0\"\n \ - checksum: 10c0/cccc\n languageName: node\n linkType: hard\n" - .to_string() - } - - /// Pristine package-lock (lockfileVersion 2: BOTH the v3 `packages` map - /// and the legacy v2 `dependencies` tree), serialized exactly as the - /// rewriter serializes, so the revert round-trip is byte-comparable. - fn package_lock_pristine() -> String { - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine==" - } - }, - "dependencies": { - "left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine==" - } - } - }); - format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) - } - - #[test] - fn revert_supported_gate_covers_cargo_and_npm_only() { - assert!(redirect_revert_supported("pkg:cargo/cfg-if@1.0.4")); - assert!(redirect_revert_supported("pkg:npm/left-pad@1.3.0")); - assert!(redirect_revert_supported("pkg:npm/%40scope/x@1.0.0")); - assert!(!redirect_revert_supported("pkg:gem/rack@3.0.0")); - assert!(!redirect_revert_supported("pkg:pypi/flask@2.0.0")); - } - - #[tokio::test] - async fn npm_classic_lock_round_trips_and_drops_ledger_entries() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - let wired = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - assert!(wired.contains(NPM_URL), "fixture is hosted-wired: {wired}"); - - 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()]); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - classic_pristine(), - "yarn.lock restored byte-identical" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - fn future_lock_edit(name: &str, version: &str) -> FileEdit { - FileEdit { - path: "future.lock".into(), - kind: "redirect_future_lock_entry".into(), - action: "rewritten".into(), - key: Some(format!("{name}@{version}")), - original: Some(Value::String(format!("{name}@{version} sha512-r"))), - new: Some(Value::String(format!("{name}@{version} {NPM_URL}"))), - } - } - - fn vlt_lock(entries: &[String]) -> String { - let body: Vec = entries.iter().map(|e| format!(" {e}")).collect(); - format!( - "{{\n \"lockfileVersion\": 1,\n \"nodes\": {{\n{}\n }},\n \"edges\": {{}}\n}}\n", - body.join(",\n") - ) - } - - fn vlt_entry(id: &str, slots: &str) -> String { - format!("\"{id}\": [0,\"left-pad\",{slots}]") - } - - fn vlt_node_edit(key: &str, id: &str) -> FileEdit { - FileEdit { - path: "vlt-lock.json".into(), - kind: super::super::vlt::KIND.into(), - action: "rewritten".into(), - key: Some(key.into()), - original: Some(Value::String(vlt_entry(id, "\"sha512-r\""))), - new: Some(Value::String(vlt_entry( - id, - &format!("\"sha512-p\",\"{NPM_URL}\""), - ))), - } - } - - #[tokio::test] - async fn npm_vlt_takeover_claims_every_variant_by_key_and_leaves_other_versions() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let hosted = format!("\"sha512-p\",\"{NPM_URL}\""); - let wired = vlt_lock(&[ - vlt_entry("·npm·left-pad@1.3.0·%E1%B9%97%3A3", &hosted), - vlt_entry("~npm~left-pad@1.3.0", &hosted), - vlt_entry("~npm~left-pad@1.3.0-rc.1", &hosted), - vlt_entry("~npm~left-pad@1.3.0~peer.2", &hosted), - ]); - tokio::fs::write(root.join("vlt-lock.json"), &wired) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.into(), record()); - state.edits = vec![ - vlt_node_edit( - "left-pad@1.3.0~%E1%B9%97%3A3", - "·npm·left-pad@1.3.0·%E1%B9%97%3A3", - ), - vlt_node_edit("left-pad@1.3.0", "~npm~left-pad@1.3.0"), - vlt_node_edit("left-pad@1.3.0-rc.1", "~npm~left-pad@1.3.0-rc.1"), - vlt_node_edit("left-pad@1.3.0~peer.2", "~npm~left-pad@1.3.0~peer.2"), - ]; - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - let registry = "\"sha512-r\""; - assert_eq!( - tokio::fs::read_to_string(root.join("vlt-lock.json")) - .await - .unwrap(), - vlt_lock(&[ - vlt_entry("·npm·left-pad@1.3.0·%E1%B9%97%3A3", registry), - vlt_entry("~npm~left-pad@1.3.0", registry), - vlt_entry("~npm~left-pad@1.3.0-rc.1", &hosted), - vlt_entry("~npm~left-pad@1.3.0~peer.2", registry), - ]) - ); - let keys: Vec<&str> = state - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .collect(); - assert_eq!(keys, ["left-pad@1.3.0-rc.1"]); - assert!(state.records.is_empty()); - } - - #[tokio::test] - async fn npm_vlt_takeover_refuses_a_drifted_line_untouched() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let wired = vlt_lock(&[vlt_entry("~npm~left-pad@1.3.0", "\"sha512-other\"")]); - tokio::fs::write(root.join("vlt-lock.json"), &wired) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.into(), record()); - state.edits = vec![vlt_node_edit("left-pad@1.3.0", "~npm~left-pad@1.3.0")]; - let before = state.clone(); - let err = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .unwrap_err(); - assert!(err.contains("drifted from the recorded redirect"), "{err}"); - assert_eq!( - serde_json::to_value(&state).unwrap(), - serde_json::to_value(&before).unwrap() - ); - assert_eq!( - tokio::fs::read_to_string(root.join("vlt-lock.json")) - .await - .unwrap(), - wired - ); - } - - #[tokio::test] - async fn npm_vlt_takeover_keeps_a_relaid_flag_and_trailing_slots() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let relaid = format!( - "\"~npm~left-pad@1.3.0\": [2,\"left-pad\",\"sha512-p\",\"{NPM_URL}\",null,null,null,null,{{ \"lp\": \"bin.js\"}}]" - ); - tokio::fs::write(root.join("vlt-lock.json"), vlt_lock(&[relaid])) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.into(), record()); - state.edits = vec![vlt_node_edit("left-pad@1.3.0", "~npm~left-pad@1.3.0")]; - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("vlt-lock.json")) - .await - .unwrap(), - vlt_lock(&["\"~npm~left-pad@1.3.0\": [2,\"left-pad\",\"sha512-r\",null,null,null,null,null,{ \"lp\": \"bin.js\"}]".to_string()]) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - } - - #[tokio::test] - async fn npm_vlt_takeover_reverts_a_relocked_away_variant_whatever_the_ledger_order() { - let hosted = format!("\"sha512-p\",\"{NPM_URL}\""); - let plain = vlt_node_edit("left-pad@1.3.0", "~npm~left-pad@1.3.0"); - let peer = vlt_node_edit("left-pad@1.3.0~peer.2", "~npm~left-pad@1.3.0~peer.2"); - for edits in [ - vec![plain.clone(), peer.clone()], - vec![peer.clone(), plain.clone()], - ] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let wired = vlt_lock(&[vlt_entry("~npm~left-pad@1.3.0", &hosted)]); - tokio::fs::write(root.join("vlt-lock.json"), &wired) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.into(), record()); - state.edits = edits; - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("vlt-lock.json")) - .await - .unwrap(), - vlt_lock(&[vlt_entry("~npm~left-pad@1.3.0", "\"sha512-r\"")]) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - } - } - - #[tokio::test] - async fn npm_vlt_takeover_follows_a_pin_vlt_carried_to_a_new_peer_context() { - let hosted = format!("\"sha512-p\",\"{NPM_URL}\""); - let old_id = "~npm~left-pad@1.3.0~peer.0df72515a50372ba"; - let new_id = "~npm~left-pad@1.3.0~peer.32643a3290c32d5d"; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let wired = vlt_lock(&[ - vlt_entry(new_id, &hosted), - vlt_entry("~npm~right-pad@1.3.0", "\"sha512-q\""), - ]); - tokio::fs::write(root.join("vlt-lock.json"), &wired) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.into(), record()); - state.edits = vec![vlt_node_edit( - "left-pad@1.3.0~peer.0df72515a50372ba", - old_id, - )]; - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("vlt-lock.json")) - .await - .unwrap(), - vlt_lock(&[ - vlt_entry(new_id, "\"sha512-r\""), - vlt_entry("~npm~right-pad@1.3.0", "\"sha512-q\""), - ]) - ); - assert!(state.edits.is_empty() && state.records.is_empty()); - } - - #[tokio::test] - async fn npm_unclassified_edit_naming_the_purl_refuses_the_claim_untouched() { - for dry_run in [true, false] { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - state.edits.push(future_lock_edit("left-pad", "1.3.0")); - let wired = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - let before = state.clone(); - let err = revert_redirect_purl(root, &mut state, NPM_PURL, dry_run) - .await - .unwrap_err(); - assert_eq!( - err, - "the redirect ledger holds a redirect_future_lock_entry edit this socket-patch \ - release does not understand; upgrade socket-patch" - ); - assert_eq!( - serde_json::to_value(&state).unwrap(), - serde_json::to_value(&before).unwrap(), - "nothing claimed" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - wired - ); - } - } - - #[tokio::test] - async fn npm_unclassified_edit_for_another_package_does_not_block_the_claim() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - state.edits.push(future_lock_edit("left-pad", "1.3.1")); - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!(state.edits.len(), 1); - assert_eq!(state.edits[0].kind, "redirect_future_lock_entry"); - } - - #[tokio::test] - async fn npm_unclassified_edit_for_a_longer_or_scoped_name_does_not_block_the_claim() { - for other in ["long-left-pad", "@scope/left-pad"] { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - state.edits.push(future_lock_edit(other, "1.3.0")); - revert_redirect_purl(tmp.path(), &mut state, NPM_PURL, false) - .await - .unwrap_or_else(|e| panic!("{other}: {e}")); - assert_eq!(state.edits.len(), 1, "{other}"); - assert_eq!(state.edits[0].kind, "redirect_future_lock_entry", "{other}"); - } - } - - #[tokio::test] - async fn cargo_and_golang_claims_refuse_an_unclassified_edit_naming_them() { - let tmp = tempfile::tempdir().unwrap(); - for (purl, name, version) in [ - ("pkg:cargo/serde@1.0.0", "serde", "1.0.0"), - ("pkg:golang/example.com/m@v1.2.3", "example.com/m", "v1.2.3"), - ] { - let mut state = RedirectState::new(); - state.records.insert(purl.into(), record()); - state.edits.push(FileEdit { - path: "future.lock".into(), - kind: "redirect_future_lock_entry".into(), - action: "rewritten".into(), - key: None, - original: Some(serde_json::json!({ "id": format!("{name}@{version}") })), - new: Some(Value::String("x".into())), - }); - let before = state.clone(); - let err = revert_redirect_purl(tmp.path(), &mut state, purl, false) - .await - .unwrap_err(); - assert!(err.contains("redirect_future_lock_entry edit"), "{err}"); - assert_eq!( - serde_json::to_value(&state).unwrap(), - serde_json::to_value(&before).unwrap(), - "{purl}" - ); - } - } - - #[tokio::test] - async fn npm_berry_lock_round_trips_and_drops_ledger_entries() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &berry_pristine()).await; - let root = tmp.path(); - let wired = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - assert!( - wired.contains("::__archiveUrl="), - "fixture is hosted-wired: {wired}" - ); - - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - berry_pristine(), - "yarn.lock restored byte-identical" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// A CRLF (and BOM'd) berry lock — yarn's own output on Windows — - /// round-trips through the takeover byte-exactly: the rewriter records - /// the CRLF fragments, the revert replays them. Across checkouts too: a - /// ledger written on a CRLF checkout reverts an LF checkout of the same - /// commit and vice versa (`core.autocrlf` re-spells the lock, never the - /// committed ledger), landing on the pristine lock in the LIVE file's - /// endings. A lock whose endings are mixed proves nothing: it refuses - /// as drift, byte-untouched, ledger intact. - #[tokio::test] - async fn npm_berry_crlf_lock_round_trips_across_checkouts() { - let respell = |text: &str, crlf: bool| { - let lf = text.replace("\r\n", "\n"); - if crlf { - lf.replace('\n', "\r\n") - } else { - lf - } - }; - for (label, bom, recorded_crlf, live_crlf) in [ - ("crlf", "", true, true), - ("bom+crlf", "\u{feff}", true, true), - ("recorded crlf, reverted lf", "", true, false), - ("recorded lf, reverted crlf", "\u{feff}", false, true), - ] { - let pristine = format!("{bom}{}", respell(&berry_pristine(), recorded_crlf)); - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &pristine).await; - let root = tmp.path(); - let edit = state - .edits - .iter() - .find(|e| e.kind == "redirect_yarn_berry_entry") - .expect("berry edit"); - let recorded = edit.original.as_ref().and_then(Value::as_str).unwrap(); - assert_eq!( - recorded.contains("\r\n"), - recorded_crlf, - "{label}: the ledger records the on-disk endings: {recorded:?}" - ); - let wired = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - tokio::fs::write(root.join("yarn.lock"), respell(&wired, live_crlf)) - .await - .unwrap(); - - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .unwrap_or_else(|e| panic!("{label}: revert must succeed: {e}")); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - format!("{bom}{}", respell(&berry_pristine(), live_crlf)), - "{label}: the pristine lock, in the live file's endings" - ); - assert!(state.edits.is_empty(), "{label}: edits dropped"); - } - - // Mixed live endings: neither the verbatim nor a respelled fragment - // is provable — refuse, touch nothing, keep the ledger. - let pristine = respell(&berry_pristine(), true); - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &pristine).await; - let root = tmp.path(); - let wired = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - let mixed = wired.replacen(" languageName: node\r\n", " languageName: node\n", 1); - assert_ne!(mixed, wired, "the fixture edit must hit"); - tokio::fs::write(root.join("yarn.lock"), &mixed) - .await - .unwrap(); - let edits_before = state.edits.len(); - let err = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("a mixed lock must refuse"); - assert!(err.contains("drifted"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - mixed, - "byte-untouched" - ); - assert_eq!(state.edits.len(), edits_before, "ledger intact"); - } - - #[tokio::test] - async fn npm_package_lock_v2_round_trips_both_trees() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - assert_eq!(state.edits.len(), 2, "packages + dependencies edits"); - let wired = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(wired.contains(NPM_URL), "fixture is hosted-wired: {wired}"); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - package_lock_pristine(), - "package-lock.json restored byte-identical (both trees)" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - fn npmrc_edit(action: &str) -> FileEdit { - FileEdit { - path: ".npmrc".into(), - kind: super::super::npmrc::NPMRC_ALLOW_REMOTE_EDIT_KIND.into(), - action: action.into(), - key: Some("allow-remote".into()), - original: None, - new: Some(serde_json::json!("all")), - } - } - - /// Pristine lockfileVersion 3 package-lock holding two registry deps. - fn two_dep_package_lock() -> String { - let entry = |name: &str, version: &str| { - serde_json::json!({ - "version": version, - "resolved": format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz"), - "integrity": "sha512-pristine==" - }) - }; - let lock = serde_json::json!({ - "name": "app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": entry("left-pad", "1.3.0"), - "node_modules/other": entry("other", "2.0.0"), - } - }); - format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) - } - - /// The `.npmrc` `allow-remote=all` auto-config is unwound in the SAME - /// transaction as the LAST package-lock purl's revert (created file - /// deleted), and never while another package-lock entry still needs it. - #[tokio::test] - async fn npm_revert_unwinds_npmrc_only_when_the_last_lock_entry_goes() { - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &two_dep_package_lock(), - &[ - (NPM_PURL, npm_dep()), - ("pkg:npm/other@2.0.0", npm_dep_for("other", "2.0.0")), - ], - ) - .await; - let root = tmp.path(); - tokio::fs::write(root.join(".npmrc"), "allow-remote=all\n") - .await - .unwrap(); - state.edits.push(npmrc_edit("created")); - - // The last-but-one lock entry goes: .npmrc stays. - let out = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("first revert"); - assert!(!out.reverted_files.iter().any(|f| f == ".npmrc"), "{out:?}"); - assert_eq!( - tokio::fs::read_to_string(root.join(".npmrc")) - .await - .unwrap(), - "allow-remote=all\n", - "still needed by the other package-lock entry" - ); - assert!(state - .edits - .iter() - .any(|e| e.kind == "redirect_npmrc_allow_remote")); - - // Dry run of the last one: previews the removal, writes nothing. - let mut probe = state.clone(); - let out = revert_npm_redirect_purl(root, &mut probe, "pkg:npm/other@2.0.0", true) - .await - .expect("dry-run revert"); - assert!(out.reverted_files.iter().any(|f| f == ".npmrc"), "{out:?}"); - assert!(root.join(".npmrc").exists(), "dry run writes nothing"); - - let out = revert_npm_redirect_purl(root, &mut state, "pkg:npm/other@2.0.0", false) - .await - .expect("last revert"); - assert!(out.reverted_files.iter().any(|f| f == ".npmrc"), "{out:?}"); - assert!( - !root.join(".npmrc").exists(), - "the created .npmrc is deleted" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - two_dep_package_lock() - ); - assert!( - state.edits.is_empty() && state.records.is_empty(), - "{state:?}" - ); - } - - /// An APPENDED line is removed exactly (user bytes, BOM and CRLF kept); - /// an ambiguous duplicate refuses the whole revert byte-untouched. - #[tokio::test] - async fn npm_revert_removes_only_the_appended_npmrc_line() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - let wired = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - state.edits.push(npmrc_edit("added")); - - tokio::fs::write( - root.join(".npmrc"), - "allow-remote=all\r\n; mine\r\nallow-remote=all\r\n", - ) - .await - .unwrap(); - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("ambiguous .npmrc refuses"); - assert!(err.contains("more than once"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - wired, - "a refusal leaves the lock untouched" - ); - - tokio::fs::write( - root.join(".npmrc"), - "\u{feff}registry=https://r.example/\r\nallow-remote=all\r\n", - ) - .await - .unwrap(); - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join(".npmrc")) - .await - .unwrap(), - "\u{feff}registry=https://r.example/\r\n" - ); - assert!(state.edits.is_empty(), "{state:?}"); - } - - /// A symlinked `.npmrc` refuses while planning — never at - /// `flush_npmrc` time, after `flush_staged` had written the reverted - /// lock while the ledger still recorded the redirect: lock - /// byte-identical, ledger untouched. While another - /// package-lock entry still needs the setting, the odd `.npmrc` shape - /// does not block the revert at all (the file is never read). - #[cfg(unix)] - #[tokio::test] - async fn npm_revert_refuses_a_symlinked_npmrc_before_writing_anything() { - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &two_dep_package_lock(), - &[ - (NPM_PURL, npm_dep()), - ("pkg:npm/other@2.0.0", npm_dep_for("other", "2.0.0")), - ], - ) - .await; - let root = tmp.path(); - tokio::fs::write(root.join("shared.npmrc"), "allow-remote=all\n") - .await - .unwrap(); - std::os::unix::fs::symlink("shared.npmrc", root.join(".npmrc")).unwrap(); - state.edits.push(npmrc_edit("created")); - - // Not the last lock entry: the unwind is not due, the link is fine. - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("first revert is not blocked by the .npmrc shape"); - - let wired = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - let before = state.clone(); - let err = revert_npm_redirect_purl(root, &mut state, "pkg:npm/other@2.0.0", false) - .await - .expect_err("symlinked .npmrc refuses the last revert"); - assert!(err.contains("not a regular file"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - wired, - "the lock must not be reverted behind the refusal" - ); - assert_eq!(state.edits.len(), before.edits.len(), "ledger untouched"); - assert_eq!( - state.records.len(), - before.records.len(), - "ledger untouched" - ); - assert!(root - .join(".npmrc") - .symlink_metadata() - .unwrap() - .file_type() - .is_symlink()); - } - - /// Pristine pnpm v6 lock holding a PLAIN instance and a resolved-peer - /// instance of the same purl: the rewriter records one edit per - /// instance, keying the peered one `@(@)`. - fn pnpm_v6_pristine() -> String { - [ - "lockfileVersion: '6.0'", - "", - "dependencies:", - " left-pad:", - " specifier: 1.3.0", - " version: 1.3.0", - "", - "packages:", - "", - " /left-pad@1.3.0:", - " resolution: {integrity: sha512-pristine==}", - " dev: false", - "", - " /left-pad@1.3.0(react@18.2.0):", - " resolution: {integrity: sha512-pristine==}", - " dev: false", - "", - ] - .join("\n") - } - - /// Pristine pnpm v5 lock: same two-instance shape, `/name/version` keys - /// with the peer combination spelled as a `_` (respelled - /// `@_` in the recorded instance key). - fn pnpm_v5_pristine() -> String { - [ - "lockfileVersion: 5.4", - "", - "specifiers:", - " left-pad: 1.3.0", - "", - "dependencies:", - " left-pad: 1.3.0", - "", - "packages:", - "", - " /left-pad/1.3.0:", - " resolution: {integrity: sha512-pristine==}", - " dev: false", - "", - " /left-pad/1.3.0_react@18.2.0:", - " resolution: {integrity: sha512-pristine==}", - " dev: false", - "", - ] - .join("\n") - } - - /// The pnpm rewriter keys a resolved-peer instance's edit - /// `@(@)`, not bare `@` — the - /// takeover claim must cover it. A missed instance is a silent HALF - /// takeover: the plain entry reverts, the record is dropped, the peered - /// edit is stranded in the ledger, and every dependent resolving through - /// the peered instance keeps installing the expiring hosted tarball. - #[tokio::test] - async fn npm_pnpm_v6_peered_instance_takeover_reverts_every_instance() { - let (tmp, mut state) = npm_redirected_fixture("pnpm-lock.yaml", &pnpm_v6_pristine()).await; - let root = tmp.path(); - assert_eq!( - state.edits.len(), - 2, - "plain + peered instance edits: {:?}", - state.edits - ); - let wired = tokio::fs::read_to_string(root.join("pnpm-lock.yaml")) - .await - .unwrap(); - assert_eq!( - wired.matches(NPM_URL).count(), - 2, - "both instances hosted-wired: {wired}" - ); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("pnpm-lock.yaml")) - .await - .unwrap(), - pnpm_v6_pristine(), - "pnpm-lock.yaml restored byte-identical (both instances)" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!( - state.edits.is_empty(), - "no stranded instance edits: {:?}", - state.edits - ); - } - - /// v5 twin of the peered-instance claim: the `_` instance - /// key (`left-pad@1.3.0_react@18.2.0`) must be claimed too. - #[tokio::test] - async fn npm_pnpm_v5_suffixed_instance_takeover_reverts_every_instance() { - let (tmp, mut state) = npm_redirected_fixture("pnpm-lock.yaml", &pnpm_v5_pristine()).await; - let root = tmp.path(); - assert_eq!( - state.edits.len(), - 2, - "plain + suffixed instance edits: {:?}", - state.edits - ); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("pnpm-lock.yaml")) - .await - .unwrap(), - pnpm_v5_pristine(), - "pnpm-lock.yaml restored byte-identical (both instances)" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!( - state.edits.is_empty(), - "no stranded instance edits: {:?}", - state.edits - ); - } - - /// The peered-instance claim is boundary-checked: `left-pad@1.3.0-rc1`'s - /// peered key starts with `left-pad@1.3.0`, but `-` extends the version — - /// taking over 1.3.0 must not claim (and replay) the prerelease sibling's - /// edit. - #[tokio::test] - async fn npm_pnpm_prerelease_sibling_peered_edit_is_not_claimed() { - let rc1_url = - "http://127.0.0.1:5555/patch/npm/left-pad/1.3.0-rc1/tok/6b7c/left-pad-1.3.0-rc1.tgz"; - let lock = format!( - "lockfileVersion: '6.0'\n\npackages:\n\n /left-pad@1.3.0:\n \ - resolution: {{integrity: sha512-h==, tarball: {NPM_URL}}}\n\n \ - /left-pad@1.3.0-rc1(react@18.2.0):\n \ - resolution: {{integrity: sha512-h2==, tarball: {rc1_url}}}\n" - ); - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - tokio::fs::write(root.join("pnpm-lock.yaml"), &lock) - .await - .unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.to_string(), record()); - state - .records - .insert("pkg:npm/left-pad@1.3.0-rc1".to_string(), record()); - state.edits.push(FileEdit { - path: "pnpm-lock.yaml".into(), - kind: "redirect_pnpm_resolution".into(), - action: "rewritten".into(), - key: Some("left-pad@1.3.0".into()), - original: Some(Value::String("{integrity: sha512-p==}".into())), - new: Some(Value::String(format!( - "{{integrity: sha512-h==, tarball: {NPM_URL}}}" - ))), - }); - state.edits.push(FileEdit { - path: "pnpm-lock.yaml".into(), - kind: "redirect_pnpm_resolution".into(), - action: "rewritten".into(), - key: Some("left-pad@1.3.0-rc1(react@18.2.0)".into()), - original: Some(Value::String("{integrity: sha512-p2==}".into())), - new: Some(Value::String(format!( - "{{integrity: sha512-h2==, tarball: {rc1_url}}}" - ))), - }); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of 1.3.0 succeeds without touching 1.3.0-rc1"); - - let lock_after = tokio::fs::read_to_string(root.join("pnpm-lock.yaml")) - .await - .unwrap(); - assert!( - !lock_after.contains(NPM_URL), - "1.3.0 un-hosted: {lock_after}" - ); - assert!( - lock_after.contains(rc1_url), - "1.3.0-rc1 still hosted-wired: {lock_after}" - ); - assert_eq!( - state.edits.len(), - 1, - "the sibling keeps its edit: {:?}", - state.edits - ); - assert!( - state.records.contains_key("pkg:npm/left-pad@1.3.0-rc1") - && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - } - - /// Pristine package-lock (lockfileVersion 2, both trees) holding TWO - /// versions of left-pad — the sibling-purl fixture the claim matcher - /// must not cross-claim. - fn two_version_lock_pristine() -> String { - let lp = |v: &str| { - serde_json::json!({ - "version": v, - "resolved": format!("https://registry.npmjs.org/left-pad/-/left-pad-{v}.tgz"), - "integrity": format!("sha512-pristine-{v}=="), - }) - }; - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/a": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz", - "integrity": "sha512-a==" - }, - "node_modules/a/node_modules/left-pad": lp("1.2.0"), - "node_modules/left-pad": lp("1.3.0"), - }, - "dependencies": { - "a": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/a/-/a-1.0.0.tgz", - "integrity": "sha512-a==", - "dependencies": { "left-pad": lp("1.2.0") } - }, - "left-pad": lp("1.3.0"), - } - }); - format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) - } - - /// Two hosted-redirected VERSIONS of the same package: taking over one - /// purl must not claim (and silently un-host) the sibling's lock edits — - /// the package-lock JSON edit keys carry no version, so a name-only - /// matcher replays the sibling's `original` back over its live hosted - /// wiring and drops its edits while its ledger record survives edit-less. - #[tokio::test] - async fn npm_two_versions_takeover_of_one_leaves_the_siblings_redirect_intact() { - let sibling_purl = "pkg:npm/left-pad@1.2.0"; - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &two_version_lock_pristine(), - &[ - (NPM_PURL, npm_dep()), - (sibling_purl, npm_dep_for("left-pad", "1.2.0")), - ], - ) - .await; - let root = tmp.path(); - // 2 edits per purl: one v3 `packages` entry + one v2 `dependencies` - // node each. - assert_eq!(state.edits.len(), 4, "{:?}", state.edits); - let sibling_url = npm_dep_for("left-pad", "1.2.0").artifact_url.clone(); - let wired = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(wired.contains(NPM_URL) && wired.contains(&sibling_url)); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of 1.3.0 succeeds without touching 1.2.0"); - - let lock = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(!lock.contains(NPM_URL), "1.3.0 un-hosted: {lock}"); - assert!( - lock.contains("left-pad/-/left-pad-1.3.0.tgz"), - "1.3.0 back on the registry: {lock}" - ); - assert_eq!( - lock.matches(&sibling_url).count(), - 2, - "1.2.0 still hosted-wired in BOTH trees: {lock}" - ); - assert!( - state.records.contains_key(sibling_purl) && !state.records.contains_key(NPM_PURL), - "only 1.3.0's record dropped: {:?}", - state.records.keys() - ); - assert_eq!( - state.edits.len(), - 2, - "1.2.0 keeps its two edits: {:?}", - state.edits - ); - - // The sibling's own takeover still round-trips the file to pristine. - revert_npm_redirect_purl(root, &mut state, sibling_purl, false) - .await - .expect("takeover of 1.2.0 succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - two_version_lock_pristine(), - "package-lock.json restored byte-identical" - ); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// `npm i left-pad@npm:other` keys package `other` under the lock path - /// `node_modules/left-pad`: taking over left-pad must not claim that - /// entry's edit through the key name (the entry's `name` field exonerates - /// it, exactly as the rewriter matched), while an alias install OF - /// left-pad (`npm i mylp@npm:left-pad`) must still be claimed through - /// the `name` field. - #[tokio::test] - async fn npm_alias_collision_takeover_claims_by_entry_name_not_key_path() { - let other_purl = "pkg:npm/other@1.3.0"; - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - // Alias of ANOTHER package onto this key path — same version - // on purpose, so only the name field can exonerate it. - "node_modules/left-pad": { - "name": "other", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/other/-/other-1.3.0.tgz", - "integrity": "sha512-pristine-other==" - }, - // Alias OF the target package: claimed via the name field. - "node_modules/mylp": { - "name": "left-pad", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-1.3.0==" - }, - "node_modules/b/node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-1.3.0==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &pristine, - &[ - (NPM_PURL, npm_dep()), - (other_purl, npm_dep_for("other", "1.3.0")), - ], - ) - .await; - let root = tmp.path(); - 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, false) - .await - .expect("takeover of left-pad succeeds without touching `other`"); - - let lock = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!( - !lock.contains(NPM_URL), - "both left-pad entries (path-keyed AND alias-keyed) un-hosted: {lock}" - ); - assert!( - lock.contains(&other_url), - "`other` (aliased onto node_modules/left-pad) still hosted-wired: {lock}" - ); - assert!( - state.records.contains_key(other_purl) && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - assert_eq!( - state.edits.len(), - 1, - "other keeps its edit: {:?}", - state.edits - ); - - revert_npm_redirect_purl(root, &mut state, other_purl, false) - .await - .expect("takeover of other succeeds"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - pristine, - "package-lock.json restored byte-identical" - ); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// The version-scoped claim must not soften the fail-closed contract: a - /// lock entry that VANISHED after being redirected still refuses (its - /// edit is attributed by key path + recorded URLs), never a silent - /// record-drop that strands the edit. - #[tokio::test] - async fn npm_missing_lock_entry_still_fails_closed() { - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture("package-lock.json", &pristine).await; - let root = tmp.path(); - // A third party pruned the entry from the lock after the redirect. - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("packages") - .and_then(Value::as_object_mut) - .unwrap() - .remove("node_modules/left-pad") - .expect("fixture entry present"); - tokio::fs::write( - root.join("package-lock.json"), - serde_json::to_string_pretty(&on_disk).unwrap(), - ) - .await - .unwrap(); - let edits_before = state.edits.len(); - - 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}"); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - } - - /// `npm i mylp@npm:left-pad` records an edit keyed by the ALIAS lock path - /// (`node_modules/mylp`); after `npm uninstall mylp` regenerates the lock - /// without that entry, the takeover must still attribute the edit to this - /// purl (via its recorded URLs — the key path says "mylp") and refuse - /// fail-closed exactly like the path-keyed vanished entry above — never - /// report success with the alias edit stranded in the ledger behind a - /// dropped record (half a takeover). - #[tokio::test] - async fn npm_vanished_alias_keyed_entry_fails_closed_not_half_takeover() { - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine==" - }, - // Alias install OF the target package: the rewriter matches - // it via the `name` field and keys its edit by this path. - "node_modules/mylp": { - "name": "left-pad", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture("package-lock.json", &pristine).await; - let root = tmp.path(); - assert_eq!( - state.edits.len(), - 2, - "path-keyed + alias-keyed edits: {:?}", - state.edits - ); - // `npm uninstall mylp` regenerated the lock: the alias entry is gone, - // the surviving entry keeps the hosted `resolved`. - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("packages") - .and_then(Value::as_object_mut) - .unwrap() - .remove("node_modules/mylp") - .expect("fixture alias entry present"); - let on_disk_text = serde_json::to_string_pretty(&on_disk).unwrap(); - tokio::fs::write(root.join("package-lock.json"), &on_disk_text) - .await - .unwrap(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("vanished alias-keyed entry must refuse, not strand its edit"); - assert!(err.contains("no longer exists"), "{err}"); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - on_disk_text, - "nothing reached disk on refusal" - ); - } - - /// `npm i left-pad@npm:other` keys package `other` under - /// `node_modules/left-pad`; a hand edit strips BOTH the `name` and - /// `version` fields from that live entry. Taking over left-pad must not - /// claim `other`'s edit through a version-only URL fallback — the entry's - /// live values ARE that edit's `new` values, so the replay would NOT fail - /// closed: `other` would be silently un-hosted and its edit dropped while - /// its record survives edit-less. - #[tokio::test] - async fn npm_version_gone_fallback_does_not_claim_alias_collision_sibling() { - let other_purl = "pkg:npm/other@1.3.0"; - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - // Alias of ANOTHER package onto this key path — same version - // on purpose. - "node_modules/left-pad": { - "name": "other", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/other/-/other-1.3.0.tgz", - "integrity": "sha512-pristine-other==" - }, - // The real target package, nested. - "node_modules/b/node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-1.3.0==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &pristine, - &[ - (NPM_PURL, npm_dep()), - (other_purl, npm_dep_for("other", "1.3.0")), - ], - ) - .await; - let root = tmp.path(); - assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - let other_url = npm_dep_for("other", "1.3.0").artifact_url.clone(); - // Hand edit / merge artifact: strip the alias entry's name+version. - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - let entry = on_disk - .get_mut("packages") - .and_then(|p| p.get_mut("node_modules/left-pad")) - .and_then(Value::as_object_mut) - .unwrap(); - entry.remove("name").expect("fixture name field present"); - entry - .remove("version") - .expect("fixture version field present"); - tokio::fs::write( - root.join("package-lock.json"), - serde_json::to_string_pretty(&on_disk).unwrap(), - ) - .await - .unwrap(); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of left-pad succeeds without touching `other`"); - - let lock = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(!lock.contains(NPM_URL), "left-pad un-hosted: {lock}"); - assert!( - lock.contains(&other_url), - "`other` (aliased onto node_modules/left-pad, fields stripped) \ - still hosted-wired: {lock}" - ); - assert!( - state.records.contains_key(other_purl) && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - assert_eq!( - state.edits.len(), - 1, - "other keeps its edit: {:?}", - state.edits - ); - } - - /// Narrowing guard for the version-gone fallback: with only the `version` - /// field hand-stripped from the target's own live entry, the recorded - /// URLs name this exact package+version, so the takeover still claims and - /// reverts it rather than stranding the edit. - #[tokio::test] - async fn npm_version_stripped_target_entry_is_still_claimed_via_recorded_urls() { - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture("package-lock.json", &pristine).await; - let root = tmp.path(); - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("packages") - .and_then(|p| p.get_mut("node_modules/left-pad")) - .and_then(Value::as_object_mut) - .unwrap() - .remove("version") - .expect("fixture version field present"); - tokio::fs::write( - root.join("package-lock.json"), - serde_json::to_string_pretty(&on_disk).unwrap(), - ) - .await - .unwrap(); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - let lock = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(!lock.contains(NPM_URL), "left-pad un-hosted: {lock}"); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// Scope-blindness guard for the version-gone URL fallback: `npm i - /// left-pad@npm:@scope/left-pad` keys the SCOPED fork under - /// `node_modules/left-pad`, and its registry URL - /// (`…/@scope/left-pad/-/left-pad-1.3.0.tgz`) embeds both `/left-pad/` - /// (the slash closing `@scope`) and the bare `left-pad-1.3.0.tgz` - /// basename. With the entry's `name`+`version` hand-stripped, taking - /// over unscoped left-pad must not claim the scoped sibling's edit - /// through those substrings — the entry's live values ARE that edit's - /// `new` values, so the replay would NOT fail closed: @scope/left-pad - /// would be silently un-hosted and its edit dropped while its record - /// survives edit-less. - #[tokio::test] - async fn npm_version_gone_fallback_does_not_claim_scoped_sibling_of_same_bare_name() { - let scoped_purl = "pkg:npm/@scope/left-pad@1.3.0"; - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - // The scoped fork aliased onto the bare key path — same bare - // name AND version on purpose. - "node_modules/left-pad": { - "name": "@scope/left-pad", - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@scope/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-scoped==" - }, - // The real target package, nested. - "node_modules/b/node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-1.3.0==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &pristine, - &[ - (NPM_PURL, npm_dep()), - (scoped_purl, npm_dep_for("@scope/left-pad", "1.3.0")), - ], - ) - .await; - let root = tmp.path(); - assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0").artifact_url.clone(); - // Hand edit / merge artifact: strip the alias entry's name+version. - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - let entry = on_disk - .get_mut("packages") - .and_then(|p| p.get_mut("node_modules/left-pad")) - .and_then(Value::as_object_mut) - .unwrap(); - entry.remove("name").expect("fixture name field present"); - entry - .remove("version") - .expect("fixture version field present"); - tokio::fs::write( - root.join("package-lock.json"), - serde_json::to_string_pretty(&on_disk).unwrap(), - ) - .await - .unwrap(); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of left-pad succeeds without touching @scope/left-pad"); - - let lock = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(!lock.contains(NPM_URL), "left-pad un-hosted: {lock}"); - assert!( - lock.contains(&scoped_url), - "@scope/left-pad (aliased onto node_modules/left-pad, fields \ - stripped) still hosted-wired: {lock}" - ); - assert!( - state.records.contains_key(scoped_purl) && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - assert_eq!( - state.edits.len(), - 1, - "the scoped sibling keeps its edit: {:?}", - state.edits - ); - } - - /// Fail-closed-direction twin of the scoped-sibling guard: with - /// @scope/left-pad installed at its own scoped path and then - /// uninstalled (its entry vanished, its edit orphaned in the ledger), - /// taking over UNSCOPED left-pad@1.3.0 must not claim the orphan - /// through the URL fallback — claiming it refuses with "entry - /// `node_modules/@scope/left-pad` … no longer exists", a spurious - /// permanent refusal for a purl whose own wiring is intact. - #[tokio::test] - async fn npm_vanished_scoped_sibling_entry_does_not_block_the_unscoped_takeover() { - let scoped_purl = "pkg:npm/@scope/left-pad@1.3.0"; - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-1.3.0==" - }, - "node_modules/@scope/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@scope/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-pristine-scoped==" - }, - }, - }); - let pristine = format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()); - let (tmp, mut state) = npm_redirected_fixture_multi( - "package-lock.json", - &pristine, - &[ - (NPM_PURL, npm_dep()), - (scoped_purl, npm_dep_for("@scope/left-pad", "1.3.0")), - ], - ) - .await; - let root = tmp.path(); - assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - // `npm uninstall @scope/left-pad` regenerated the lock without the - // scoped entry; the ledger still holds its edit. - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("packages") - .and_then(Value::as_object_mut) - .unwrap() - .remove("node_modules/@scope/left-pad") - .expect("fixture scoped entry present"); - tokio::fs::write( - root.join("package-lock.json"), - serde_json::to_string_pretty(&on_disk).unwrap(), - ) - .await - .unwrap(); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of left-pad succeeds despite the scoped orphan edit"); - - let lock = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!(!lock.contains(NPM_URL), "left-pad un-hosted: {lock}"); - assert!( - state.records.contains_key(scoped_purl) && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - assert_eq!( - state.edits.len(), - 1, - "the scoped orphan edit survives for its own takeover: {:?}", - state.edits - ); - assert_eq!( - state.edits[0].key.as_deref(), - Some("node_modules/@scope/left-pad"), - "{:?}", - state.edits - ); - } - - #[tokio::test] - async fn npm_refuses_on_drifted_lock_fail_closed() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - // A third party re-resolved the entry to a shape the ledger never saw. - let drifted = classic_pristine().replace( - "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a", - "https://corp.example/left-pad-1.3.0.tgz#dead", - ); - tokio::fs::write(root.join("yarn.lock"), &drifted) - .await - .unwrap(); - let records_before = state.records.len(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("drifted lock must refuse"); - assert!(err.contains("drifted"), "{err}"); - // The ledger keeps everything on refusal, and the file is untouched. - assert_eq!(state.records.len(), records_before); - assert_eq!(state.edits.len(), edits_before); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - drifted - ); - } - - #[tokio::test] - 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, false) - .await - .expect_err("no record"); - assert!(err.contains("records no hosted redirect"), "{err}"); - } - - // ── bun ────────────────────────────────────────────────────────────── - - /// Real text-lock grammar (bun 1.4.2 matrix capture, lockfileVersion 2; - /// the `packages` tuple grammar is identical on 0/1/2): the root - /// `left-pad@1.3.0` registry 4-tuple, a nested `haspad/left-pad` - /// instance at the SIBLING version 1.2.0, and an unrelated `other`. - fn bun_pristine() -> String { - r#"{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "takeover-fixture", - "dependencies": { - "haspad": "1.0.0", - "left-pad": "1.3.0", - "other": "1.0.0", - }, - }, - }, - "packages": { - "haspad": ["haspad@1.0.0", "", { "dependencies": { "left-pad": "^1.2.0" } }, "sha512-hh=="], - - "left-pad": ["left-pad@1.3.0", "", {}, "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="], - - "other": ["other@1.0.0", "", {}, "sha512-oo=="], - - "haspad/left-pad": ["left-pad@1.2.0", "", {}, "sha512-OQadpCyFCT/VLniZQgym8d3/ofIJtuZyw2ibsVeIUOexKgW/osn8+mMFJbwGMPeDC4GnLzD8q115WPCDx4YRWg=="], - } -} -"# - .to_string() - } - - /// The packages-entry line keyed `key` (verbatim, without its line - /// terminator). - fn bun_line(lock: &str, key: &str) -> String { - let prefix = format!(" \"{key}\": ["); - lock.split('\n') - .find(|l| l.starts_with(&prefix)) - .unwrap_or_else(|| panic!("no `{key}` entry in:\n{lock}")) - .trim_end_matches('\r') - .to_string() - } - - async fn read_lock(root: &Path) -> String { - tokio::fs::read_to_string(root.join("bun.lock")) - .await - .unwrap() - } - - /// The takeover claims the purl's `redirect_bun_lock_package` edit - /// by the recorded line's spec and replays the registry line back, - /// leaving the sibling-version and foreign entries untouched. - #[tokio::test] - async fn npm_bun_lock_takeover_restores_the_registry_line_and_drops_the_ledger() { - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; - let root = tmp.path(); - let wired = read_lock(root).await; - assert!(wired.contains(NPM_URL), "fixture is hosted-wired:\n{wired}"); - assert_eq!( - state - .edits - .iter() - .filter(|e| e.kind == BUN_TEXT_KIND) - .count(), - 1, - "one bun edit for the one 1.3.0 instance: {:?}", - state.edits - ); - - let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("bun takeover revert succeeds"); - assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); - assert_eq!( - read_lock(root).await, - bun_pristine(), - "bun.lock restored byte-identical" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edit consumed"); - } - - /// (a) A hosted edit for ANOTHER VERSION of the same package (the nested - /// `haspad/left-pad` at 1.2.0) is not this purl's to claim: reverting - /// 1.3.0 leaves the 1.2.0 line hosted and its record + edit in the - /// ledger. - #[tokio::test] - async fn npm_bun_sibling_version_edit_is_neither_claimed_nor_a_refusal() { - const SIBLING: &str = "pkg:npm/left-pad@1.2.0"; - let (tmp, mut state) = npm_redirected_fixture_multi( - "bun.lock", - &bun_pristine(), - &[ - (NPM_PURL, npm_dep()), - (SIBLING, npm_dep_for("left-pad", "1.2.0")), - ], - ) - .await; - let root = tmp.path(); - let wired = read_lock(root).await; - let sibling_line = bun_line(&wired, "haspad/left-pad"); - assert!( - sibling_line.contains("/left-pad/1.2.0/") - && sibling_line.contains("left-pad-1.2.0.tgz"), - "sibling instance is hosted-wired too: {sibling_line}" - ); - assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of 1.3.0 succeeds"); - let after = read_lock(root).await; - assert_eq!( - bun_line(&after, "left-pad"), - bun_line(&bun_pristine(), "left-pad"), - "the 1.3.0 line is back to its registry tuple" - ); - assert_eq!( - bun_line(&after, "haspad/left-pad"), - sibling_line, - "the sibling version's hosted line is untouched" - ); - assert_eq!(state.edits.len(), 1, "{:?}", state.edits); - assert_eq!(state.edits[0].key.as_deref(), Some("haspad/left-pad")); - assert!( - state.records.contains_key(SIBLING) && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - } - - /// The digest-less spelling Bun 1.1.39–1.3.9 re-save a URL 3-tuple as - /// (`bun add`, `bun install` after a manifest change): the recorded - /// `new` is no longer on disk byte-for-byte, but the 2-tuple with the - /// same key/spec/meta IS our wiring — the claim must not refuse as - /// drift (that would block hosted→vendored takeover, scoped `rollback` - /// and `remove` for every user on those releases). The registry line comes - /// back and the ledger is cleared. - fn drop_digest(line: &str) -> String { - let cut = line - .rfind(", \"sha512-") - .unwrap_or_else(|| panic!("no sha512 element in {line}")); - let tail = if line.trim_end_matches('\r').ends_with("],") { - "]," - } else { - "]" - }; - let cr = if line.ends_with('\r') { "\r" } else { "" }; - format!("{}{tail}{cr}", &line[..cut]) - } - - #[tokio::test] - async fn npm_bun_digestless_live_line_is_claimed_and_restored() { - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; - let root = tmp.path(); - let wired = read_lock(root).await; - let wired_line = bun_line(&wired, "left-pad"); - let digestless = drop_digest(&wired_line); - assert!( - digestless.ends_with("{}],") && !digestless.contains("sha512"), - "{digestless}" - ); - tokio::fs::write( - root.join("bun.lock"), - wired.replace(&wired_line, &digestless), - ) - .await - .unwrap(); - - let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("the digest-less spelling of our own wiring must not refuse"); - assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); - assert_eq!( - read_lock(root).await, - bun_pristine(), - "registry line restored" - ); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// Both hosted instances re-saved digest-less; reverting 1.3.0 restores - /// ONLY its line — the sibling 1.2.0 keeps its digest-less hosted - /// 2-tuple untouched (it is that purl's wiring, not ours to heal). - #[tokio::test] - async fn npm_bun_digestless_sibling_stays_untouched() { - const SIBLING: &str = "pkg:npm/left-pad@1.2.0"; - let (tmp, mut state) = npm_redirected_fixture_multi( - "bun.lock", - &bun_pristine(), - &[ - (NPM_PURL, npm_dep()), - (SIBLING, npm_dep_for("left-pad", "1.2.0")), - ], - ) - .await; - let root = tmp.path(); - let wired = read_lock(root).await; - let main_line = bun_line(&wired, "left-pad"); - let sibling_line = bun_line(&wired, "haspad/left-pad"); - let sibling_digestless = drop_digest(&sibling_line); - let live = wired - .replace(&main_line, &drop_digest(&main_line)) - .replace(&sibling_line, &sibling_digestless); - tokio::fs::write(root.join("bun.lock"), &live) - .await - .unwrap(); - - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover of 1.3.0 succeeds"); - let after = read_lock(root).await; - assert_eq!( - bun_line(&after, "left-pad"), - bun_line(&bun_pristine(), "left-pad") - ); - assert_eq!( - bun_line(&after, "haspad/left-pad"), - sibling_digestless, - "the sibling's digest-less hosted line is left exactly as found" - ); - assert_eq!(state.edits.len(), 1); - assert!(state.records.contains_key(SIBLING) && !state.records.contains_key(NPM_PURL)); - } - - /// A digest-less 2-tuple at ANOTHER uuid (someone re-granted the patch - /// and Bun re-saved it) is not the recorded wiring: still drift, still - /// a refusal, file byte-identical. - #[tokio::test] - async fn npm_bun_digestless_line_at_another_uuid_still_refuses() { - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; - let root = tmp.path(); - let wired = read_lock(root).await; - let wired_line = bun_line(&wired, "left-pad"); - let foreign = drop_digest(&wired_line).replace("/6b7c/", "/7c8d/"); - assert_ne!( - foreign, - drop_digest(&wired_line), - "the uuid segment must differ" - ); - let live = wired.replace(&wired_line, &foreign); - tokio::fs::write(root.join("bun.lock"), &live) - .await - .unwrap(); - - let err = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("another uuid's digest-less line is drift"); - assert!(err.contains("drifted"), "{err}"); - assert_eq!( - read_lock(root).await, - live, - "refusal leaves the lock untouched" - ); - assert_eq!(state.edits.len(), 1); - assert!(state.records.contains_key(NPM_PURL)); - } - - /// (b) Scoped package + re-redirect chain: the hosted rewrite destroys - /// the `@scope/pkg@1.0.0` spec, so the SECOND redirect (a rotated - /// artifact URL) records a hosted-URL line as its `original`. That edit - /// is claimed through the URL's tarball leaf (`pkg-1.0.0.tgz` — the - /// scope is a path level, not part of the basename) plus the full - /// scoped name in the spec; `@other/pkg` and bare `pkg`, whose leaves - /// are identical, stay hosted. Both links unwind newest-first to the - /// registry line. - #[tokio::test] - async fn npm_bun_scoped_package_claims_the_re_redirect_chain_by_spec_and_leaf() { - const SCOPED: &str = "pkg:npm/%40scope/pkg@1.0.0"; - const OTHER_SCOPE: &str = "pkg:npm/%40other/pkg@1.0.0"; - const BARE: &str = "pkg:npm/pkg@1.0.0"; - let pristine = r#"{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "scoped-fixture", - "dependencies": { - "@other/pkg": "1.0.0", - "@scope/pkg": "1.0.0", - "pkg": "1.0.0", - }, - }, - }, - "packages": { - "@other/pkg": ["@other/pkg@1.0.0", "", {}, "sha512-o1=="], - - "@scope/pkg": ["@scope/pkg@1.0.0", "", {}, "sha512-s1=="], - - "pkg": ["pkg@1.0.0", "", {}, "sha512-p1=="], - } -} -"#; - let (tmp, mut state) = npm_redirected_fixture_multi( - "bun.lock", - pristine, - &[ - (SCOPED, npm_dep_for("@scope/pkg", "1.0.0")), - (OTHER_SCOPE, npm_dep_for("@other/pkg", "1.0.0")), - (BARE, npm_dep_for("pkg", "1.0.0")), - ], - ) - .await; - let root = tmp.path(); - let first = read_lock(root).await; - let first_scoped_line = bun_line(&first, "@scope/pkg"); - assert!( - first_scoped_line.contains("/@scope/pkg/1.0.0/"), - "{first_scoped_line}" - ); - - // Second redirect of ONLY @scope/pkg with a rotated artifact URL - // (same origin + leaf, different uuid path segment): the rewriter's - // prior-hosted match re-pins it and records the chain link. - let mut rotated = npm_dep_for("@scope/pkg", "1.0.0"); - rotated.artifact_url = rotated.artifact_url.replace("/6b7c/", "/7c8d/"); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("bun.lock".into(), first.clone()); - let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[rotated]); - let second = rewrite - .files - .get("bun.lock") - .unwrap_or_else(|| panic!("re-redirect must rewrite: {:?}", rewrite.warnings)) - .clone(); - assert!( - bun_line(&second, "@scope/pkg").contains("/7c8d/"), - "{second}" - ); - tokio::fs::write(root.join("bun.lock"), &second) - .await - .unwrap(); - state.edits.extend(rewrite.edits); - assert_eq!(state.edits.len(), 4, "{:?}", state.edits); - let chain_link = state.edits.last().unwrap(); - assert!( - chain_link.original.as_ref().and_then(Value::as_str) - == Some(first_scoped_line.as_str()), - "the chain link's original is the PRIOR hosted line: {chain_link:?}" - ); - - revert_redirect_purl(root, &mut state, "pkg:npm/@scope/pkg@1.0.0", false) - .await - .expect("scoped takeover succeeds"); - let after = read_lock(root).await; - assert_eq!( - bun_line(&after, "@scope/pkg"), - bun_line(pristine, "@scope/pkg"), - "both chain links unwound to the registry tuple" - ); - assert_eq!( - bun_line(&after, "@other/pkg"), - bun_line(&first, "@other/pkg"), - "same-leaf scoped sibling stays hosted" - ); - assert_eq!( - bun_line(&after, "pkg"), - bun_line(&first, "pkg"), - "same-leaf bare sibling stays hosted" - ); - assert_eq!(state.edits.len(), 2, "{:?}", state.edits); - assert!( - state - .edits - .iter() - .all(|e| matches!(e.key.as_deref(), Some("@other/pkg") | Some("pkg"))), - "{:?}", - state.edits - ); - assert!( - !state.records.contains_key(SCOPED) - && state.records.contains_key(OTHER_SCOPE) - && state.records.contains_key(BARE), - "{:?}", - state.records.keys() - ); - } - - /// The claim rule in isolation: registry spec by exact version, hosted - /// URL spec by exact tarball leaf, full (scoped) name in every case; - /// anything else — sibling versions, local vendored paths, workspace - /// specs, URLs without a path — is not ours. - #[test] - fn bun_spec_names_discriminates_name_and_version() { - assert!(bun_spec_names("left-pad@1.3.0", "left-pad", "1.3.0")); - assert!(!bun_spec_names("left-pad@1.3.0-rc1", "left-pad", "1.3.0")); - assert!(!bun_spec_names("left-pad@11.3.0", "left-pad", "1.3.0")); - assert!(!bun_spec_names("left-pad@1.3.0", "other", "1.3.0")); - assert!(bun_spec_names( - &format!("left-pad@{NPM_URL}"), - "left-pad", - "1.3.0" - )); - assert!(!bun_spec_names( - "left-pad@http://127.0.0.1:5555/p/left-pad-11.3.0.tgz", - "left-pad", - "1.3.0" - )); - assert!(!bun_spec_names( - "left-pad@http://127.0.0.1:5555/p/left-pad-1.3.0-rc1.tgz", - "left-pad", - "1.3.0" - )); - // Vendored local path, workspace and origin-only specs are never - // hosted redirects. - assert!(!bun_spec_names( - "left-pad@.socket/vendor/npm/6b7c/left-pad-1.3.0.tgz", - "left-pad", - "1.3.0" - )); - assert!(!bun_spec_names( - "left-pad@workspace:packages/left-pad", - "left-pad", - "1.3.0" - )); - assert!(!bun_spec_names( - "left-pad@https://patch.socket.dev", - "left-pad", - "1.3.0" - )); - // Scoped: the leaf is the BARE basename whether the URL keeps the - // scope as a path level (production, test fixtures) or not; the - // full scoped name must match the spec's name. - assert!(bun_spec_names( - "@scope/pkg@https://patch.socket.dev/patch/npm/@scope/pkg/1.0.0/t/u/pkg-1.0.0.tgz", - "@scope/pkg", - "1.0.0" - )); - assert!(bun_spec_names( - "@scope/pkg@http://127.0.0.1:5555/patch/npm/@scope/pkg/1.0.0/tok/6b7c/@scope/pkg-1.0.0.tgz", - "@scope/pkg", - "1.0.0" - )); - assert!(!bun_spec_names( - "@other/pkg@https://h/patch/npm/@other/pkg/1.0.0/t/u/pkg-1.0.0.tgz", - "@scope/pkg", - "1.0.0" - )); - assert!(!bun_spec_names( - "pkg@https://h/patch/npm/pkg/1.0.0/t/u/pkg-1.0.0.tgz", - "@scope/pkg", - "1.0.0" - )); - assert!(bun_spec_names("@scope/pkg@1.0.0", "@scope/pkg", "1.0.0")); - } - - /// (c) Drift: the line was re-resolved by a third party since the - /// redirect (neither the hosted nor the registry line is present) — - /// the same fail-closed refusal the yarn/pnpm text kinds give, with the - /// file and ledger left exactly as found. - #[tokio::test] - async fn npm_bun_drifted_line_refuses_fail_closed() { - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; - let root = tmp.path(); - let wired = read_lock(root).await; - let drifted = wired.replace( - &bun_line(&wired, "left-pad"), - " \"left-pad\": [\"left-pad@https://corp.example/mirror/left-pad-1.3.0.tgz\", {}, \"sha512-corp==\"],", - ); - assert_ne!(drifted, wired); - tokio::fs::write(root.join("bun.lock"), &drifted) - .await - .unwrap(); - let records_before = state.records.len(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("drifted line must refuse"); - assert!(err.contains("drifted"), "{err}"); - assert!(err.contains("bun.lock"), "{err}"); - assert_eq!(read_lock(root).await, drifted, "file untouched"); - assert_eq!(state.records.len(), records_before); - assert_eq!(state.edits.len(), edits_before); - } - - /// (d) CRLF lock: the recorded lines carry (or, for a rewriter that - /// normalized the rewritten line, lack) a trailing `\r`; the whole-line - /// replace restores the pristine CRLF bytes either way. - #[tokio::test] - async fn npm_bun_crlf_lock_round_trips_byte_exact() { - let pristine = bun_pristine().replace('\n', "\r\n"); - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &pristine).await; - let root = tmp.path(); - let wired = read_lock(root).await; - assert!(wired.contains(NPM_URL), "{wired}"); - assert!( - wired.contains("\r\n"), - "CRLF preserved elsewhere: {wired:?}" - ); - - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("CRLF takeover succeeds"); - assert_eq!( - read_lock(root).await, - pristine, - "CRLF bun.lock restored byte-identical" - ); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// (e) Two hosted records (different packages): the takeover of one - /// restores only its line and keeps the other purl's record + edit — - /// the state a scoped `rollback ` / `remove ` needs. - #[tokio::test] - async fn npm_bun_two_hosted_records_takeover_of_one_leaves_the_other_hosted() { - const OTHER: &str = "pkg:npm/other@1.0.0"; - let (tmp, mut state) = npm_redirected_fixture_multi( - "bun.lock", - &bun_pristine(), - &[ - (NPM_PURL, npm_dep()), - (OTHER, npm_dep_for("other", "1.0.0")), - ], - ) - .await; - let root = tmp.path(); - let wired = read_lock(root).await; - let other_line = bun_line(&wired, "other"); - assert!(other_line.contains("other-1.0.0.tgz"), "{other_line}"); - - let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover succeeds"); - assert_eq!(out.reverted_files, vec!["bun.lock".to_string()]); - let after = read_lock(root).await; - assert_eq!( - bun_line(&after, "left-pad"), - bun_line(&bun_pristine(), "left-pad") - ); - assert_eq!(bun_line(&after, "other"), other_line, "other stays hosted"); - assert_eq!(state.edits.len(), 1); - assert_eq!(state.edits[0].key.as_deref(), Some("other")); - assert_eq!(state.records.len(), 1); - assert!(state.records.contains_key(OTHER)); - - // Taking over the second one finishes the job. - revert_redirect_purl(root, &mut state, OTHER, false) - .await - .expect("second takeover succeeds"); - assert_eq!(read_lock(root).await, bun_pristine()); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// bun's dry run mirrors the cargo/yarn contract: every inverse and - /// drift check resolves, nothing reaches disk, the in-memory ledger is - /// claimed, and the preview names the files a wet run rewrites. - #[tokio::test] - async fn npm_bun_dry_run_previews_without_touching_disk() { - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; - let root = tmp.path(); - let wired = read_lock(root).await; - - let dry = revert_redirect_purl(root, &mut state, NPM_PURL, true) - .await - .expect("dry-run revert succeeds"); - assert_eq!(dry.reverted_files, vec!["bun.lock".to_string()]); - assert_eq!(read_lock(root).await, wired, "disk untouched"); - assert!(state.records.is_empty(), "record claimed in memory"); - assert!(state.edits.is_empty(), "edit claimed in memory"); - } - - /// A hand-edited ledger whose bun fragments mention the package but are - /// not entry lines cannot be attributed: refuse with the WORKING remedy - /// (the whole-ledger `rollback` replay) — never `bun install`, which - /// keeps a hosted URL tuple byte-identically — and keep the ledger. - #[tokio::test] - async fn npm_bun_unparseable_edit_mentioning_the_package_refuses_with_the_rollback_remedy() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - state.records.insert(NPM_PURL.to_string(), record()); - state.edits.push(FileEdit { - path: "bun.lock".into(), - kind: BUN_TEXT_KIND.into(), - action: "rewritten".into(), - key: Some("left-pad".into()), - original: Some(Value::String("\"left-pad@1.3.0\" (truncated".into())), - new: Some(Value::String(format!("\"left-pad@{NPM_URL}\" (truncated"))), - }); - let err = revert_npm_redirect_purl(tmp.path(), &mut state, NPM_PURL, false) - .await - .expect_err("undecidable bun edit must refuse"); - assert!(err.contains("unscoped `socket-patch rollback`"), "{err}"); - assert!(err.contains("do not edit"), "{err}"); - assert!(!err.contains("bun install"), "{err}"); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert!(!state.edits.is_empty(), "ledger keeps the edit"); - } - - /// An alias install (`bun add alias@npm:left-pad@1.3.0`) keys the entry - /// by the alias; the rewriter matched it by spec, and so does the - /// claim. - #[tokio::test] - async fn npm_bun_alias_keyed_instance_is_claimed_by_spec() { - let pristine = r#"{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "alias-fixture", - "dependencies": { - "alias": "npm:left-pad@1.3.0", - }, - }, - }, - "packages": { - "alias": ["left-pad@1.3.0", "", {}, "sha512-XI5M=="], - } -} -"#; - let (tmp, mut state) = npm_redirected_fixture("bun.lock", pristine).await; - let root = tmp.path(); - assert_eq!(state.edits[0].key.as_deref(), Some("alias")); - revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("alias takeover succeeds"); - assert_eq!(read_lock(root).await, pristine); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - /// A user hand-restored bun.lock (git checkout): the revert is a clean - /// no-op that still drops the ledger entries. - #[tokio::test] - async fn npm_bun_hand_restored_lock_is_a_noop_that_drops_the_ledger() { - let (tmp, mut state) = npm_redirected_fixture("bun.lock", &bun_pristine()).await; - let root = tmp.path(); - tokio::fs::write(root.join("bun.lock"), bun_pristine()) - .await - .unwrap(); - let out = revert_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert!(out.reverted_files.is_empty(), "{:?}", out.reverted_files); - assert_eq!(read_lock(root).await, bun_pristine()); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - - // ── refusal / degenerate arms of the fail-closed contract ──────────── - - #[tokio::test] - async fn unsupported_ecosystem_purl_is_refused() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - let err = revert_redirect_purl(tmp.path(), &mut state, "pkg:gem/rack@3.0.0", false) - .await - .expect_err("unsupported ecosystem must refuse"); - assert!( - err.contains("no hosted-redirect revert implementation"), - "{err}" - ); - assert!(err.contains("pkg:gem/rack@3.0.0"), "names the purl: {err}"); - } - - /// A hand-edited ledger can hold a VERSIONLESS record key; the canon - /// match finds it, but the purl parse must still refuse fail-closed - /// rather than guess a version. - #[tokio::test] - async fn versionless_cargo_purl_record_is_refused() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - state - .records - .insert("pkg:cargo/cfg-if".to_string(), record()); - let err = revert_cargo_redirect_purl(tmp.path(), &mut state, "pkg:cargo/cfg-if", false) - .await - .expect_err("versionless purl must refuse"); - assert!(err.contains("not a cargo purl"), "{err}"); - assert!(!state.records.is_empty(), "ledger keeps the record"); - } - - /// npm twin of the versionless-record refusal. - #[tokio::test] - async fn npm_versionless_purl_record_is_refused() { - let tmp = tempfile::tempdir().unwrap(); - let mut state = RedirectState::new(); - state - .records - .insert("pkg:npm/left-pad".to_string(), record()); - let err = revert_npm_redirect_purl(tmp.path(), &mut state, "pkg:npm/left-pad", false) - .await - .expect_err("versionless purl must refuse"); - assert!(err.contains("not an npm purl"), "{err}"); - assert!(!state.records.is_empty(), "ledger keeps the record"); - } - - /// Corrupt ledger (hand-edited): a wiring edit without an `original` - /// fragment cannot be inverted — refuse and leave every claimed file - /// AND the ledger untouched. - #[tokio::test] - async fn cargo_edit_without_original_fragment_is_refused_fail_closed() { - 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(); - state - .edits - .iter_mut() - .find(|e| e.kind == "redirect_cargo_toml_dep") - .expect("fixture records a toml wiring edit") - .original = None; - - let err = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect_err("edit without an original must refuse"); - assert!(err.contains("records no original fragment"), "{err}"); - - 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 — its inverse resolved before the refusal" - ); - assert_eq!( - tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(), - cfg_before, - ".cargo/config.toml untouched" - ); - assert_eq!(state.records.len(), records_before); - assert_eq!(state.edits.len(), edits_before); - } - - /// Cargo.lock deleted after the redirect: refuse, and leave the OTHER - /// claimed files and the ledger exactly as found — the contract this - /// module exists for. - #[tokio::test] - async fn cargo_missing_lock_file_refuses_and_leaves_the_rest_untouched() { - 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 cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - tokio::fs::remove_file(root.join("Cargo.lock")) - .await - .unwrap(); - let records_before = state.records.len(); - let edits_before = state.edits.len(); - - let err = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect_err("deleted lock must refuse"); - assert!(err.contains("no longer exists"), "{err}"); - assert!(err.contains("Cargo.lock"), "{err}"); - - assert!(!root.join("Cargo.lock").exists(), "not resurrected"); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - toml_before, - "Cargo.toml untouched (still hosted-wired)" - ); - assert_eq!( - tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(), - cfg_before, - ".cargo/config.toml untouched" - ); - assert_eq!(state.records.len(), records_before); - assert_eq!(state.edits.len(), edits_before); - } - - /// A user hand-restored Cargo.toml to the pre-redirect wiring: that edit - /// is a no-op (already at `original`) and the rest still reverts. - #[tokio::test] - async fn cargo_hand_restored_manifest_is_a_noop_and_the_rest_reverts() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - tokio::fs::write(root.join("Cargo.toml"), pristine_toml()) - .await - .unwrap(); - - let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - assert!( - out.reverted_files.iter().any(|f| f == "Cargo.lock"), - "{:?}", - out.reverted_files - ); - assert!( - !out.reverted_files.iter().any(|f| f == "Cargo.toml"), - "hand-restored file skipped: {:?}", - out.reverted_files - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - pristine_toml() - ); - let lock = tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(); - assert!( - lock.contains(CRATES_IO) && !lock.contains("sparse+"), - "Cargo.lock restored: {lock}" - ); - assert!( - !root.join(".cargo/config.toml").exists(), - "socket-only config removed" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// `rm -rf .cargo` after the redirect: the registry-block edit is - /// skipped and the takeover still succeeds. - #[tokio::test] - async fn cargo_registry_config_already_deleted_is_skipped() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - tokio::fs::remove_file(root.join(".cargo/config.toml")) - .await - .unwrap(); - - let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - assert!( - !out.reverted_files.iter().any(|f| f.contains(".cargo")), - "{:?}", - out.reverted_files - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - pristine_toml(), - "Cargo.toml restored byte-identical" - ); - assert!( - !root.join(".cargo/config.toml").exists(), - "config not resurrected" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// Removing the block the redirect APPENDED to an existing config (the - /// legacy `.cargo/config` here) restores the user's bytes — no trailing - /// blank line (`[net]\nretry = 2\n\n`) left behind — and never touches - /// blank runs of the user's own elsewhere in the file. - #[tokio::test] - async fn appended_registry_block_revert_restores_the_config_bytes() { - let cases = [ - "[net]\nretry = 2\n", - "[net]\n\n\n\nretry = 2\n", - "# a comment\n\n[http]\ntimeout = 5\n", - "[net]\r\nretry = 2\r\n", - // No final newline, trailing blank lines, whitespace only. - "[net]\nretry = 2", - "[net]\r\nretry = 2", - "[net]\nretry = 2\n\n", - "[net]\r\nretry = 2\r\n\r\n", - "\n", - ]; - // Both unwind paths: `remove ` and the whole-ledger replay. - for (user_cfg, replay) in cases.iter().flat_map(|cfg| [(*cfg, false), (*cfg, true)]) { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let lock = format!("version = 4\n\n{}\n", pristine_lock_block()); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), pristine_toml()); - files.insert("Cargo.lock".into(), lock.clone()); - files.insert(".cargo/config".into(), user_cfg.to_string()); - let dep: crate::patch::redirect::DepOverride = - serde_json::from_value(serde_json::json!({ - "ecosystem": "cargo", "name": "cfg-if", "version": "1.0.4", - "token": "tok", "patchUuid": UUID, - "artifactUrl": "http://127.0.0.1:5555/cfg-if-1.0.4.crate", - "registryOverride": { - "kind": "cargo-sparse", "indexUrl": INDEX, - "identifiers": { - "name": "cfg-if", "version": "1.0.4", - "cargoCksumSha256": "a".repeat(64), - }, - }, - "integrity": { "sha256": "a".repeat(64) }, - })) - .unwrap(); - let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); - tokio::fs::create_dir_all(root.join(".cargo")) - .await - .unwrap(); - for (rel, content) in files.iter().chain(rewrite.files.iter()) { - tokio::fs::write(root.join(rel), content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - state.records.insert(PURL.to_string(), record()); - - if replay { - let outcome = crate::patch::redirect::revert_remaining_redirect_edits( - root, &mut state, false, - ) - .await; - assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); - } else { - revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - } - assert_eq!( - tokio::fs::read_to_string(root.join(".cargo/config")) - .await - .unwrap(), - user_cfg, - "replay: {replay}" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - pristine_toml() - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(), - lock - ); - } - } - - /// A CRLF project (manifest, lock and legacy config) is - /// redirected with CRLF kept and `remove` restores every byte. - #[tokio::test] - async fn crlf_project_reverts_byte_for_byte() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let crlf = |s: &str| s.replace('\n', "\r\n"); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("Cargo.toml".into(), crlf(&pristine_toml())); - files.insert( - "Cargo.lock".into(), - crlf(&format!("version = 4\n\n{}\n", pristine_lock_block())), - ); - files.insert(".cargo/config".into(), crlf("[net]\nretry = 2\n")); - let dep: crate::patch::redirect::DepOverride = serde_json::from_value(serde_json::json!({ - "ecosystem": "cargo", "name": "cfg-if", "version": "1.0.4", - "token": "tok", "patchUuid": UUID, - "artifactUrl": "http://127.0.0.1:5555/cfg-if-1.0.4.crate", - "registryOverride": { - "kind": "cargo-sparse", "indexUrl": INDEX, - "identifiers": { - "name": "cfg-if", "version": "1.0.4", - "cargoCksumSha256": "a".repeat(64), - }, - }, - "integrity": { "sha256": "a".repeat(64) }, - })) - .unwrap(); - let rewrite = crate::patch::redirect::rewrite_registry_redirect(&files, &[dep]); - assert_eq!(rewrite.files.len(), 3, "{:?}", rewrite.warnings); - tokio::fs::create_dir_all(root.join(".cargo")) - .await - .unwrap(); - for (rel, content) in files.iter().chain(rewrite.files.iter()) { - tokio::fs::write(root.join(rel), content).await.unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - state.records.insert(PURL.to_string(), record()); - revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - for (rel, content) in &files { - assert_eq!( - &tokio::fs::read_to_string(root.join(rel)).await.unwrap(), - content, - "{rel}" - ); - } - } - - /// A ledger recorded on one side of a line-ending conversion reverts - /// files checked out on the other: a Windows scan (CRLF fragments, - /// JSON-escaped, so git never converts them) removed on an LF checkout, - /// and an LF scan removed on a CRLF (`core.autocrlf`) checkout — through - /// `remove` and through the whole-ledger replay — neither may refuse as - /// "drifted" because no fragment matches byte-for-byte. - #[tokio::test] - async fn revert_survives_a_checkout_line_ending_conversion() { - let crlf = |s: &str| s.replace('\n', "\r\n"); - let lf = |s: &str| s.replace("\r\n", "\n"); - let pristine: Vec<(&str, String)> = vec![ - ("Cargo.toml", pristine_toml()), - ( - "Cargo.lock", - format!("version = 4\n\n{}\n", pristine_lock_block()), - ), - (".cargo/config", "[net]\nretry = 2\n".to_string()), - ]; - for (scan_crlf, replay) in [(true, false), (false, false), (true, true), (false, true)] { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let scanned = |s: &str| if scan_crlf { crlf(s) } else { s.to_string() }; - let checked_out = |s: &str| if scan_crlf { lf(s) } else { crlf(s) }; - let files: BTreeMap = pristine - .iter() - .map(|(rel, text)| (rel.to_string(), scanned(text))) - .collect(); - let rewrite = crate::patch::redirect::rewrite_registry_redirect( - &files, - &[cargo_dep("cfg-if", "1.0.4", UUID)], - ); - assert_eq!(rewrite.files.len(), 3, "{:?}", rewrite.warnings); - tokio::fs::create_dir_all(root.join(".cargo")) - .await - .unwrap(); - for (rel, content) in files.iter().chain(rewrite.files.iter()) { - tokio::fs::write(root.join(rel), checked_out(content)) - .await - .unwrap(); - } - // The ledger round-trips through its JSON file unchanged. - let mut state: RedirectState = serde_json::from_str( - &serde_json::to_string(&{ - let mut state = RedirectState::new(); - state.edits = rewrite.edits.clone(); - state.records.insert(PURL.to_string(), record()); - state - }) - .unwrap(), - ) - .unwrap(); - if replay { - let outcome = crate::patch::redirect::revert_remaining_redirect_edits( - root, &mut state, false, - ) - .await; - assert!( - outcome.fully_reverted(), - "scan crlf {scan_crlf}: {:?}", - outcome.refusals - ); - } else { - revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .unwrap_or_else(|e| panic!("scan crlf {scan_crlf}: {e}")); - } - for (rel, text) in &pristine { - assert_eq!( - tokio::fs::read_to_string(root.join(rel)).await.unwrap(), - checked_out(text), - "{rel} (scan crlf {scan_crlf}, replay {replay})" - ); - } - } - } - - /// The socket block was already hand-removed (the config now holds only - /// user content): skip it, byte-untouched, and still succeed. - #[tokio::test] - async fn cargo_registry_block_hand_removed_keeps_user_config_untouched() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - let user_cfg = "[net]\nretry = 2\n"; - tokio::fs::write(root.join(".cargo/config.toml"), user_cfg) - .await - .unwrap(); - - let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - assert!( - !out.reverted_files.iter().any(|f| f.contains(".cargo")), - "{:?}", - out.reverted_files - ); - assert_eq!( - tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(), - user_cfg, - "user config byte-untouched" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - pristine_toml() - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// The whole-ledger replay keeps a hand-pinned block too (removing it - /// would leave the hand pin naming an undefined registry), while still - /// unwinding the wiring it owns. - #[tokio::test] - async fn replay_keeps_a_registry_block_still_referenced() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - let reg = format!("socket-patch-{UUID}"); - let pinned_line = format!("other = {{ version = \"1.0\", registry = \"{reg}\" }}\n"); - let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(); - tokio::fs::write( - root.join("Cargo.toml"), - format!("{wired_toml}{pinned_line}"), - ) - .await - .unwrap(); - let outcome = - crate::patch::redirect::revert_remaining_redirect_edits(root, &mut state, false).await; - assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); - let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - assert!(cfg.contains(®), "block kept while referenced: {cfg}"); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - format!("{}{pinned_line}", pristine_toml()) - ); - assert!(state.edits.is_empty(), "{:?}", state.edits); - } - - /// A user hand-pinned a SECOND dep to the socket registry: the block is - /// kept while anything still references it (the documented defensive - /// keep), and the takeover still reverts the wiring it owns. - #[tokio::test] - async fn cargo_registry_block_kept_while_still_referenced() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - let reg = format!("socket-patch-{UUID}"); - let pinned_line = format!("other = {{ version = \"1.0\", registry = \"{reg}\" }}\n"); - let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(); - tokio::fs::write( - root.join("Cargo.toml"), - format!("{wired_toml}{pinned_line}"), - ) - .await - .unwrap(); - - let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - - let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - assert!(cfg.contains(®), "block kept while referenced: {cfg}"); - assert!( - !out.reverted_files.iter().any(|f| f.contains(".cargo")), - "{:?}", - out.reverted_files - ); - assert_eq!( - tokio::fs::read_to_string(root.join("Cargo.toml")) - .await - .unwrap(), - format!("{}{pinned_line}", pristine_toml()), - "cfg-if wiring reverted, hand pin survives" - ); - let lock = tokio::fs::read_to_string(root.join("Cargo.lock")) - .await - .unwrap(); - assert!( - lock.contains(CRATES_IO) && !lock.contains("sparse+"), - "Cargo.lock restored: {lock}" - ); - // The edits/record are dropped by design even when the block is - // kept: the kept block now belongs to the user's hand pin, and a - // stale ledger claim over it would poison later reverts. - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// A REGENERATED block (`action: "rewritten"` — the rewriter replaced a - /// degraded/commented region in place and recorded it as `original`) - /// restores that pre-existing region instead of deleting the block: the - /// original bytes are the user's. - #[tokio::test] - async fn cargo_regenerated_registry_block_restores_the_user_region() { - let (tmp, mut state) = redirected_fixture().await; - let root = tmp.path(); - let user_region = "# corp mirror config (degraded)\n"; - state - .edits - .iter_mut() - .find(|e| e.kind == "redirect_cargo_registry") - .expect("fixture records a registry edit") - .original = Some(Value::String(user_region.into())); - - let out = revert_cargo_redirect_purl(root, &mut state, PURL, false) - .await - .expect("revert succeeds"); - let cfg = tokio::fs::read_to_string(root.join(".cargo/config.toml")) - .await - .unwrap(); - assert!( - cfg.contains("# corp mirror config"), - "user region restored: {cfg}" - ); - assert!(!cfg.contains("socket-patch-"), "block gone: {cfg}"); - assert!( - out.reverted_files.iter().any(|f| f.contains(".cargo")), - "{:?}", - out.reverted_files - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// Corrupt-ledger guard, npm text kinds: an edit without an `original` - /// fragment refuses and leaves the file and ledger untouched. - #[tokio::test] - async fn npm_text_edit_without_original_fragment_is_refused_fail_closed() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - let wired = tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(); - let records_before = state.records.len(); - let edits_before = state.edits.len(); - state - .edits - .iter_mut() - .find(|e| e.kind == "redirect_yarn_classic_entry") - .expect("fixture records a classic edit") - .original = None; - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("edit without an original must refuse"); - assert!(err.contains("records no original fragment"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - wired, - "yarn.lock untouched (still hosted-wired)" - ); - assert_eq!(state.records.len(), records_before); - assert_eq!(state.edits.len(), edits_before); - } - - /// yarn.lock deleted after the redirect: refuse; ledger intact. - #[tokio::test] - async fn npm_missing_text_lock_refuses_and_keeps_the_ledger() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - tokio::fs::remove_file(root.join("yarn.lock")) - .await - .unwrap(); - let records_before = state.records.len(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("deleted lock must refuse"); - assert!(err.contains("no longer exists"), "{err}"); - assert!(err.contains("yarn.lock"), "{err}"); - assert!(!root.join("yarn.lock").exists(), "not resurrected"); - assert_eq!(state.records.len(), records_before); - assert_eq!(state.edits.len(), edits_before); - } - - /// A user hand-restored the text lock to pristine: the revert is a clean - /// no-op that still drops the ledger entries (the takeover is complete). - #[tokio::test] - async fn npm_hand_restored_text_lock_is_a_noop_that_drops_the_ledger() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - tokio::fs::write(root.join("yarn.lock"), classic_pristine()) - .await - .unwrap(); - - let out = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert!( - out.reverted_files.is_empty(), - "nothing rewritten: {:?}", - out.reverted_files - ); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - classic_pristine(), - "yarn.lock untouched" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// package-lock.json deleted for the JSON kinds: the claim survives via - /// the recorded-URL attribution (the lock can no longer vouch for the - /// entry), then the replay refuses fail-closed. - #[tokio::test] - async fn npm_missing_package_lock_refuses_via_url_attribution() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - assert_eq!(state.edits.len(), 2, "packages + dependencies edits"); - tokio::fs::remove_file(root.join("package-lock.json")) - .await - .unwrap(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("deleted lock must refuse"); - assert!(err.contains("no longer exists"), "{err}"); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), 2, "ledger keeps the edits"); - } - - /// package-lock.json is no longer valid JSON: the disk-lock parse - /// degrades to `None` (so the claim still happens via recorded URLs) and - /// the replay refuses on the parse. - #[tokio::test] - async fn npm_invalid_json_package_lock_refuses_fail_closed() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - tokio::fs::write(root.join("package-lock.json"), "{ not json") - .await - .unwrap(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("invalid JSON must refuse"); - assert!(err.contains("is not valid JSON"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - "{ not json", - "file untouched" - ); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - } - - /// JSON-kind drift refusal: the v3 `packages` entry was re-resolved to a - /// shape the ledger never saw — refuse, file byte-untouched. - #[tokio::test] - async fn npm_json_drifted_package_lock_entry_refuses_fail_closed() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("packages") - .and_then(|p| p.get_mut("node_modules/left-pad")) - .and_then(Value::as_object_mut) - .unwrap() - .insert( - "resolved".into(), - Value::String("https://corp.example/left-pad-1.3.0.tgz".into()), - ); - let drifted = serde_json::to_string_pretty(&on_disk).unwrap(); - tokio::fs::write(root.join("package-lock.json"), &drifted) - .await - .unwrap(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("drifted entry must refuse"); - assert!(err.contains("drifted"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - drifted, - "nothing reached disk on refusal" - ); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - } - - /// A user hand-restored package-lock.json to pristine: both trees replay - /// as `Ok(false)` no-ops and the ledger entries still drop. - #[tokio::test] - async fn npm_hand_restored_package_lock_is_a_noop_that_drops_the_ledger() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - tokio::fs::write(root.join("package-lock.json"), package_lock_pristine()) - .await - .unwrap(); - - let out = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert!( - out.reverted_files.is_empty(), - "nothing rewritten: {:?}", - out.reverted_files - ); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - package_lock_pristine(), - "package-lock.json untouched" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// npm upgraded the lock to a v3-only shape after the redirect (the - /// legacy `dependencies` tree is gone) — the recorded v2 edit refuses, - /// and the v3 entry's hosted wiring stays exactly as found (nothing - /// half-applied). - #[tokio::test] - async fn npm_v2_dependencies_tree_gone_refuses_fail_closed() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .as_object_mut() - .unwrap() - .remove("dependencies") - .expect("fixture v2 tree present"); - let v3_only = serde_json::to_string_pretty(&on_disk).unwrap(); - tokio::fs::write(root.join("package-lock.json"), &v3_only) - .await - .unwrap(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("vanished v2 tree must refuse"); - assert!( - err.contains("no longer holds a `dependencies` tree"), - "{err}" - ); - let after = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert_eq!(after, v3_only, "nothing reached disk on refusal"); - assert!( - after.contains(NPM_URL), - "the v3 entry keeps its hosted wiring: {after}" - ); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - } - - /// The v2 `dependencies` node for this purl vanished (the tree survives): - /// `any_found` stays false and the replay refuses. - #[tokio::test] - async fn npm_v2_dependencies_node_gone_refuses_fail_closed() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("dependencies") - .and_then(Value::as_object_mut) - .unwrap() - .remove("left-pad") - .expect("fixture v2 node present"); - let pruned = serde_json::to_string_pretty(&on_disk).unwrap(); - tokio::fs::write(root.join("package-lock.json"), &pruned) - .await - .unwrap(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("vanished v2 node must refuse"); - assert!( - err.contains("`dependencies` entry") && err.contains("no longer exists"), - "{err}" - ); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - pruned, - "nothing reached disk on refusal" - ); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - } - - /// A drift inside a v2 `dependencies` node propagates out of the - /// recursive walk as the same fail-closed refusal. - #[tokio::test] - async fn npm_v2_dependencies_node_drift_refuses_fail_closed() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &package_lock_pristine()).await; - let root = tmp.path(); - let mut on_disk: Value = serde_json::from_str( - &tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - ) - .unwrap(); - on_disk - .get_mut("dependencies") - .and_then(|d| d.get_mut("left-pad")) - .and_then(Value::as_object_mut) - .unwrap() - .insert("integrity".into(), Value::String("sha512-corp==".into())); - let drifted = serde_json::to_string_pretty(&on_disk).unwrap(); - tokio::fs::write(root.join("package-lock.json"), &drifted) - .await - .unwrap(); - let edits_before = state.edits.len(); - - let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect_err("drifted v2 node must refuse"); - assert!(err.contains("drifted"), "{err}"); - let after = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert_eq!(after, drifted, "nothing reached disk on refusal"); - assert!( - after.contains(NPM_URL), - "the v3 entry keeps its hosted wiring: {after}" - ); - assert!(!state.records.is_empty(), "ledger keeps the record"); - assert_eq!(state.edits.len(), edits_before, "ledger keeps the edits"); - } - - /// Pristine v3-only lock whose entry has `resolved` but NO `integrity` - /// key: the rewriter records `integrity: null` in `original`, so the - /// revert must REMOVE the hosted integrity field, not leave it stale. - fn no_integrity_lock_pristine() -> String { - let lock = serde_json::json!({ - "name": "app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { "name": "app", "version": "1.0.0" }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz" - } - } - }); - format!("{}\n", serde_json::to_string_pretty(&lock).unwrap()) - } - - #[tokio::test] - async fn npm_entry_without_integrity_round_trips_the_field_removal() { - let (tmp, mut state) = - npm_redirected_fixture("package-lock.json", &no_integrity_lock_pristine()).await; - let root = tmp.path(); - let wired = tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(); - assert!( - wired.contains("\"integrity\""), - "the rewriter wrote a hosted integrity: {wired}" - ); - - let out = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("revert succeeds"); - assert_eq!(out.reverted_files, vec!["package-lock.json".to_string()]); - assert_eq!( - tokio::fs::read_to_string(root.join("package-lock.json")) - .await - .unwrap(), - no_integrity_lock_pristine(), - "integrity key REMOVED on revert, not left null/stale" - ); - assert!(state.records.is_empty(), "record dropped"); - assert!(state.edits.is_empty(), "edits dropped"); - } - - /// A bun.lock edit belonging to a DIFFERENT package is neither claimed - /// nor a refusal: this purl's takeover proceeds and the foreign edit and - /// its record stay in the ledger untouched. - #[tokio::test] - async fn npm_foreign_bun_edit_is_neither_claimed_nor_a_refusal() { - let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await; - let root = tmp.path(); - state - .records - .insert("pkg:npm/other@1.0.0".to_string(), record()); - state.edits.push(FileEdit { - path: "bun.lock".into(), - kind: "redirect_bun_lock_package".into(), - action: "rewritten".into(), - key: Some("other".into()), - original: Some(Value::String( - " \"other\": [\"other@1.0.0\", \"reg\", {}, \"sha512-p==\"],".into(), - )), - new: Some(Value::String( - " \"other\": [\"other@http://127.0.0.1:5555/patch/npm/other/1.0.0/tok/6b7c/other-1.0.0.tgz\", {}, \"sha512-h==\"]," - .into(), - )), - }); - - revert_npm_redirect_purl(root, &mut state, NPM_PURL, false) - .await - .expect("takeover succeeds despite the foreign bun edit"); - assert_eq!( - tokio::fs::read_to_string(root.join("yarn.lock")) - .await - .unwrap(), - classic_pristine(), - "yarn.lock restored byte-identical" - ); - assert_eq!( - state.edits.len(), - 1, - "the foreign bun edit survives: {:?}", - state.edits - ); - assert_eq!(state.edits[0].kind, "redirect_bun_lock_package"); - assert!( - state.records.contains_key("pkg:npm/other@1.0.0") - && !state.records.contains_key(NPM_PURL), - "{:?}", - state.records.keys() - ); - } - - const GO_PURL: &str = "pkg:golang/example.com/lib@v1.10.0"; - const GO_UUID: &str = "7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a"; - const GO_OTHER_PURL: &str = "pkg:golang/example.com/other@v0.2.0"; - const GO_OTHER_UUID: &str = "8e9f0a1b-2c3d-4e4f-9a5b-6c7d8e9f0a1b"; - const GO_ZIP_H1: &str = "h1:mU9vN/n1hbXktM62lJ6MbRKOk3aI8NDH+szCf62RXtE="; - const GO_MOD_H1: &str = "h1:XgagPTRZSCprrzR+3Ro36/XJpibdovhAbsKThYI8bxg="; - - fn go_override(module: &str, version: &str, uuid: &str) -> crate::patch::redirect::DepOverride { - let socket_module = format!("patch.socket.dev/gopatch/{uuid}"); - let socket_version = format!("{version}-socketpatch.1"); - serde_json::from_value(serde_json::json!({ - "ecosystem": "golang", - "name": module, - "version": version, - "token": "", - "patchUuid": uuid, - "artifactUrl": format!("https://patch.socket.dev/{socket_module}/@v/{socket_version}.zip"), - "registryOverride": { - "kind": "goproxy", - "indexUrl": "https://patch.socket.dev", - "identifiers": { - "name": module, "version": version, - "goModulePath": socket_module, - "goModuleVersion": socket_version, - }, - }, - "integrity": { "dirhashH1": GO_ZIP_H1, "goModH1": GO_MOD_H1 }, - })) - .unwrap() - } - - /// go's own go.sum order: `v1.9.0/go.mod` sorts BEFORE `v1.10.0` - /// (semver), although it is bytewise greater. - fn go_pristine(eol: &str) -> (String, String) { - let go_mod = "module example.com/app\n\ngo 1.21\n\nrequire (\n\texample.com/lib v1.10.0\n\texample.com/other v0.2.0\n)\n" - .replace('\n', eol); - let go_sum = "example.com/leaf v1.0.0 h1:L=\n\ - example.com/leaf v1.0.0/go.mod h1:LM=\n\ - example.com/lib v1.9.0/go.mod h1:N9=\n\ - example.com/lib v1.10.0 h1:T=\n\ - example.com/lib v1.10.0/go.mod h1:TM=\n\ - example.com/other v0.2.0 h1:O=\n\ - example.com/other v0.2.0/go.mod h1:OM=\n" - .replace('\n', eol); - (go_mod, go_sum) - } - - /// Both modules hosted-redirected by the real rewriter, written to disk. - async fn go_redirected_fixture(eol: &str) -> (tempfile::TempDir, RedirectState) { - let tmp = tempfile::tempdir().unwrap(); - let (go_mod, go_sum) = go_pristine(eol); - let mut files: BTreeMap = BTreeMap::new(); - files.insert("go.mod".into(), go_mod); - files.insert("go.sum".into(), go_sum); - let rewrite = crate::patch::redirect::rewrite_registry_redirect( - &files, - &[ - go_override("example.com/lib", "v1.10.0", GO_UUID), - go_override("example.com/other", "v0.2.0", GO_OTHER_UUID), - ], - ); - assert!(rewrite.warnings.is_empty(), "{:?}", rewrite.warnings); - for (rel, content) in &rewrite.files { - tokio::fs::write(tmp.path().join(rel), content) - .await - .unwrap(); - } - let mut state = RedirectState::new(); - state.edits = rewrite.edits; - let mut lib = record(); - lib.uuid = GO_UUID.to_string(); - let mut other = record(); - other.uuid = GO_OTHER_UUID.to_string(); - state.records.insert(GO_PURL.to_string(), lib); - state.records.insert(GO_OTHER_PURL.to_string(), other); - (tmp, state) - } - - /// hosted → vendored takeover of one Go module: its replace and gopatch - /// go.sum lines go, its pruned go.sum pair comes back where go sorts it, - /// and its ledger record is dropped. The other hosted module is intact. - #[tokio::test] - async fn golang_per_purl_revert_unwinds_only_that_module() { - for eol in ["\n", "\r\n"] { - let (tmp, mut state) = go_redirected_fixture(eol).await; - let root = tmp.path(); - assert!(redirect_revert_supported(GO_PURL)); - revert_redirect_purl(root, &mut state, GO_PURL, false) - .await - .expect("golang takeover revert succeeds"); - - let go_mod = tokio::fs::read_to_string(root.join("go.mod")) - .await - .unwrap(); - let go_sum = tokio::fs::read_to_string(root.join("go.sum")) - .await - .unwrap(); - assert!(!go_mod.contains(GO_UUID), "{go_mod:?}"); - assert!( - go_mod.contains(&format!( - "replace example.com/other v0.2.0 => patch.socket.dev/gopatch/{GO_OTHER_UUID}" - )), - "{go_mod:?}" - ); - let expected_sum = format!( - "example.com/leaf v1.0.0 h1:L={eol}\ - example.com/leaf v1.0.0/go.mod h1:LM={eol}\ - example.com/lib v1.9.0/go.mod h1:N9={eol}\ - example.com/lib v1.10.0 h1:T={eol}\ - example.com/lib v1.10.0/go.mod h1:TM={eol}\ - patch.socket.dev/gopatch/{GO_OTHER_UUID} v0.2.0-socketpatch.1 {GO_ZIP_H1}{eol}\ - patch.socket.dev/gopatch/{GO_OTHER_UUID} v0.2.0-socketpatch.1/go.mod {GO_MOD_H1}{eol}" - ); - assert_eq!(go_sum, expected_sum, "eol {eol:?}"); - assert!(!state.records.contains_key(GO_PURL)); - assert!(state.records.contains_key(GO_OTHER_PURL)); - assert!( - state.edits.iter().all(|e| { - let text = format!("{:?}{:?}{:?}", e.key, e.new, e.original); - !text.contains(GO_UUID) && !text.contains("example.com/lib") - }), - "{:?}", - state.edits - ); - - // The remaining module unwinds through the same path, back to - // the pristine bytes. - revert_redirect_purl(root, &mut state, GO_OTHER_PURL, false) - .await - .expect("second revert succeeds"); - let (pristine_mod, pristine_sum) = go_pristine(eol); - assert_eq!( - tokio::fs::read_to_string(root.join("go.mod")) - .await - .unwrap(), - pristine_mod - ); - assert_eq!( - tokio::fs::read_to_string(root.join("go.sum")) - .await - .unwrap(), - pristine_sum - ); - assert!(state.records.is_empty() && state.edits.is_empty()); - } - } - - /// A go.mod whose socket replace was hand-edited away from the recorded - /// directive has drifted: refuse, byte-untouched, ledger kept. - #[tokio::test] - async fn golang_per_purl_revert_refuses_a_drifted_replace() { - let (tmp, mut state) = go_redirected_fixture("\n").await; - let root = tmp.path(); - let go_mod = tokio::fs::read_to_string(root.join("go.mod")) - .await - .unwrap(); - let drifted = go_mod.replace( - &format!("patch.socket.dev/gopatch/{GO_UUID} v1.10.0-socketpatch.1"), - "../my-fork", - ); - tokio::fs::write(root.join("go.mod"), &drifted) - .await - .unwrap(); - let go_sum = tokio::fs::read_to_string(root.join("go.sum")) - .await - .unwrap(); - let before = state.clone(); - - let err = revert_redirect_purl(root, &mut state, GO_PURL, false) - .await - .expect_err("a drifted replace refuses"); - assert!(err.contains("go.mod"), "{err}"); - assert_eq!( - tokio::fs::read_to_string(root.join("go.mod")) - .await - .unwrap(), - drifted - ); - assert_eq!( - tokio::fs::read_to_string(root.join("go.sum")) - .await - .unwrap(), - go_sum - ); - assert_eq!(state.records.len(), before.records.len()); - assert_eq!(state.edits.len(), before.edits.len()); - } -} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs new file mode 100644 index 000000000..aaf712929 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs @@ -0,0 +1,382 @@ +//! bun.lockb upstream restore: every package record whose remote-tarball +//! resolution is a hosted URL naming an in-scope patch uuid is rebuilt as +//! Bun's npm registry record for the pin's `name@version` — the registry's +//! `dist.tarball` and `dist.integrity` (via the `SOCKET_NPM_REGISTRY`-aware +//! [`super::UpstreamClient`]), through the native codec +//! ([`BunLockb::set_registry_package`], which also re-derives the metadata +//! hash Bun's frozen install checks). Every other byte is the file's own. +//! +//! Where the hosted rewrite had to normalize the lock, the codec marked it +//! (see `vendor::bun_lockb`): a lock promoted from binary format 1 is +//! demoted back to its exact format-1 bytes once every hosted record is +//! rebuilt, and a lock whose workspace dependency behaviors were normalized +//! (not invertible) is refused — the checkout remedy, never a non-exact lock. +//! +//! Only reached under [`super::RestoreOptions::bun_lockb`]: see there for +//! why `rollback` keeps refusing a binary lock. + +use std::collections::BTreeSet; + +use super::npm::{by_uuid, fetch_dists, refuse_all_in}; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::vendor::bun_lockb::{BunLockb, NORMALIZED_FORMAT_1, NORMALIZED_WORKSPACE}; + +pub(super) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let bytes = match view.read_bytes(rel).await { + Ok(Some(bytes)) => bytes, + Ok(None) => { + refuse_all_in(&pins, rel, &mut result, format!("{rel} no longer exists")); + continue; + } + Err(e) => { + refuse_all_in(&pins, rel, &mut result, e); + continue; + } + }; + let mut lock = match BunLockb::parse(&bytes).and_then(|lock| { + lock.validate_mutation()?; + Ok(lock) + }) { + Ok(lock) => lock, + Err(e) => { + refuse_all_in(&pins, rel, &mut result, e); + continue; + } + }; + let flags = lock.normalized_flags(); + if flags & NORMALIZED_WORKSPACE != 0 { + refuse_all_in( + &pins, + rel, + &mut result, + format!( + "the hosted rewrite normalized {rel}'s workspace dependency behaviors, \ + so its original bytes cannot be rebuilt" + ), + ); + continue; + } + let packages = match lock.packages() { + Ok(packages) => packages, + Err(e) => { + refuse_all_in(&pins, rel, &mut result, e); + continue; + } + }; + // (package id, uuid, name, version) per hosted record. + let mut hits: Vec<(usize, String, String, String)> = Vec::new(); + for p in &packages { + // Registry records carry a version; a hosted record is a + // remote tarball, which the codec reports without one. + if p.version.is_some() { + continue; + } + let Some(uuid) = ctx.hosted_uuid(&p.resolution) else { + continue; + }; + let Some(pin) = pins.get(uuid.as_str()) else { + continue; + }; + match pin.name_version() { + Some((name, version)) if name == p.name => { + hits.push((p.id, uuid, name, version)); + } + _ => result.refuse( + &uuid, + format!( + "the {rel} package #{} ({}) wiring it is not {}", + p.id, p.name, pin.purl + ), + ), + } + } + let wanted: BTreeSet<(String, String, String)> = hits + .iter() + .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) + .collect(); + let dists = fetch_dists(&wanted, ctx, &mut result).await; + let mut changed = false; + let mut restored = Vec::new(); + for (id, uuid, name, version) in hits { + if result.refused.contains_key(&uuid) { + continue; + } + let Some(dist) = dists.get(&(name.clone(), version.clone())) else { + continue; + }; + let Some(integrity) = dist.integrity.as_deref() else { + result.refuse( + &uuid, + format!("the registry records no integrity for {name}@{version}"), + ); + continue; + }; + // Transactional per record: a failed rebuild leaves `lock` as is. + match lock.set_registry_package(id, &version, &dist.tarball, integrity) { + Ok(()) => { + restored.push(uuid); + changed = true; + } + Err(e) => result.refuse(&uuid, format!("{rel} package #{id}: {e}")), + } + } + if changed && flags & NORMALIZED_FORMAT_1 != 0 { + // The hosted rewrite promoted a format-1 lock: give back its + // exact original bytes, or nothing. + match lock.demote_legacy_format() { + Ok(Some(original)) => lock = original, + Ok(None) => {} + Err(e) => { + for uuid in restored.drain(..) { + result.refuse(&uuid, format!("{rel}: {e}")); + } + changed = false; + } + } + } + result.handled.extend(restored); + if changed { + view.write_bytes(rel, lock.bytes()); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::super::{restore_upstream, PinStatus, RestoreOptions, RestoreOutcome}; + use super::*; + use crate::patch::redirect::{rewrite_bun_binary, DepOverride, Integrity, RewriteResult}; + use base64::Engine; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const UUID: &str = "77777777-7777-4777-8777-777777777777"; + const TOKEN: &str = "11111111-1111-4111-8111-111111111111"; + const UPSTREAM_URL: &str = "https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz"; + + fn fixture(dir: &str) -> Vec { + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/bun-lockb") + .join(dir) + .join("bun.lockb"), + ) + .unwrap() + } + + /// `original` as `scan --mode hosted` leaves it: minimist@1.2.2 on the + /// patch server. + fn hosted(original: &[u8]) -> Vec { + let dep = DepOverride { + ecosystem: "npm".into(), + name: "minimist".into(), + namespace: None, + version: "1.2.2".into(), + token: TOKEN.into(), + patch_uuid: UUID.into(), + artifact_url: format!( + "https://patch.socket.dev/patch/npm/{TOKEN}/{UUID}/minimist-1.2.2.tgz" + ), + berry_zip_url: None, + registry_override: None, + integrity: Integrity { + sha512: Some(format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode([42; 64]) + )), + ..Default::default() + }, + }; + let mut result = RewriteResult::default(); + rewrite_bun_binary(original, &[dep], &mut result); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + result.binary_files.remove("bun.lockb").expect("rewritten") + } + + fn minimist(bytes: &[u8]) -> crate::vendor::bun_lockb::BinaryPackage { + BunLockb::parse_packages(bytes) + .unwrap() + .into_iter() + .find(|p| p.name == "minimist" && p.version.as_deref() == Some("1.2.2")) + .expect("the registry record") + } + + /// An npm registry answering minimist@1.2.2 with the fixture's digest. + async fn registry(integrity: &str) -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "dist": { "tarball": UPSTREAM_URL, "integrity": integrity } + }))) + .mount(&server) + .await; + server + } + + /// Discover the hosted pin in `lock` and restore it. + async fn run(lock: &[u8], opts: &RestoreOptions) -> (RestoreOutcome, Vec) { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("bun.lockb"), lock).unwrap(); + let discovery = crate::vex::discover_patched_refs(tmp.path()).await; + let pins = HostedPin::all(&discovery); + assert_eq!(pins.len(), 1, "{pins:?}"); + assert_eq!(pins[0].purl, "pkg:npm/minimist@1.2.2"); + assert_eq!(pins[0].files, ["bun.lockb"]); + let outcome = restore_upstream(tmp.path(), &pins, opts).await; + assert!(outcome.flush_error.is_none(), "{:?}", outcome.flush_error); + ( + outcome, + std::fs::read(tmp.path().join("bun.lockb")).unwrap(), + ) + } + + fn vendor_opts() -> RestoreOptions { + RestoreOptions { + bun_lockb: true, + ..RestoreOptions::default() + } + } + + /// Every binary writer gets its exact pre-hosted bytes back — including + /// the uninitialized padding early writers leave in registry records, + /// and a format-1 lock (0.1.1 / 0.1.6), which the hosted rewrite + /// promoted to format 2 and the restore demotes again. + #[tokio::test] + #[serial_test::serial] + async fn hosted_record_restores_byte_exact_on_every_writer() { + for dir in [ + "0.1.1", + "0.1.6", + "0.1.7", + "0.5.9", + "0.6.7", + "0.6.8", + "0.8.1", + "0.8.1-production", + "0.8.1-production-complex", + "1.0.0", + "1.0.0-production", + "1.0.36", + "1.1.0", + "1.1.38", + "1.1.45", + "1.2.0", + "1.2.23", + "1.3.0", + "1.3.14", + "1.4.2", + "two-versions", + ] { + let original = fixture(dir); + let upstream = minimist(&original); + assert_eq!(upstream.resolution, UPSTREAM_URL, "{dir}"); + let server = registry(upstream.integrity.as_deref().unwrap()).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let lock = hosted(&original); + let (outcome, after) = run(&lock, &vendor_opts()).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + assert_eq!(outcome.pins[0].status, PinStatus::Restored, "{dir}"); + assert_eq!(outcome.reverted_files, ["bun.lockb"], "{dir}"); + assert!(after == original, "{dir}: not byte-exact"); + } + } + + /// Where the hosted rewrite had to normalize workspace dependency + /// behaviors (not invertible), the restore refuses with the checkout + /// remedy and writes nothing, instead of returning a non-exact lock. + #[tokio::test] + #[serial_test::serial] + async fn workspace_normalized_locks_refuse() { + for dir in ["1.1.45-extensions", "1.2.23-extensions", "1.4.2-extensions"] { + let original = fixture(dir); + let upstream = minimist(&original); + let server = registry(upstream.integrity.as_deref().unwrap()).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let lock = hosted(&original); + assert_eq!( + BunLockb::parse(&lock).unwrap().normalized_flags(), + crate::vendor::bun_lockb::NORMALIZED_WORKSPACE, + "{dir}" + ); + let (outcome, after) = run(&lock, &vendor_opts()).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + let why: Vec<&str> = outcome.refused().map(|(_, why)| why).collect(); + assert_eq!(why.len(), 1, "{dir}: {why:?}"); + assert!(why[0].contains("workspace dependency behaviors"), "{why:?}"); + assert!(why[0].contains("git checkout -- bun.lockb"), "{why:?}"); + assert!(outcome.reverted_files.is_empty(), "{dir}"); + assert!(after == lock, "{dir}"); + } + } + + /// A promoted format-1 lock whose mark this codec did not write exactly + /// (here: an unknown flag bit) is refused, never demoted to a guess. + #[tokio::test] + #[serial_test::serial] + async fn promoted_format_1_lock_that_does_not_invert_refuses() { + let original = fixture("0.1.6"); + let upstream = minimist(&original); + let server = registry(upstream.integrity.as_deref().unwrap()).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let mut lock = hosted(&original); + let count = u64::from_le_bytes(lock[86..94].try_into().unwrap()) as usize; + let package_start = u64::from_le_bytes(lock[110..118].try_into().unwrap()) as usize; + // The root resolution's flag byte (its last). + let flags_at = package_start + count * 16 + 63; + assert_eq!(lock[flags_at], crate::vendor::bun_lockb::NORMALIZED_FORMAT_1); + lock[flags_at] |= 0x40; + BunLockb::parse(&lock).unwrap().validate_mutation().unwrap(); + let (outcome, after) = run(&lock, &vendor_opts()).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + let why: Vec<&str> = outcome.refused().map(|(_, why)| why).collect(); + assert_eq!(why.len(), 1, "{why:?}"); + assert!(why[0].contains("format-1"), "{why:?}"); + assert!(why[0].contains("git checkout -- bun.lockb"), "{why:?}"); + assert!(after == lock); + } + + /// Without the vendor opt-in (the `rollback` posture), or offline, + /// nothing is written and the refusal names the checkout remedy; a dry + /// run resolves the restore and writes nothing. + #[tokio::test] + #[serial_test::serial] + async fn refusals_and_dry_run_leave_the_lock_untouched() { + let original = fixture("1.1.38"); + let upstream = minimist(&original); + let server = registry(upstream.integrity.as_deref().unwrap()).await; + std::env::set_var("SOCKET_NPM_REGISTRY", server.uri()); + let lock = hosted(&original); + let rollback = RestoreOptions::default(); + let offline = RestoreOptions { + offline: true, + ..vendor_opts() + }; + for opts in [&rollback, &offline] { + let (outcome, after) = run(&lock, opts).await; + let why: Vec<&str> = outcome.refused().map(|(_, why)| why).collect(); + assert_eq!(why.len(), 1, "{why:?}"); + assert!(why[0].contains("git checkout -- bun.lockb"), "{why:?}"); + assert!(outcome.reverted_files.is_empty()); + assert!(after == lock); + } + let dry = RestoreOptions { + dry_run: true, + ..vendor_opts() + }; + let (outcome, after) = run(&lock, &dry).await; + std::env::remove_var("SOCKET_NPM_REGISTRY"); + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(outcome.reverted_files, ["bun.lockb"]); + assert!(after == lock); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs new file mode 100644 index 000000000..da6fa7542 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -0,0 +1,409 @@ +//! Cargo upstream restore: `Cargo.lock` entries back on crates.io (source + +//! the index's checksum), every `Cargo.toml` declaration loses its +//! `registry = "socket-patch-"` pin, and the project cargo config +//! drops the `[registries.socket-patch-]` block nothing references +//! any more. +//! +//! The hosted rewriter only ever pins crates.io dependencies (it refuses a +//! dep declared against any other registry), so the upstream source is +//! always crates.io's. A declaration's original spelling is not recorded: +//! the shorthand `name = { version = "…", registry = "…" }` the rewriter +//! produces from `name = "…"` collapses back to that shorthand, and every +//! other form just loses the pin. + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; + +use super::{Ctx, FormatResult, HostedPin, View}; + +/// How Cargo.lock names crates.io (cargo keeps this spelling even when it +/// fetches over the sparse protocol). +const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + +/// The project cargo configs, in cargo's read preference. +const CARGO_CONFIGS: [&str; 2] = [".cargo/config", ".cargo/config.toml"]; + +fn registry_name(uuid: &str) -> String { + format!("socket-patch-{uuid}") +} + +struct LockHit { + uuid: String, + name: String, + version: String, + /// The hosted source string (`sparse+https://…/index/`). + source: String, +} + +fn quoted_field(block: &str, field: &str) -> Option { + block.lines().find_map(|l| { + let rest = l.strip_prefix(field)?.trim_start().strip_prefix('=')?; + let v = rest.trim(); + v.strip_prefix('"')?.strip_suffix('"').map(str::to_string) + }) +} + +pub(crate) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + _files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let by_uuid: BTreeMap<&str, &HostedPin> = pins.iter().map(|p| (p.uuid.as_str(), *p)).collect(); + + // ── Cargo.lock ── + let lock_raw = match view.read("Cargo.lock").await { + Ok(t) => t, + Err(e) => { + for pin in pins { + result.refuse(&pin.uuid, e.clone()); + } + return result; + } + }; + if let Some(raw) = lock_raw { + let crlf = raw.contains("\r\n"); + let mut lock = raw.replace("\r\n", "\n"); + let mut hits: Vec = Vec::new(); + let mut from = 0; + while let Some((start, end)) = super::super::next_lock_block(&lock, from) { + from = end.max(start + 1); + let block = &lock[start..end]; + let Some(source) = quoted_field(block, "source") else { + continue; + }; + let Some(uuid) = ctx.hosted_uuid(&source) else { + continue; + }; + if !by_uuid.contains_key(uuid.as_str()) { + continue; + } + match (quoted_field(block, "name"), quoted_field(block, "version")) { + (Some(name), Some(version)) => hits.push(LockHit { + uuid, + name, + version, + source, + }), + _ => result.refuse(&uuid, "its Cargo.lock entry names no crate and version"), + } + } + let lookups = hits.iter().map(|h| async move { + ( + h.uuid.clone(), + ctx.client.cargo_cksum(&h.name, &h.version).await, + ) + }); + let cksums: BTreeMap> = + futures_util::future::join_all(lookups).await.into_iter().collect(); + let mut changed = false; + for hit in &hits { + let cksum = match cksums.get(&hit.uuid) { + Some(Ok(c)) => c.clone(), + Some(Err(why)) => { + result.refuse(&hit.uuid, format!("{}@{}: {why}", hit.name, hit.version)); + continue; + } + None => continue, + }; + if result.refused.contains_key(&hit.uuid) { + continue; + } + // The entry's own source + checksum lines. + let mut from = 0; + while let Some((start, end)) = super::super::next_lock_block(&lock, from) { + from = end.max(start + 1); + let block = lock[start..end].to_string(); + if quoted_field(&block, "source").as_deref() != Some(hit.source.as_str()) + || quoted_field(&block, "name").as_deref() != Some(hit.name.as_str()) + || quoted_field(&block, "version").as_deref() != Some(hit.version.as_str()) + { + continue; + } + let rebuilt: Vec = block + .split('\n') + .map(|l| { + if l.starts_with("source = ") { + format!("source = \"{CRATES_IO_SOURCE}\"") + } else if l.starts_with("checksum = ") { + format!("checksum = \"{cksum}\"") + } else { + l.to_string() + } + }) + .collect(); + lock.replace_range(start..end, &rebuilt.join("\n")); + from = start + 1; + } + // Dependents' full-id references and the v1 `[metadata]` key. + lock = lock.replace(&format!("({})", hit.source), &format!("({CRATES_IO_SOURCE})")); + let metadata_key = format!( + "\"checksum {} {} ({CRATES_IO_SOURCE})\" = \"", + hit.name, hit.version + ); + let rebuilt: Vec = lock + .split('\n') + .map(|l| match l.strip_prefix(&metadata_key) { + Some(_) => format!("{metadata_key}{cksum}\""), + None => l.to_string(), + }) + .collect(); + lock = rebuilt.join("\n"); + result.handled.insert(hit.uuid.clone()); + changed = true; + } + if changed { + view.write( + "Cargo.lock", + if crlf { lock.replace('\n', "\r\n") } else { lock }, + ); + } + } + + // ── Cargo.toml pins ── + let root = view.root().to_path_buf(); + let mut manifests: Vec = vec!["Cargo.toml".to_string()]; + manifests.extend( + tokio::task::spawn_blocking(move || crate::utils::cargo_workspace::member_manifests(&root)) + .await + .unwrap_or_default(), + ); + for rel in &manifests { + let Ok(Some(text)) = view.read(rel).await else { + continue; + }; + let mut changed = false; + let mut out: Vec = Vec::new(); + for line in text.split('\n') { + let mut kept = Some(line.to_string()); + for pin in pins { + if result.refused.contains_key(&pin.uuid) { + continue; + } + let reg = registry_name(&pin.uuid); + let Some(current) = kept.as_deref() else { + break; + }; + if !current.contains(&format!("\"{reg}\"")) { + continue; + } + match unpin_line(current, ®) { + Some(next) => { + kept = next; + changed = true; + result.handled.insert(pin.uuid.clone()); + } + None => result.refuse( + &pin.uuid, + format!("{rel} pins it with a declaration socket-patch cannot unpin"), + ), + } + } + if let Some(line) = kept { + out.push(line); + } + } + if changed { + view.write(rel, out.join("\n")); + } + } + + // ── cargo config registry blocks ── + let mut referenced: BTreeSet = BTreeSet::new(); + for rel in manifests.iter().map(String::as_str).chain(["Cargo.lock"]) { + if let Ok(Some(text)) = view.read(rel).await { + for pin in pins { + let reg = registry_name(&pin.uuid); + if text.contains(®) || lock_names_index(&text, &pin.uuid) { + referenced.insert(pin.uuid.clone()); + } + } + } + } + for rel in CARGO_CONFIGS { + let Ok(Some(config)) = view.read(rel).await else { + continue; + }; + let mut next = config.clone(); + for pin in pins { + if result.refused.contains_key(&pin.uuid) || referenced.contains(&pin.uuid) { + continue; + } + if let Some(removed) = remove_registry_block(&next, ®istry_name(&pin.uuid)) { + next = removed; + result.handled.insert(pin.uuid.clone()); + } + } + if next != config { + if next.trim().is_empty() { + view.remove(rel); + } else { + view.write(rel, next); + } + } + } + let _ = by_uuid; + result +} + +/// Whether a Cargo.lock still sources a crate from the hosted index of +/// `uuid` (a pin this pass did not restore). +fn lock_names_index(text: &str, uuid: &str) -> bool { + text.contains(&format!("/{uuid}/index/")) +} + +static SHORTHAND_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new( + r#"^(\s*[^=\s][^=]*?\s*=\s*)\{ version = "([^"]+)", registry = "(socket-patch-[0-9a-fA-F-]{36})" \}(.*)$"#, + ) + .expect("static shorthand regex is valid") +}); + +/// The line with its `registry = ""` pin removed: `Some(None)` drops +/// the whole line (the table form's inserted `registry = …` line), +/// `Some(Some(line))` is the unpinned declaration, `None` a spelling this +/// restore does not recognize. +fn unpin_line(line: &str, reg: &str) -> Option> { + let trimmed = line.trim(); + let body = trimmed.split('#').next().unwrap_or(trimmed).trim(); + if body == format!("registry = \"{reg}\"") { + return Some(None); + } + if let Some(c) = SHORTHAND_RE.captures(line) { + if &c[3] == reg { + return Some(Some(format!("{}\"{}\"{}", &c[1], &c[2], &c[4]))); + } + } + let open = line.find('{')?; + let close = open + line[open..].find('}')?; + let inner = &line[open + 1..close]; + let pin_re = Regex::new(&format!( + r#",?\s*registry\s*=\s*"{}"\s*,?"#, + regex::escape(reg) + )) + .expect("registry pin regex is valid"); + let m = pin_re.find(inner)?; + let mut rest = String::new(); + rest.push_str(inner[..m.start()].trim_end()); + let tail = inner[m.end()..].trim_start(); + if !tail.is_empty() { + if !rest.trim().is_empty() { + rest.push_str(", "); + } + rest.push_str(tail.trim_end()); + } + let rest = rest.trim().trim_end_matches(',').trim(); + let rebuilt = if rest.is_empty() { + format!("{}{{}}{}", &line[..open], &line[close + 1..]) + } else { + format!("{}{{ {rest} }}{}", &line[..open], &line[close + 1..]) + }; + Some(Some(rebuilt)) +} + +/// The config with its `[registries.]` block (and the blank separator +/// the rewriter put before it) removed; `None` when absent. +fn remove_registry_block(config: &str, reg: &str) -> Option { + let crlf = config.contains("\r\n"); + let lf = config.replace("\r\n", "\n"); + let header = format!("[registries.{reg}]"); + let lines: Vec<&str> = lf.split('\n').collect(); + let i = lines.iter().position(|l| l.trim() == header)?; + let mut end = lines.len(); + for (j, l) in lines.iter().enumerate().skip(i + 1) { + if l.trim_start().starts_with('[') { + end = j; + break; + } + } + while end > i + 1 && lines[end - 1].trim().is_empty() { + end -= 1; + } + let fragment = format!("{}\n", lines[i..end].join("\n")); + let removed = remove_appended_cargo_block(&lf, &fragment) + .or_else(|| { + // The block ends the file with no final newline. + remove_appended_cargo_block(&lf, fragment.trim_end_matches('\n')) + })?; + Some(if crlf { + removed.replace('\n', "\r\n") + } else { + removed + }) +} + +/// Invert the cargo rewriter's append of a `[registries.…]` block: it wrote +/// `config + "\n" + block` (just `block` into an empty config), or +/// `config + "\n\n" + block` when the config lacked a final newline. +/// Removing the block plus the one newline before it restores the config's +/// exact bytes, a missing final newline or trailing blank lines included, +/// and anything the user appended after the block kept. An all-CRLF file is +/// inverted as LF and written back CRLF. `None` when the block is not in +/// the file. +fn remove_appended_cargo_block(content: &str, fragment: &str) -> Option { + if !content.is_empty() && content.matches('\n').count() == content.matches("\r\n").count() { + return remove_appended_cargo_block( + &content.replace("\r\n", "\n"), + &fragment.replace("\r\n", "\n"), + ) + .map(|lf| lf.replace('\n', "\r\n")); + } + let lf_fragment = fragment.replace("\r\n", "\n"); + let (pos, len) = match content.find(fragment) { + Some(pos) => (pos, fragment.len()), + None => (content.find(&lf_fragment)?, lf_fragment.len()), + }; + let before = &content[..pos]; + let before = before + .strip_suffix("\r\n") + .or_else(|| before.strip_suffix('\n')) + .unwrap_or(before); + Some(format!("{before}{}", &content[pos + len..])) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REG: &str = "socket-patch-55555555-5555-5555-5555-555555555555"; + + #[test] + fn shorthand_collapses_back() { + let line = format!("serde = {{ version = \"1.0.190\", registry = \"{REG}\" }}"); + assert_eq!( + unpin_line(&line, REG), + Some(Some("serde = \"1.0.190\"".to_string())) + ); + } + + #[test] + fn inline_table_loses_only_the_pin() { + let line = format!( + "serde = {{ version = \"1\", features = [\"derive\"], registry = \"{REG}\" }} # hi" + ); + assert_eq!( + unpin_line(&line, REG), + Some(Some( + "serde = { version = \"1\", features = [\"derive\"] } # hi".to_string() + )) + ); + } + + #[test] + fn table_form_line_is_dropped() { + assert_eq!(unpin_line(&format!("registry = \"{REG}\""), REG), Some(None)); + } + + #[test] + fn appended_block_leaves_the_config_byte_identical() { + let original = "[net]\ngit-fetch-with-cli = true\n"; + let hosted = format!( + "{original}\n[registries.{REG}]\nindex = \"sparse+https://patch.socket.dev/x/index/\"\n" + ); + assert_eq!(remove_registry_block(&hosted, REG).as_deref(), Some(original)); + let created = format!("[registries.{REG}]\nindex = \"sparse+https://x/\"\n"); + assert_eq!(remove_registry_block(&created, REG).as_deref(), Some("")); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs new file mode 100644 index 000000000..5be77710f --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -0,0 +1,681 @@ +//! Registry lookups for the upstream restore: what a default-registry lock +//! entry pins, re-resolved from the public registry (each base overridable +//! by the same env vars the vendored fetch honors, so tests and mirrors can +//! point it elsewhere). + +use std::collections::HashMap; + +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::vendor::registry_fetch::{ + build_registry_client, npm_registry_base, pypi_json_api_base, RegistryClient, +}; + +/// An npm version's `dist` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NpmDist { + /// The registry's tarball URL for the version. + pub tarball: String, + /// The SRI `dist.integrity` (sha512 on every modern publish). + pub integrity: Option, + /// The hex sha1 `dist.shasum`. + pub shasum: Option, +} + +/// A Go module version's two go.sum hashes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GoSums { + /// `h1:` of the module zip. + pub zip_h1: String, + /// `h1:` of the module's go.mod. + pub mod_h1: String, +} + +/// One release file of a PyPI version, from the JSON API's `urls[]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PypiFile { + /// The distribution filename (`--.whl`, `.tar.gz`, …). + pub filename: String, + /// The file's download URL (files.pythonhosted.org on PyPI). + pub url: String, + /// Lowercase hex sha256 (`digests.sha256`). + pub sha256: String, + /// Size in bytes. + pub size: Option, + /// `upload_time_iso_8601` (microsecond precision, `Z`): the same instant + /// the PEP 691 simple API reports as `upload-time`, which is what uv + /// records. + pub upload_time: Option, +} + +/// The sparse crates.io index; override with `SOCKET_CRATES_INDEX`. +pub(crate) const DEFAULT_CRATES_INDEX: &str = "https://index.crates.io"; + +fn crates_index_base() -> String { + std::env::var("SOCKET_CRATES_INDEX") + .ok() + .map(|v| v.trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_CRATES_INDEX.to_string()) +} + +/// The sparse-index path of a crate (`cargo`'s `index_path` layout). +fn crates_index_path(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + match lower.len() { + 1 => format!("1/{lower}"), + 2 => format!("2/{lower}"), + 3 => format!("3/{}/{lower}", &lower[..1]), + _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]), + } +} + +/// The RubyGems compact index root; override with `SOCKET_RUBYGEMS_URL`. +pub(crate) const DEFAULT_RUBYGEMS: &str = "https://rubygems.org"; + +fn rubygems_base() -> String { + std::env::var("SOCKET_RUBYGEMS_URL") + .ok() + .map(|v| v.trim().trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_RUBYGEMS.to_string()) +} + +/// Packagist's composer v2 metadata repository; override with +/// `SOCKET_PACKAGIST_URL`. +pub(crate) const DEFAULT_PACKAGIST: &str = "https://repo.packagist.org"; + +fn packagist_base() -> String { + std::env::var("SOCKET_PACKAGIST_URL") + .ok() + .map(|v| v.trim().trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_PACKAGIST.to_string()) +} + +/// The `checksum:` (sha256 hex of the `.gem`) a compact-index `info/` +/// file records for the ruby-platform `version` — the value bundler writes +/// into `CHECKSUMS`. Lines are `[-] |`, the +/// requirement list carrying `checksum:`. +pub(crate) fn compact_index_checksum(info: &str, version: &str) -> Option { + info.lines().find_map(|line| { + let (head, reqs) = line.split_once('|')?; + let token = head.split(' ').next()?; + if token != version { + return None; + } + reqs.split(',') + .find_map(|r| r.trim().strip_prefix("checksum:")) + .and_then(crate::utils::digest::sha256_hex) + }) +} + +/// Expand a packagist `p2` version list. `minified: composer/2.0` documents +/// (composer's `MetadataMinifier`) list each version as the DIFF against the +/// previous expanded one: every key it carries replaces the inherited value, +/// and the string `"__unset"` removes the key. +pub(crate) fn expand_packagist_versions(versions: &[Value], minified: bool) -> Vec { + if !minified { + return versions.to_vec(); + } + let mut out = Vec::with_capacity(versions.len()); + let mut current: Option> = None; + for v in versions { + let Some(obj) = v.as_object() else { + continue; + }; + let next = match current.take() { + None => obj.clone(), + Some(mut prev) => { + for (k, val) in obj { + if val.as_str() == Some("__unset") { + prev.remove(k); + } else { + prev.insert(k.clone(), val.clone()); + } + } + prev + } + }; + out.push(Value::Object(next.clone())); + current = Some(next); + } + out +} + +/// The offline refusal every lookup returns under `--offline`. +pub(crate) const OFFLINE: &str = + "the upstream entry must be re-resolved from the registry, and this run is offline"; + +type Cache = Mutex>>; + +/// One client per restore run; every lookup is cached (success and +/// failure alike) so a pin wired in several files costs one request. +pub(crate) struct UpstreamClient { + http: RegistryClient, + offline: bool, + npm: Cache, + npm_tarballs: Cache>, + cargo: Cache, + go: Cache, + rubygems: Cache, + packagist: Cache>, + pypi: Cache>, + nuget: Cache, +} + +impl UpstreamClient { + pub(crate) fn new(offline: bool) -> Self { + UpstreamClient { + http: build_registry_client(), + offline, + npm: Mutex::default(), + npm_tarballs: Mutex::default(), + cargo: Mutex::default(), + go: Mutex::default(), + rubygems: Mutex::default(), + packagist: Mutex::default(), + pypi: Mutex::default(), + nuget: Mutex::default(), + } + } + + async fn get_json(&self, url: &str) -> Result { + let resp = self + .http + .get(url) + .header("accept", "application/json") + .send() + .await + .map_err(|e| format!("GET {url}: {e}"))?; + let status = resp.status(); + if !status.is_success() { + return Err(format!("GET {url}: HTTP {status}")); + } + let text = resp + .text() + .await + .map_err(|e| format!("reading {url}: {e}"))?; + serde_json::from_str(&text).map_err(|e| format!("{url} is not JSON: {e}")) + } + + async fn get_text(&self, url: &str) -> Result { + let bytes = crate::vendor::registry_fetch::download(&self.http, url).await?; + String::from_utf8(bytes).map_err(|_| format!("{url} is not UTF-8")) + } + + /// `dist` of `name@version` from the npm registry's version document. + pub(crate) async fn npm_dist(&self, name: &str, version: &str) -> Result { + let key = (name.to_string(), version.to_string()); + if let Some(hit) = self.npm.lock().await.get(&key) { + return hit.clone(); + } + let result = self.fetch_npm_dist(name, version).await; + self.npm.lock().await.insert(key, result.clone()); + result + } + + async fn fetch_npm_dist(&self, name: &str, version: &str) -> Result { + if self.offline { + return Err(OFFLINE.to_string()); + } + let encoded_name = name.replace('/', "%2f"); + let url = format!( + "{}/{encoded_name}/{}", + npm_registry_base(), + crate::utils::uri::encode_uri_component(version) + ); + let doc = self.get_json(&url).await?; + let dist = doc + .get("dist") + .ok_or_else(|| format!("{url} carries no `dist` block"))?; + let str_field = |k: &str| dist.get(k).and_then(Value::as_str).map(str::to_string); + let tarball = str_field("tarball") + .ok_or_else(|| format!("{url} carries no `dist.tarball`"))?; + Ok(NpmDist { + tarball, + integrity: str_field("integrity"), + shasum: str_field("shasum"), + }) + } + + /// The verified upstream tarball bytes of `name@version` (checked + /// against the registry's `dist.integrity`). + pub(crate) async fn npm_tarball(&self, name: &str, version: &str) -> Result, String> { + let key = (name.to_string(), version.to_string()); + if let Some(hit) = self.npm_tarballs.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + let dist = self.npm_dist(name, version).await?; + let integrity = dist + .integrity + .clone() + .ok_or_else(|| format!("the registry records no integrity for {name}@{version}"))?; + let bytes = crate::vendor::registry_fetch::download(&self.http, &dist.tarball).await?; + crate::vendor::registry_fetch::verify_sri(&bytes, &integrity)?; + Ok(bytes) + } + .await; + self.npm_tarballs.lock().await.insert(key, result.clone()); + result + } + + /// The crates.io `cksum` (sha256 hex of the `.crate`) of + /// `name@version`, from the sparse index. + pub(crate) async fn cargo_cksum(&self, name: &str, version: &str) -> Result { + let key = (name.to_string(), version.to_string()); + if let Some(hit) = self.cargo.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + if self.offline { + return Err(OFFLINE.to_string()); + } + let url = format!("{}/{}", crates_index_base(), crates_index_path(name)); + let text = self.get_text(&url).await?; + for line in text.lines().filter(|l| !l.trim().is_empty()) { + let Ok(row) = serde_json::from_str::(line) else { + continue; + }; + if row.get("vers").and_then(Value::as_str) == Some(version) { + return row + .get("cksum") + .and_then(Value::as_str) + .filter(|c| crate::utils::digest::is_hex64_lower(c)) + .map(str::to_string) + .ok_or_else(|| format!("{url} lists {version} without a checksum")); + } + } + Err(format!("{url} does not list {name} {version}")) + } + .await; + self.cargo.lock().await.insert(key, result.clone()); + result + } + + /// Every release file of `name@version` from PyPI's JSON API (`GET + /// ///json`, base overridable with + /// `SOCKET_PYPI_JSON_API`), sorted by filename — the order Poetry, PDM, + /// Pipenv and pip-compile record them in. `name` is PEP 503 + /// canonicalized first (PyPI redirects every other spelling to it). + pub(crate) async fn pypi_files( + &self, + name: &str, + version: &str, + ) -> Result, String> { + let name = crate::crawlers::python_crawler::canonicalize_pypi_name(name); + let key = (name.clone(), version.to_string()); + if let Some(hit) = self.pypi.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + if self.offline { + return Err(OFFLINE.to_string()); + } + let url = format!( + "{}/{name}/{}/json", + pypi_json_api_base(), + crate::utils::uri::encode_uri_component(version) + ); + let doc = self.get_json(&url).await?; + pypi_release_files(&doc).map_err(|why| format!("{url} {why}")) + } + .await; + self.pypi.lock().await.insert(key, result.clone()); + result + } + + /// The go.sum hashes of `module@version`, computed from the module + /// proxy's `.zip` and `.mod` the way `go` computes them. + pub(crate) async fn go_sums(&self, module: &str, version: &str) -> Result { + let key = (module.to_string(), version.to_string()); + if let Some(hit) = self.go.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + if self.offline { + return Err(OFFLINE.to_string()); + } + if let Some(sumdb) = gosumdb_base(module) { + let url = format!( + "{sumdb}/lookup/{}@{}", + crate::crawlers::go_crawler::encode_module_path(module), + crate::crawlers::go_crawler::encode_module_path(version) + ); + let text = self.get_text(&url).await?; + let zip_key = format!("{module} {version} "); + let mod_key = format!("{module} {version}/go.mod "); + let pick = |key: &str| { + text.lines() + .find_map(|l| l.strip_prefix(key)) + .map(|h| h.trim().to_string()) + .filter(|h| crate::vendor::go_sum_edit::is_h1_dirhash(h)) + }; + return match (pick(&zip_key), pick(&mod_key)) { + (Some(zip_h1), Some(mod_h1)) => Ok(GoSums { zip_h1, mod_h1 }), + _ => Err(format!("{url} does not list both go.sum lines of {module} {version}")), + }; + } + let proxy = crate::vendor::registry_fetch::goproxy_base(module)?; + let escaped = crate::crawlers::go_crawler::encode_module_path(module); + let escaped_version = crate::crawlers::go_crawler::encode_module_path(version); + let base = format!("{proxy}/{escaped}/@v/{escaped_version}"); + let zip = crate::vendor::registry_fetch::download(&self.http, &format!("{base}.zip")) + .await?; + let zip_h1 = crate::vendor::registry_fetch::go_h1_of_zip(&zip)?; + let go_mod = + crate::vendor::registry_fetch::download(&self.http, &format!("{base}.mod")).await?; + Ok(GoSums { + zip_h1, + mod_h1: go_mod_h1(&go_mod), + }) + } + .await; + self.go.lock().await.insert(key, result.clone()); + result + } + + /// The rubygems.org sha256 of the ruby-platform `name-version.gem`, + /// from the compact index bundler itself reads (`info/`). + pub(crate) async fn rubygems_sha256(&self, name: &str, version: &str) -> Result { + let key = (name.to_string(), version.to_string()); + if let Some(hit) = self.rubygems.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + if self.offline { + return Err(OFFLINE.to_string()); + } + if !crate::vendor::gemfile_lock::is_plain_gem_token(name) { + return Err(format!("{name:?} is not a plain gem name")); + } + let url = format!("{}/info/{name}", rubygems_base()); + let text = self.get_text(&url).await?; + compact_index_checksum(&text, version) + .ok_or_else(|| format!("{url} lists no ruby-platform {version} with a checksum")) + } + .await; + self.rubygems.lock().await.insert(key, result.clone()); + result + } + + /// Every version packagist serves for the composer package `name` + /// (lowercase `vendor/package`), expanded: the stable `p2/.json` + /// list, or the `~dev` one when `dev` (composer splits branches out). + pub(crate) async fn packagist_versions(&self, name: &str, dev: bool) -> Result, String> { + let key = (name.to_string(), if dev { "~dev" } else { "" }.to_string()); + if let Some(hit) = self.packagist.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + if self.offline { + return Err(OFFLINE.to_string()); + } + let safe = name.split('/').count() == 2 + && name.split('/').all(|p| { + !p.is_empty() + && !p.starts_with('.') + && p.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b) + }) + }); + if !safe { + return Err(format!("{name:?} is not a packagist package name")); + } + let url = format!("{}/p2/{name}{}.json", packagist_base(), key.1); + let doc = self.get_json(&url).await?; + let versions = doc + .get("packages") + .and_then(|p| p.get(name)) + .and_then(Value::as_array) + .ok_or_else(|| format!("{url} lists no versions of {name}"))?; + let minified = doc.get("minified").and_then(Value::as_str) == Some("composer/2.0"); + Ok(expand_packagist_versions(versions, minified)) + } + .await; + self.packagist.lock().await.insert(key, result.clone()); + result + } + + /// The `packages.lock.json` `contentHash` of `id@version` on nuget.org + /// (base64 sha512 of the `.nupkg`): the `packageHash` of the catalog + /// entry its registration leaf points at — the hash nuget.org computed + /// over the repository-signed package, without downloading it. + pub(crate) async fn nuget_content_hash(&self, id: &str, version: &str) -> Result { + let key = (id.to_ascii_lowercase(), version.to_ascii_lowercase()); + if let Some(hit) = self.nuget.lock().await.get(&key) { + return hit.clone(); + } + let result = async { + if self.offline { + return Err(OFFLINE.to_string()); + } + let (id_lower, version_lower) = &key; + let url = format!( + "{}/v3/registration5-gz-semver2/{}/{}.json", + nuget_api_base(), + crate::utils::uri::encode_uri_component(id_lower), + crate::utils::uri::encode_uri_component(version_lower) + ); + let leaf = self.get_json_maybe_gzip(&url).await?; + let catalog = leaf + .get("catalogEntry") + .and_then(Value::as_str) + .ok_or_else(|| format!("{url} names no catalog entry"))?; + let entry = self.get_json_maybe_gzip(catalog).await?; + let same_id = entry + .get("id") + .and_then(Value::as_str) + .is_some_and(|i| i.eq_ignore_ascii_case(id_lower)); + let same_version = entry + .get("version") + .and_then(Value::as_str) + .is_some_and(|v| { + crate::vendor::nuget_feed::normalize_nuget_version(v) + .eq_ignore_ascii_case(version_lower) + }); + if !same_id || !same_version { + return Err(format!("{catalog} is not the catalog entry of {id} {version}")); + } + let sha512 = entry + .get("packageHashAlgorithm") + .and_then(Value::as_str) + .is_some_and(|a| a.eq_ignore_ascii_case("SHA512")); + match entry.get("packageHash").and_then(Value::as_str) { + Some(hash) if sha512 && is_base64_digest(hash) => Ok(hash.to_string()), + _ => Err(format!("{catalog} records no SHA512 packageHash")), + } + } + .await; + self.nuget.lock().await.insert(key, result.clone()); + result + } + + /// A JSON document that nuget.org may serve gzip-encoded whatever the + /// request asked for (the `registration5-gz-*` hives). + async fn get_json_maybe_gzip(&self, url: &str) -> Result { + use std::io::Read as _; + let mut bytes = crate::vendor::registry_fetch::download(&self.http, url).await?; + if bytes.starts_with(&[0x1f, 0x8b]) { + let mut plain = Vec::new(); + flate2::read::GzDecoder::new(bytes.as_slice()) + .take(crate::vendor::registry_fetch::MAX_DOWNLOAD_BYTES) + .read_to_end(&mut plain) + .map_err(|e| format!("{url}: bad gzip body: {e}"))?; + bytes = plain; + } + serde_json::from_slice(&bytes).map_err(|e| format!("{url} is not JSON: {e}")) + } +} + +/// nuget.org's API host; `SOCKET_NUGET_URL` names another (tests, mirrors +/// serving the same `/v3/registration5-gz-semver2/` hive). +pub(crate) const DEFAULT_NUGET_API: &str = "https://api.nuget.org"; + +fn nuget_api_base() -> String { + std::env::var("SOCKET_NUGET_URL") + .ok() + .map(|v| v.trim().trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_NUGET_API.to_string()) +} + +/// A base64 digest token (the alphabet and padding only: the lock stores +/// whatever nuget.org recorded, so its length is not second-guessed). +fn is_base64_digest(s: &str) -> bool { + let body = s.trim_end_matches('='); + !body.is_empty() + && s.len() - body.len() <= 2 + && body + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') +} + +/// The release files of a PyPI JSON API version document, sorted by +/// filename. +fn pypi_release_files(doc: &Value) -> Result, String> { + let urls = doc + .get("urls") + .and_then(Value::as_array) + .ok_or("carries no `urls` list")?; + let mut files = Vec::with_capacity(urls.len()); + for file in urls { + let str_field = |k: &str| file.get(k).and_then(Value::as_str).map(str::to_string); + let filename = str_field("filename") + .filter(|f| !f.is_empty() && !f.contains(['/', '\\'])) + .ok_or("lists a file without a plain filename")?; + let url = str_field("url").ok_or_else(|| format!("lists {filename} without a url"))?; + let sha256 = file + .get("digests") + .and_then(|d| d.get("sha256")) + .and_then(Value::as_str) + .map(str::to_ascii_lowercase) + .filter(|h| crate::utils::digest::is_hex64_lower(h)) + .ok_or_else(|| format!("lists {filename} without a sha256 digest"))?; + files.push(PypiFile { + filename, + url, + sha256, + size: file.get("size").and_then(Value::as_u64), + upload_time: str_field("upload_time_iso_8601"), + }); + } + if files.is_empty() { + return Err("lists no release files".to_string()); + } + files.sort_by(|a, b| a.filename.cmp(&b.filename)); + Ok(files) +} + +/// Go's checksum database, `sum.golang.org`; `SOCKET_GOSUMDB_URL` names +/// another (tests, mirrors). +pub(crate) const DEFAULT_GOSUMDB: &str = "https://sum.golang.org"; + +/// The checksum database go would consult for `module`, or `None` when go +/// would not (`GOSUMDB=off`, or the module matches `GONOSUMDB` / +/// `GOPRIVATE`) — the hashes are then computed from the module proxy's +/// bytes instead. An explicit `SOCKET_GOSUMDB_URL` always wins. +fn gosumdb_base(module: &str) -> Option { + if let Ok(v) = std::env::var("SOCKET_GOSUMDB_URL") { + let v = v.trim().trim_end_matches('/').to_string(); + if !v.is_empty() { + return Some(v); + } + } + let nonempty = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty()); + if nonempty("GOSUMDB").is_some_and(|v| v.trim() == "off") { + return None; + } + if let Some(patterns) = nonempty("GONOSUMDB").or_else(|| nonempty("GOPRIVATE")) { + if crate::vendor::registry_fetch::go_match_prefix_patterns(&patterns, module) { + return None; + } + } + Some(DEFAULT_GOSUMDB.to_string()) +} + +/// x/mod `dirhash.Hash1` over the single file `go.mod` — the `/go.mod h1:` +/// go.sum line. +pub(crate) fn go_mod_h1(go_mod: &[u8]) -> String { + use base64::Engine as _; + use sha2::{Digest, Sha256}; + let file_sum = hex::encode(Sha256::digest(go_mod)); + let summary = format!("{file_sum} go.mod\n"); + format!( + "h1:{}", + base64::engine::general_purpose::STANDARD.encode(Sha256::digest(summary.as_bytes())) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crates_index_paths_follow_cargo_layout() { + assert_eq!(crates_index_path("a"), "1/a"); + assert_eq!(crates_index_path("ab"), "2/ab"); + assert_eq!(crates_index_path("abc"), "3/a/abc"); + assert_eq!(crates_index_path("Serde"), "se/rd/serde"); + } + + #[test] + fn compact_index_checksum_picks_the_ruby_platform_line() { + let sha = "a".repeat(64); + let other = "b".repeat(64); + let info = format!( + "---\n1.0.0 |checksum:{other}\n1.1.0 dep:>= 0|checksum:{sha},ruby:>= 2.7\n\ + 1.1.0-java |checksum:{other}\n" + ); + assert_eq!(compact_index_checksum(&info, "1.1.0"), Some(sha)); + assert_eq!(compact_index_checksum(&info, "2.0.0"), None); + assert_eq!(compact_index_checksum("1.0.0 |ruby:>= 2", "1.0.0"), None); + } + + #[test] + fn packagist_minified_versions_inherit_and_unset() { + let versions = serde_json::json!([ + {"name": "a/b", "version": "2.0.0", "source": {"type": "git"}, "dist": {"url": "x"}}, + {"version": "1.0.0", "source": "__unset"}, + {"version": "0.9.0", "dist": {"url": "y"}} + ]); + let expanded = expand_packagist_versions(versions.as_array().unwrap(), true); + assert_eq!(expanded.len(), 3); + assert_eq!(expanded[1]["name"], "a/b"); + assert!(expanded[1].get("source").is_none()); + assert_eq!(expanded[1]["dist"]["url"], "x"); + assert!(expanded[2].get("source").is_none()); + assert_eq!(expanded[2]["dist"]["url"], "y"); + let raw = expand_packagist_versions(versions.as_array().unwrap(), false); + assert!(raw[1].get("name").is_none()); + } + + #[test] + fn pypi_release_files_are_validated_and_sorted() { + let doc = serde_json::json!({ "urls": [ + { "filename": "x-1.tar.gz", "url": "https://f/x-1.tar.gz", + "digests": { "sha256": "B".repeat(64) }, "size": 3, + "upload_time_iso_8601": "2023-01-01T00:00:00.123456Z" }, + { "filename": "x-1-py3-none-any.whl", "url": "https://f/x.whl", + "digests": { "sha256": "a".repeat(64) } }, + ]}); + let files = pypi_release_files(&doc).unwrap(); + assert_eq!(files[0].filename, "x-1-py3-none-any.whl"); + assert_eq!(files[1].sha256, "b".repeat(64)); + assert_eq!(files[1].size, Some(3)); + assert!(pypi_release_files(&serde_json::json!({ "urls": [] })).is_err()); + let bad = serde_json::json!({ "urls": [{ "filename": "x.whl", "url": "u", + "digests": { "sha256": "zz" } }] }); + assert!(pypi_release_files(&bad).unwrap_err().contains("sha256")); + } + + #[test] + fn go_mod_h1_matches_the_x_mod_recipe() { + // `module example.com/m\n` — cross-checked with `go mod download + // -json` output for a one-line module file. + let h1 = go_mod_h1(b"module example.com/m\n"); + assert!(h1.starts_with("h1:") && h1.ends_with('='), "{h1}"); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs b/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs new file mode 100644 index 000000000..52e6b8c43 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs @@ -0,0 +1,484 @@ +//! Composer upstream restore: a hosted `composer.lock` entry's `dist` goes +//! back to what packagist serves for the version (`type`, `url`, +//! `reference`, `shasum`), and the `source` block the hosted rewriter +//! deleted (`redirect_composer_dist`) is re-inserted right before it — both +//! re-derived from packagist's composer v2 metadata (`p2/.json`, or +//! `p2/~dev.json` for a branch version), the document composer itself +//! resolved the entry from. +//! +//! The rewrite keeps `dist.reference` (the upstream commit), so it is the +//! cross-check: packagist must still serve that exact reference for the +//! version, or the lock pinned something packagist no longer describes (a +//! moved tag, another repository) and the pin is refused. +//! +//! Only packagist-sourced entries are restored: the entry must carry +//! packagist's `notification-url`, or — composer omits it for hand-trimmed +//! and some older locks — `composer.json` must declare no custom +//! `repositories`. Anything else may have come from a private repository +//! whose metadata this restore cannot read, so it is refused. +//! +//! The lock is edited as text so every other byte stays composer's: the +//! blocks are rebuilt in composer's key order at the indent the entry +//! already uses, with the file's own slash style (`\/` in locks written by +//! composer versions that escaped slashes) and line endings. +//! `content-hash` hashes composer.json, not the lock, and is untouched. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use super::super::{find_composer_entry, json_object_end_from, json_string_field, ComposerEntry}; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::crawlers::composer_crawler::normalize_version; + +const COMPOSER_LOCK: &str = "composer.lock"; +const DIST_KEY: &str = "\"dist\": {"; +const SOURCE_KEY: &str = "\"source\": {"; +/// The `notification-url` composer records for packagist packages. +const PACKAGIST_NOTIFY: &str = "https://packagist.org/downloads/"; + +/// One hosted entry to restore. +struct Hit { + uuid: String, + /// Lowercase `vendor/package` (the purl name; packagist's key). + name: String, + /// The version as the lock spells it (`v2.0.0`, `dev-main`). + locked_version: String, + /// The purl version, for re-locating the entry. + purl_version: String, + /// The lock's surviving `dist.reference`. + reference: Option, +} + +/// Whether composer resolves `version` from the `~dev` metadata file +/// (branch versions: `dev-` and `.x-dev`). +fn is_dev_version(version: &str) -> bool { + let lower = version.to_ascii_lowercase(); + lower.starts_with("dev-") || lower.ends_with("-dev") +} + +/// The `[start, end]` byte range of the entry's `"dist": {…}` object. +fn dist_range(content: &str, entry: (usize, usize)) -> Option<(usize, usize)> { + let start = entry.0 + content[entry.0..=entry.1].find(DIST_KEY)?; + let end = json_object_end_from(content, start + DIST_KEY.len())?; + Some((start, end)) +} + +/// A JSON string literal in the lock's style: `\/` when the lock escapes +/// slashes (older composer), plain otherwise (composer 2's +/// `JSON_UNESCAPED_SLASHES`). +fn json_str(value: &str, escaped_slashes: bool) -> String { + let lit = Value::String(value.to_string()).to_string(); + if escaped_slashes { + lit.replace('/', "\\/") + } else { + lit + } +} + +/// `"": {` + the string fields of `obj` named in `keys` (in that order, +/// absent ones skipped) at `inner`, closed at `outer` — composer's pretty +/// print. `None` when `obj` lacks a string `type` or `url`. +fn render_block( + key: &str, + obj: &serde_json::Map, + keys: &[&str], + inner: &str, + outer: &str, + eol: &str, + escaped: bool, +) -> Option { + for required in ["type", "url"] { + obj.get(required)?.as_str()?; + } + let fields: Vec = keys + .iter() + .filter_map(|k| { + let v = obj.get(*k)?.as_str()?; + Some(format!("{inner}\"{k}\": {}", json_str(v, escaped))) + }) + .collect(); + Some(format!( + "\"{key}\": {{{eol}{}{eol}{outer}}}", + fields.join(&format!(",{eol}")) + )) +} + +/// Leading whitespace of the line holding byte `at`. +fn indent_at(content: &str, at: usize) -> &str { + let line_start = content[..at].rfind('\n').map_or(0, |i| i + 1); + let line = &content[line_start..]; + &line[..line.len() - line.trim_start_matches([' ', '\t']).len()] +} + +/// Whether `composer.json` points composer at anything but packagist. +/// `Err` when it exists but cannot be read as JSON (the refusal reason). +fn declares_custom_repositories(composer_json: Option<&str>) -> Result { + let Some(text) = composer_json else { + return Ok(false); + }; + let doc: Value = + serde_json::from_str(text).map_err(|e| format!("composer.json is not JSON: {e}"))?; + Ok(match doc.get("repositories") { + None | Some(Value::Null) => false, + Some(Value::Array(a)) => !a.is_empty(), + Some(Value::Object(o)) => !o.is_empty(), + Some(_) => true, + }) +} + +/// The packagist version entry that locked `locked` (exact pretty version +/// first, then composer's leading-`v` normalization). +fn pick_version<'v>(versions: &'v [Value], locked: &str) -> Result<&'v Value, String> { + let version_of = |v: &&Value| v.get("version").and_then(Value::as_str).map(str::to_string); + let exact: Vec<&Value> = versions + .iter() + .filter(|v| version_of(v).as_deref() == Some(locked)) + .collect(); + let candidates = if exact.is_empty() { + versions + .iter() + .filter(|v| { + version_of(v).is_some_and(|x| normalize_version(&x) == normalize_version(locked)) + }) + .collect() + } else { + exact + }; + match candidates.as_slice() { + [one] => Ok(one), + [] => Err(format!("packagist does not list version {locked}")), + _ => Err(format!("packagist lists version {locked} more than once")), + } +} + +pub(crate) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + _files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let content = match view.read(COMPOSER_LOCK).await { + Ok(Some(text)) => text, + Ok(None) => { + for pin in pins { + result.refuse(&pin.uuid, "composer.lock no longer exists"); + } + return result; + } + Err(e) => { + for pin in pins { + result.refuse(&pin.uuid, e.clone()); + } + return result; + } + }; + let composer_json = view.read("composer.json").await.ok().flatten(); + let custom_repos = declares_custom_repositories(composer_json.as_deref()); + + let mut hits: Vec = Vec::new(); + for pin in pins { + let Some((name, version)) = pin.name_version() else { + result.refuse(&pin.uuid, format!("{} is not a composer purl", pin.purl)); + continue; + }; + let ComposerEntry::Found(start, end) = find_composer_entry(&content, &name, &version) + else { + continue; + }; + let entry = &content[start..=end]; + let Some((d_start, d_end)) = dist_range(&content, (start, end)) else { + continue; + }; + let dist = &content[d_start..=d_end]; + let wired = json_string_field(dist, "url") + .and_then(|u| ctx.hosted_uuid(u)) + .is_some_and(|u| u == pin.uuid); + if !wired { + continue; + } + // Packagist gate (module docs). + match json_string_field(entry, "notification-url").map(|u| u.replace("\\/", "/")) { + Some(u) if u == PACKAGIST_NOTIFY => {} + Some(u) => { + result.refuse( + &pin.uuid, + format!("{name} was locked from {u}, not packagist, whose metadata this restore cannot read"), + ); + continue; + } + None => match &custom_repos { + Ok(false) => {} + Ok(true) => { + result.refuse( + &pin.uuid, + format!( + "composer.json declares custom repositories and the {name} lock \ + entry does not record packagist as its origin" + ), + ); + continue; + } + Err(why) => { + result.refuse(&pin.uuid, why.clone()); + continue; + } + }, + } + let Some(locked_version) = json_string_field(entry, "version") else { + continue; + }; + hits.push(Hit { + uuid: pin.uuid.clone(), + name: name.to_ascii_lowercase(), + locked_version: locked_version.to_string(), + purl_version: version, + reference: json_string_field(dist, "reference").map(|r| r.replace("\\/", "/")), + }); + } + if hits.is_empty() { + return result; + } + + let lookups = hits.iter().map(|h| async move { + ( + h.uuid.clone(), + ctx.client + .packagist_versions(&h.name, is_dev_version(&h.locked_version)) + .await, + ) + }); + let metadata: BTreeMap, String>> = + futures_util::future::join_all(lookups) + .await + .into_iter() + .collect(); + + let escaped = content.contains("\\/"); + let eol = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut content = content; + let mut changed = false; + for hit in &hits { + let label = format!("{}@{}", hit.name, hit.locked_version); + let versions = match metadata.get(&hit.uuid) { + Some(Ok(v)) => v, + Some(Err(why)) => { + result.refuse(&hit.uuid, format!("{label}: {why}")); + continue; + } + None => continue, + }; + let upstream = match pick_version(versions, &hit.locked_version) { + Ok(v) => v, + Err(why) => { + result.refuse(&hit.uuid, format!("{label}: {why}")); + continue; + } + }; + let Some(dist) = upstream.get("dist").and_then(Value::as_object) else { + result.refuse( + &hit.uuid, + format!("{label}: packagist serves no dist for it"), + ); + continue; + }; + let upstream_ref = dist.get("reference").and_then(Value::as_str); + if upstream_ref != hit.reference.as_deref() { + result.refuse( + &hit.uuid, + format!( + "{label}: the lock pins dist.reference {:?} but packagist now serves {:?}", + hit.reference.as_deref().unwrap_or(""), + upstream_ref.unwrap_or("") + ), + ); + continue; + } + // Offsets moved with every earlier hit's edit: locate again. + let ComposerEntry::Found(start, end) = + find_composer_entry(&content, &hit.name, &hit.purl_version) + else { + continue; + }; + let Some((d_start, d_end)) = dist_range(&content, (start, end)) else { + continue; + }; + let outer = indent_at(&content, d_start).to_string(); + let block = &content[d_start..=d_end]; + let inner = block + .split('\n') + .nth(1) + .map(|l| &l[..l.len() - l.trim_start_matches([' ', '\t']).len()]) + .filter(|i| !i.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{outer} ")); + let Some(dist_text) = render_block( + "dist", + dist, + &["type", "url", "reference", "shasum"], + &inner, + &outer, + eol, + escaped, + ) else { + result.refuse( + &hit.uuid, + format!("{label}: packagist's dist has no type or url"), + ); + continue; + }; + // Re-insert the source the rewriter dropped — unless the entry + // still carries one (a lock redirected before the drop, or a + // source it could not remove). + let has_source = content[start..d_start].contains(SOURCE_KEY); + let source_text = match upstream.get("source").and_then(Value::as_object) { + Some(source) if !has_source => { + match render_block( + "source", + source, + &["type", "url", "reference"], + &inner, + &outer, + eol, + escaped, + ) { + Some(text) => format!("{text},{eol}{outer}"), + None => { + result.refuse( + &hit.uuid, + format!("{label}: packagist's source has no type or url"), + ); + continue; + } + } + } + _ => String::new(), + }; + content.replace_range(d_start..=d_end, &format!("{source_text}{dist_text}")); + changed = true; + result.handled.insert(hit.uuid.clone()); + } + if changed { + view.write(COMPOSER_LOCK, content); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn dev_versions_use_the_dev_metadata_file() { + assert!(is_dev_version("dev-main")); + assert!(is_dev_version("2.x-dev")); + assert!(is_dev_version("2.1.x-DEV")); + assert!(!is_dev_version("v2.0.0")); + assert!(!is_dev_version("2.0.0-beta1")); + } + + #[test] + fn blocks_render_in_composer_order_and_slash_style() { + let dist = json!({ + "shasum": "", + "url": "https://api.github.com/repos/a/b/zipball/abc", + "type": "zip", + "reference": "abc", + "mirrors": [{"url": "x"}] + }); + let obj = dist.as_object().unwrap(); + let keys = ["type", "url", "reference", "shasum"]; + assert_eq!( + render_block( + "dist", + obj, + &keys, + " ", + " ", + "\n", + false + ) + .unwrap(), + "\"dist\": {\n \"type\": \"zip\",\n \"url\": \ + \"https://api.github.com/repos/a/b/zipball/abc\",\n \ + \"reference\": \"abc\",\n \"shasum\": \"\"\n }" + ); + let escaped = render_block("dist", obj, &keys, " ", "", "\r\n", true).unwrap(); + assert!(escaped.contains("\"https:\\/\\/api.github.com\\/repos\\/a\\/b\\/zipball\\/abc\"")); + assert!(escaped.contains(",\r\n \"reference\"")); + // No shasum upstream: the key stays absent (fixture no-shasum-key). + let bare = json!({"type": "zip", "url": "u", "reference": "r"}); + assert!(!render_block( + "dist", + bare.as_object().unwrap(), + &keys, + " ", + "", + "\n", + false + ) + .unwrap() + .contains("shasum")); + assert!(render_block( + "dist", + json!({"url": "u"}).as_object().unwrap(), + &keys, + "", + "", + "\n", + false + ) + .is_none()); + } + + #[test] + fn custom_repositories_gate() { + assert_eq!(declares_custom_repositories(None), Ok(false)); + assert_eq!(declares_custom_repositories(Some("{}")), Ok(false)); + assert_eq!( + declares_custom_repositories(Some(r#"{"repositories": []}"#)), + Ok(false) + ); + assert_eq!( + declares_custom_repositories(Some( + r#"{"repositories": [{"type": "vcs", "url": "https://git.example/x"}]}"# + )), + Ok(true) + ); + assert_eq!( + declares_custom_repositories(Some(r#"{"repositories": {"packagist.org": false}}"#)), + Ok(true) + ); + assert!(declares_custom_repositories(Some("{")).is_err()); + } + + #[test] + fn version_pick_prefers_the_pretty_spelling() { + let versions = vec![ + json!({"version": "2.0.0"}), + json!({"version": "v2.0.0"}), + json!({"version": "1.0.0"}), + ]; + assert_eq!( + pick_version(&versions, "v2.0.0").unwrap()["version"], + "v2.0.0" + ); + assert_eq!( + pick_version(&versions, "1.0.0").unwrap()["version"], + "1.0.0" + ); + assert_eq!( + pick_version(&[json!({"version": "v3.0.0"})], "3.0.0").unwrap()["version"], + "v3.0.0" + ); + assert!(pick_version(&versions, "9.9.9").is_err()); + let dup = vec![json!({"version": "2.0.0"}), json!({"version": "2.0.0"})]; + assert!(pick_version(&dup, "2.0.0").is_err()); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs new file mode 100644 index 000000000..cae1d6994 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -0,0 +1,960 @@ +//! RubyGems upstream restore: a hosted gem goes back to the upstream `GEM` +//! section of `Gemfile.lock` / `gems.locked`, and the `source "" do … end` block the hosted rewriter (`rewrite_gem`) put into +//! `Gemfile` / `gems.rb` is undone. Discovery records only the lock; the +//! manifest is its bundler sibling ([`bundler_manifest_for`]). +//! +//! The lock states a hosted gem can be in, and what each restore does: +//! +//! * **Converged** (bundler ≥ 2.2 after the rewrite or an unfrozen install; +//! the rewriter writes it itself in the `CHECKSUMS` era): the gem's spec +//! (+ its dependency sub-lines) sits in a `GEM` section of its own whose +//! single remote is the patch registry. The section is deleted and the +//! spec moves back, in name order, into the upstream `GEM` section: the +//! one remaining non-Socket single-remote section, or — when there are +//! several — the one naming the manifest's global `source`, else the +//! rubygems.org one; anything else is ambiguous and refused. +//! * **Merged** (bundler ≤ 2.1 writes every rubygems source into ONE `GEM` +//! section): the Socket `remote:` line is dropped from it. +//! * **Mixed** (bundler < 2.6: the rewriter edits only the manifest and the +//! lock still records the upstream source; see `redirect_gem_frozen_install`): +//! discovery finds no pin for it (no lock wiring), so it is only unwound +//! when a caller hands in a pin for it — the manifest block alone is then +//! undone, and the untouched lock says exactly how the gem was declared. +//! +//! In every state a `CHECKSUMS` entry is re-pinned to the upstream sha256 +//! from the rubygems.org compact index (`info/`, what bundler itself +//! records; other upstream remotes are refused: their index may need +//! credentials and is not the default registry), and the `DEPENDENCIES` +//! source pin (`name (= v)!`) loses its `!`. +//! +//! What the rewrite discards is NOT derivable, so the restore picks the +//! installable reading and documents it: +//! +//! * the original declaration's version constraint (`"~> 7.0"`, or none), +//! quote style, parenthesized form and comment: the gem comes back as +//! `gem "", ""[, ]` — the exact pin the lock +//! records as `name (= version)`, so the pair stays frozen-installable +//! (the mixed state keeps the lock's own constraint instead); +//! * the blank lines and indentation the rewriter's `^\s*gem` match +//! swallowed before the declaration: see [`declaration_prefix`]; +//! * whether the gem was declared at all. The rewriter APPENDS a block for a +//! transitive gem and adds its `DEPENDENCIES` entry, but a direct gem on +//! the manifest's last line produces the same bytes. The block and entry +//! are removed only when that is provable: an option-less block the +//! rewriter could not have written in place (a blank line before it — +//! the in-place match swallows every blank line before the declaration) +//! that another locked spec depends on. Otherwise the gem is kept as a +//! direct pin: a stray exact pin installs the same bytes, while dropping +//! a real declaration would stop `Bundler.require` loading the gem. +//! * a `CHECKSUMS` entry the rewriter ADDED is only recognizable next to +//! bundler's own bare entry for the gem (then it is dropped); otherwise it +//! is re-pinned in place. + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; + +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::utils::line_endings::{to_lf, LineEndings}; +use crate::vendor::gem::{gem_declaration_any, quoted_literal}; +use crate::vendor::gemfile_lock::{ + bundler_manifest_for, parse_spec, same_remote, split_checksum_entry, BUNDLER_LOCKS, +}; + +/// The default upstream `GEM` remote. +const RUBYGEMS_REMOTE: &str = "https://rubygems.org/"; + +// ── lock model (LF text, split on `\n`) ───────────────────────────────────── + +fn is_header(line: &str) -> bool { + !line.is_empty() && !line.starts_with(' ') +} + +/// Exactly `n` spaces of indent, then content. +fn indented(line: &str, n: usize) -> Option<&str> { + let rest = line.get(n..)?; + (line[..n].bytes().all(|b| b == b' ') && !rest.starts_with(' ') && !rest.is_empty()) + .then_some(rest) +} + +/// One 4-space spec entry and its 6-space dependency sub-lines. +struct Entry { + line: usize, + /// The last sub-line (== `line` when it has none). + last: usize, + name: String, + version: String, + platform: bool, +} + +/// One `GEM` section: `[start, end)` runs from its header to the next +/// header (its trailing blank separator included). +struct GemSec { + start: usize, + end: usize, + remotes: Vec<(usize, String)>, + specs_line: Option, + entries: Vec, +} + +/// `[start, end)` of every section, with its header. +fn section_ranges<'l>(lines: &[&'l str]) -> Vec<(&'l str, usize, usize)> { + let mut out: Vec<(&str, usize, usize)> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if is_header(line) { + if let Some(last) = out.last_mut() { + last.2 = i; + } + out.push((line.trim_end(), i, lines.len())); + } + } + out +} + +fn gem_sections(lines: &[&str]) -> Vec { + let mut out = Vec::new(); + for (header, start, end) in section_ranges(lines) { + if header != "GEM" { + continue; + } + let mut sec = GemSec { + start, + end, + remotes: Vec::new(), + specs_line: None, + entries: Vec::new(), + }; + for (k, line) in lines.iter().enumerate().take(end).skip(start + 1) { + if let Some(key) = indented(line, 2) { + if let Some(url) = key.strip_prefix("remote:") { + sec.remotes.push((k, url.trim().to_string())); + } else if key.trim_end() == "specs:" { + sec.specs_line = Some(k); + } + } else if let Some(entry) = indented(line, 4).filter(|_| sec.specs_line.is_some()) { + let spec = parse_spec(entry.trim_end()); + sec.entries.push(Entry { + line: k, + last: k, + name: spec.map(|s| s.name).unwrap_or(entry).to_string(), + version: spec.map(|s| s.version).unwrap_or_default().to_string(), + platform: spec.is_none_or(|s| s.platform.is_some()), + }); + } else if line.starts_with(" ") { + if let Some(e) = sec.entries.last_mut() { + e.last = k; + } + } + } + out.push(sec); + } + out +} + +/// The line range of the column-0 section `header`, header excluded. +fn named_section(lines: &[&str], header: &str) -> Option<(usize, usize)> { + section_ranges(lines) + .into_iter() + .find(|(h, _, _)| *h == header) + .map(|(_, s, e)| (s + 1, e)) +} + +/// The `DEPENDENCIES` entry of `name`: its line and trimmed text. +fn dependency_line<'l>(lines: &[&'l str], name: &str) -> Option<(usize, &'l str)> { + let (s, e) = named_section(lines, "DEPENDENCIES")?; + (s..e).find_map(|k| { + let entry = indented(lines[k], 2)?.trim_end(); + let dep = entry.split([' ', '(']).next()?.trim_end_matches('!'); + (dep == name).then_some((k, entry)) + }) +} + +/// Whether another locked spec depends on `name` (a 6-space sub-line). +fn is_subdependency(lines: &[&str], name: &str) -> bool { + lines + .iter() + .any(|l| indented(l, 6).is_some_and(|d| d.split([' ', '(']).next() == Some(name))) +} + +/// How the lock wires patch `uuid`. +enum Wiring { + /// A `GEM` section of its own (index into the sections). + Own(usize), + /// One `remote:` line (the line index) of a multi-remote section. + Merged(usize, usize), +} + +fn wiring(secs: &[GemSec], uuid: &str, ctx: &Ctx<'_>) -> Result, String> { + let hits: Vec<(usize, usize)> = secs + .iter() + .enumerate() + .flat_map(|(i, s)| { + s.remotes + .iter() + .filter(|(_, r)| ctx.hosted_uuid(r).as_deref() == Some(uuid)) + .map(move |(k, _)| (i, *k)) + }) + .collect(); + match hits.as_slice() { + [] => Ok(None), + [(i, k)] if secs[*i].remotes.len() > 1 => Ok(Some(Wiring::Merged(*i, *k))), + [(i, _)] => Ok(Some(Wiring::Own(*i))), + _ => Err("several GEM remotes name the patch".to_string()), + } +} + +/// The upstream section a spec moves back to (module docs). +fn choose_upstream( + secs: &[GemSec], + own: usize, + globals: &[String], + ctx: &Ctx<'_>, +) -> Result { + let candidates: Vec = (0..secs.len()) + .filter(|&i| { + i != own + && secs[i].remotes.len() == 1 + && ctx.hosted_uuid(&secs[i].remotes[0].1).is_none() + }) + .collect(); + let remote = |i: &usize| secs[*i].remotes[0].1.as_str(); + match candidates.as_slice() { + [] => Err("the lock has no upstream GEM section to move it back to".to_string()), + [one] => Ok(*one), + _ => { + let single = |keep: &dyn Fn(&usize) -> bool| match candidates + .iter() + .filter(|i| keep(i)) + .collect::>() + .as_slice() + { + [one] => Some(**one), + _ => None, + }; + if let Some(one) = single(&|i| globals.iter().any(|g| same_remote(g, remote(i)))) + .or_else(|| single(&|i| same_remote(remote(i), RUBYGEMS_REMOTE))) + { + return Ok(one); + } + Err(format!( + "the lock has {} upstream GEM sections and none is singled out by the \ + manifest's global source or rubygems.org", + candidates.len() + )) + } + } +} + +/// The line after which a spec named `name-version` sorts into `sec` +/// (bundler writes specs sorted by full name). +fn insertion_point(sec: &GemSec, full_name: &str) -> Option { + let pred = sec + .entries + .iter() + .rfind(|e| { + let full = if e.version.is_empty() { + e.name.clone() + } else { + format!("{}-{}", e.name, e.version) + }; + full.as_str() < full_name + }); + pred.map(|e| e.last).or(sec.specs_line) +} + +/// A lock restore step's outcome. +enum LockEdit { + /// The `CHECKSUMS` entry must be re-pinned from `remote` first. + NeedsSha { + remote: Option, + }, + Done(String), +} + +/// One pin's coordinates. +struct Gem<'p> { + uuid: &'p str, + name: String, + version: String, +} + +/// Restore `gem` in the LF lock text (module docs). `transitive`: drop the +/// `DEPENDENCIES` entry instead of unpinning it. +fn lock_edit( + lock: &str, + gem: &Gem<'_>, + globals: &[String], + transitive: bool, + sha: Option<&str>, + ctx: &Ctx<'_>, +) -> Result { + let lines: Vec<&str> = lock.split('\n').collect(); + let secs = gem_sections(&lines); + let wiring = wiring(&secs, gem.uuid, ctx)?; + let mut drop: BTreeSet = BTreeSet::new(); + let mut replace: BTreeMap = BTreeMap::new(); + let mut insert_after: BTreeMap> = BTreeMap::new(); + let is_ours = |e: &Entry| e.name == gem.name && e.version == gem.version && !e.platform; + let checksum_remote = match &wiring { + Some(Wiring::Own(si)) => { + let sec = &secs[*si]; + let entry = match sec.entries.as_slice() { + [e] if is_ours(e) => e, + _ => { + return Err(format!( + "its Socket GEM section does not lock exactly {} ({})", + gem.name, gem.version + )) + } + }; + let up = choose_upstream(&secs, *si, globals, ctx)?; + let at = insertion_point(&secs[up], &format!("{}-{}", gem.name, gem.version)) + .ok_or("the upstream GEM section has no `specs:` list")?; + drop.extend(sec.start..sec.end); + insert_after.insert(at, lines[entry.line..=entry.last].to_vec()); + Some(secs[up].remotes[0].1.clone()) + } + Some(Wiring::Merged(si, line)) => { + let sec = &secs[*si]; + if !sec.entries.iter().any(is_ours) { + return Err(format!( + "its merged GEM section does not lock {} ({})", + gem.name, gem.version + )); + } + drop.insert(*line); + let rest: Vec<&String> = sec + .remotes + .iter() + .filter(|(k, _)| k != line) + .map(|(_, r)| r) + .collect(); + match rest.as_slice() { + [one] => Some((*one).clone()), + _ => None, + } + } + None => { + let holders: Vec<&GemSec> = secs + .iter() + .filter(|s| s.entries.iter().any(is_ours)) + .collect(); + match holders.as_slice() { + [s] if s.remotes.len() == 1 => Some(s.remotes[0].1.clone()), + _ => None, + } + } + }; + if wiring.is_some() { + if let Some((k, entry)) = dependency_line(&lines, &gem.name) { + if transitive { + drop.insert(k); + } else if let Some(unpinned) = entry.strip_suffix('!') { + replace.insert(k, format!(" {unpinned}")); + } + } + } + if let Some((s, e)) = named_section(&lines, "CHECKSUMS") { + let mut with_sha = Vec::new(); + let mut bare = false; + for (k, line) in lines.iter().enumerate().take(e).skip(s) { + let Some(entry) = indented(line, 2) else { + continue; + }; + let Some((name, token, tail)) = split_checksum_entry(entry.trim_end()) else { + continue; + }; + if name != gem.name || token != gem.version { + continue; + } + if tail.contains("sha256=") { + with_sha.push(k); + } else { + bare = true; + } + } + match with_sha.as_slice() { + [] => {} + [k] if bare => { + drop.insert(*k); + } + [k] => { + let Some(sha) = sha else { + return Ok(LockEdit::NeedsSha { + remote: checksum_remote, + }); + }; + let re = Regex::new(r"sha256=[0-9A-Fa-f]*").expect("static sha256 regex is valid"); + replace.insert( + *k, + re.replace(lines[*k], format!("sha256={sha}")).into_owned(), + ); + } + _ => return Err("CHECKSUMS pins it more than once".to_string()), + } + } + let mut out: Vec = Vec::with_capacity(lines.len()); + for (k, line) in lines.iter().enumerate() { + if !drop.contains(&k) { + out.push(replace.get(&k).cloned().unwrap_or_else(|| line.to_string())); + } + if let Some(block) = insert_after.get(&k) { + out.extend(block.iter().map(|l| l.to_string())); + } + } + Ok(LockEdit::Done(out.join("\n"))) +} + +// ── manifest (Gemfile / gems.rb) ──────────────────────────────────────────── + +/// The rewriter's `source "" do\n gem "n", "v"[, opts]\nend` block. +struct Block { + start: usize, + /// Past the `end` line's line break. + end: usize, + opts: Option, + /// Whether a line break followed `end` (the declaration's own). + eol: bool, +} + +fn find_block( + text: &str, + gem: &Gem<'_>, + rel: &str, + ctx: &Ctx<'_>, +) -> Result, String> { + let re = Regex::new(&format!( + r#"(?m)^source "([^"\r\n]*)" do\r?\n gem "{}", "{}"(?:, ([^\r\n]*))?\r?\nend(\r?\n|\z)"#, + regex::escape(&gem.name), + regex::escape(&gem.version) + )) + .expect("source-block regex from escaped coordinates is valid"); + let hits: Vec = re + .captures_iter(text) + .filter(|c| ctx.hosted_uuid(&c[1]).as_deref() == Some(gem.uuid)) + .map(|c| { + let m = c.get(0).expect("group 0 is the whole match"); + Block { + start: m.start(), + end: m.end(), + opts: c.get(2).map(|o| o.as_str().to_string()), + eol: !c[3].is_empty(), + } + }) + .collect(); + let rest_mentions = |b: Option<&Block>| { + let rest = match b { + Some(b) => format!("{}{}", &text[..b.start], &text[b.end..]), + None => text.to_string(), + }; + rest.contains(gem.uuid) + }; + match hits.as_slice() { + [] if rest_mentions(None) => Err(format!( + "{rel} wires it in a shape other than the source block socket-patch writes" + )), + [] => Ok(None), + [_] if rest_mentions(hits.first()) => Err(format!( + "{rel} names the patch outside the source block socket-patch writes" + )), + [_] => Ok(hits.into_iter().next()), + _ => Err(format!("{rel} declares it in several Socket source blocks")), + } +} + +/// The line before byte `at` (without its line break); `None` at the start. +fn line_before(text: &str, at: usize) -> Option<&str> { + let before = text[..at].strip_suffix('\n')?; + let before = before.strip_suffix('\r').unwrap_or(before); + Some(&before[before.rfind('\n').map_or(0, |i| i + 1)..]) +} + +fn indent_of(line: &str) -> &str { + &line[..line.len() - line.trim_start().len()] +} + +/// Whether the rewriter can only have APPENDED `block` (a transitive gem): +/// its in-place rewrite swallows every blank line before the declaration, +/// so a blank line right before the block rules it out. +fn provably_appended(text: &str, block: &Block) -> bool { + line_before(text, block.start).is_some_and(|l| l.trim().is_empty()) +} + +/// The blank line + indent to put before a declaration restored in place of +/// `block`. The rewriter's `^\s*gem` match swallowed whatever whitespace +/// preceded the original line, which no file records, so this re-derives the +/// conventional layout from the neighbors: inside a block (`group … do`) +/// or right after another declaration or a comment, the neighbor's indent +/// and no blank line; after anything else (`source`, `ruby`, `end`, …) one +/// blank line and the indent of the declaration that follows, if any. +fn declaration_prefix(text: &str, block: &Block, eol: &str) -> String { + let Some(prev) = line_before(text, block.start) else { + return String::new(); + }; + let trimmed = prev.trim(); + if trimmed.is_empty() { + return String::new(); + } + let code = trimmed.split('#').next().unwrap_or_default().trim_end(); + if code == "do" || code.ends_with(" do") || (code.ends_with('|') && code.contains(" do |")) { + return format!("{} ", indent_of(prev)); + } + if trimmed.starts_with('#') || gem_declaration_any(trimmed).is_some() { + return indent_of(prev).to_string(); + } + let next = text[block.end..].split('\n').next().unwrap_or_default(); + let indent = if gem_declaration_any(next.trim()).is_some() { + indent_of(next) + } else { + "" + }; + format!("{eol}{indent}") +} + +/// How the gem comes back into the manifest. +enum Decl { + /// Gone: the block was the rewriter's append for a transitive gem. + Transitive, + /// `gem ""[, ]`; `args` is the version constraint list + /// (`, "7.0.0"`). + Direct(String), +} + +fn restore_manifest(text: &str, block: &Block, gem: &Gem<'_>, decl: &Decl) -> String { + let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let replacement = match decl { + Decl::Transitive => String::new(), + Decl::Direct(args) => { + let opts = block + .opts + .as_deref() + .map(|o| format!(", {o}")) + .unwrap_or_default(); + format!( + "{}gem \"{}\"{args}{opts}{}", + if provably_appended(text, block) { + String::new() + } else { + declaration_prefix(text, block, eol) + }, + gem.name, + if block.eol { eol } else { "" } + ) + } + }; + format!( + "{}{replacement}{}", + &text[..block.start], + &text[block.end..] + ) +} + +/// The manifest's global `source ""` declarations (no block). +fn global_sources(manifest: &str) -> Vec { + manifest + .lines() + .filter_map(|l| { + let rest = l.trim().strip_prefix("source")?.trim_start(); + let (rest, paren) = match rest.strip_prefix('(') { + Some(r) => (r.trim_start(), true), + None => (rest, false), + }; + let (_, url, tail) = quoted_literal(rest)?; + let tail = tail.trim_start(); + let tail = if paren { tail.strip_prefix(')')? } else { tail }; + let code = tail.split('#').next().unwrap_or_default().trim(); + code.is_empty().then(|| url.to_string()) + }) + .collect() +} + +/// The version-constraint args of a `DEPENDENCIES` entry (`rails (~> 7.0, +/// >= 7.0.1)` → `, "~> 7.0", ">= 7.0.1"`). +fn constraint_args(entry: &str) -> String { + let entry = entry.trim_end_matches('!'); + let Some((_, rest)) = entry.split_once(" (") else { + return String::new(); + }; + rest.trim_end_matches(')') + .split(", ") + .filter(|c| !c.is_empty()) + .map(|c| format!(", \"{c}\"")) + .collect() +} + +pub(crate) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + for lock_rel in BUNDLER_LOCKS { + let manifest_rel = bundler_manifest_for(lock_rel); + if !files.iter().any(|f| f == lock_rel || f == manifest_rel) { + continue; + } + let (lock_raw, manifest_raw) = + match (view.read(lock_rel).await, view.read(manifest_rel).await) { + (Ok(l), Ok(m)) => (l, m), + (Err(e), _) | (_, Err(e)) => { + for pin in pins { + result.refuse(&pin.uuid, e.clone()); + } + continue; + } + }; + let endings = lock_raw.as_deref().map(LineEndings::of); + let mut lock: Option = lock_raw.as_deref().map(|t| to_lf(t).into_owned()); + let mut manifest = manifest_raw.clone(); + let globals = manifest.as_deref().map(global_sources).unwrap_or_default(); + for pin in pins { + if result.refused.contains_key(&pin.uuid) { + continue; + } + let Some((name, version)) = pin.name_version() else { + result.refuse(&pin.uuid, format!("{} is not a gem purl", pin.purl)); + continue; + }; + let gem = Gem { + uuid: &pin.uuid, + name, + version, + }; + match restore_one( + &gem, + lock.as_deref(), + manifest.as_deref(), + manifest_rel, + &globals, + ctx, + ) + .await + { + Ok(None) => {} + Ok(Some((next_lock, next_manifest))) => { + if endings == Some(LineEndings::Mixed) && next_lock != lock { + result.refuse( + &pin.uuid, + format!("{lock_rel} mixes CRLF and LF line endings"), + ); + continue; + } + lock = next_lock; + manifest = next_manifest; + result.handled.insert(pin.uuid.clone()); + } + Err(why) => result.refuse(&pin.uuid, why), + } + } + if let (Some(next), Some(endings)) = (lock, endings) { + let next = endings.restore(&next).into_owned(); + if lock_raw.as_deref() != Some(next.as_str()) { + view.write(lock_rel, next); + } + } + if manifest != manifest_raw { + if let Some(next) = manifest { + view.write(manifest_rel, next); + } + } + } + result +} + +/// Restore one gem in a lock + manifest pair: `Ok(None)` when neither wires +/// it, else the next `(lock, manifest)` texts. +async fn restore_one( + gem: &Gem<'_>, + lock: Option<&str>, + manifest: Option<&str>, + manifest_rel: &str, + globals: &[String], + ctx: &Ctx<'_>, +) -> Result, Option)>, String> { + let block = match manifest { + Some(text) => find_block(text, gem, manifest_rel, ctx)?, + None => None, + }; + let lines: Vec<&str> = lock.map(|l| l.split('\n').collect()).unwrap_or_default(); + let wired = match lock { + Some(_) => wiring(&gem_sections(&lines), gem.uuid, ctx)?.is_some(), + None => false, + }; + if !wired && block.is_none() { + return Ok(None); + } + let decl = if wired { + let transitive = block.as_ref().is_some_and(|b| { + b.opts.is_none() + && manifest.is_some_and(|m| provably_appended(m, b)) + && is_subdependency(&lines, &gem.name) + }); + if transitive { + Decl::Transitive + } else { + Decl::Direct(format!(", \"{}\"", gem.version)) + } + } else if lock.is_some() { + // Mixed state: the lock was never touched, so its DEPENDENCIES say + // how (and whether) the manifest declared the gem. + match dependency_line(&lines, &gem.name) { + Some((_, entry)) => Decl::Direct(constraint_args(entry)), + None => Decl::Transitive, + } + } else { + Decl::Direct(format!(", \"{}\"", gem.version)) + }; + let transitive = matches!(decl, Decl::Transitive); + let next_lock = match lock { + None => None, + Some(text) => Some( + match lock_edit(text, gem, globals, transitive, None, ctx)? { + LockEdit::Done(next) => next, + LockEdit::NeedsSha { remote } => { + let remote = remote.ok_or("its upstream GEM remote is ambiguous")?; + if !same_remote(&remote, RUBYGEMS_REMOTE) { + return Err(format!( + "its upstream GEM remote {remote} is not rubygems.org, so its CHECKSUMS \ + sha256 cannot be re-derived" + )); + } + let sha = ctx + .client + .rubygems_sha256(&gem.name, &gem.version) + .await + .map_err(|why| format!("{} {}: {why}", gem.name, gem.version))?; + match lock_edit(text, gem, globals, transitive, Some(&sha), ctx)? { + LockEdit::Done(next) => next, + LockEdit::NeedsSha { .. } => unreachable!("a sha was supplied"), + } + } + }, + ), + }; + let next_manifest = match (manifest, &block) { + (Some(text), Some(b)) => Some(restore_manifest(text, b, gem, &decl)), + _ => manifest.map(str::to_string), + }; + Ok(Some((next_lock, next_manifest))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "77777777-7777-7777-7777-777777777777"; + const IDX: &str = "https://patch.socket.dev/patch-registry/gem/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/"; + + fn ctx_with<'a>(client: &'a super::super::UpstreamClient) -> Ctx<'a> { + Ctx { + client, + origins: &[], + bun_lockb: false, + } + } + + fn gem() -> Gem<'static> { + Gem { + uuid: UUID, + name: "rails".into(), + version: "7.0.0".into(), + } + } + + fn done(e: LockEdit) -> String { + match e { + LockEdit::Done(t) => t, + LockEdit::NeedsSha { .. } => panic!("unexpected sha request"), + } + } + + #[test] + fn converged_section_moves_back_in_name_order() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let hosted = format!( + "GEM\n remote: {IDX}\n specs:\n rails (7.0.0)\n rack (>= 2)\n\nGEM\n \ + remote: https://rubygems.org/\n specs:\n puma (6.0.0)\n rack (3.0.0)\n \ + zeitwerk (2.6.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n puma\n rails (= 7.0.0)!\n\n\ + BUNDLED WITH\n 2.4.0\n" + ); + let want = "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.0.0)\n rack \ + (3.0.0)\n rails (7.0.0)\n rack (>= 2)\n zeitwerk (2.6.0)\n\nPLATFORMS\n \ + ruby\n\nDEPENDENCIES\n puma\n rails (= 7.0.0)\n\nBUNDLED WITH\n 2.4.0\n"; + // No CHECKSUMS (bundler 2.2–2.5): no registry lookup, offline works. + assert_eq!( + done(lock_edit(&hosted, &gem(), &[], false, None, &ctx).unwrap()), + want + ); + // Transitive: the DEPENDENCIES entry the rewriter added goes. + let out = done(lock_edit(&hosted, &gem(), &[], true, None, &ctx).unwrap()); + assert!(out.contains("DEPENDENCIES\n puma\n\n"), "{out}"); + } + + #[test] + fn checksums_need_the_upstream_sha() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let hosted = format!( + "GEM\n remote: https://rubygems.org/\n specs:\n\nGEM\n remote: {IDX}\n specs:\n \ + rails (7.0.0)\n\nDEPENDENCIES\n rails (= 7.0.0)!\n\nCHECKSUMS\n rails (7.0.0) \ + sha256={}\n", + "d".repeat(64) + ); + match lock_edit(&hosted, &gem(), &[], false, None, &ctx).unwrap() { + LockEdit::NeedsSha { remote } => { + assert_eq!(remote.as_deref(), Some("https://rubygems.org/")) + } + LockEdit::Done(_) => panic!("CHECKSUMS entry left patched"), + } + let sha = "2".repeat(64); + assert_eq!( + done(lock_edit(&hosted, &gem(), &[], false, Some(&sha), &ctx).unwrap()), + format!( + "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.0)\n\nDEPENDENCIES\n \ + rails (= 7.0.0)\n\nCHECKSUMS\n rails (7.0.0) sha256={sha}\n" + ) + ); + // The rewriter's ADDED entry next to bundler's bare one is dropped. + let added = hosted.replace("CHECKSUMS\n", "CHECKSUMS\n rails (7.0.0)\n"); + assert!( + done(lock_edit(&added, &gem(), &[], false, None, &ctx).unwrap()) + .ends_with("CHECKSUMS\n rails (7.0.0)\n") + ); + } + + #[test] + fn merged_section_drops_only_the_socket_remote() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let hosted = format!( + "GEM\n remote: https://rubygems.org/\n remote: {IDX}\n specs:\n puma (6.0.0)\n \ + rails (7.0.0)\n\nDEPENDENCIES\n puma\n rails (= 7.0.0)!\n\nBUNDLED WITH\n 2.1.4\n" + ); + assert_eq!( + done(lock_edit(&hosted, &gem(), &[], false, None, &ctx).unwrap()), + "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.0.0)\n rails (7.0.0)\n\n\ + DEPENDENCIES\n puma\n rails (= 7.0.0)\n\nBUNDLED WITH\n 2.1.4\n" + ); + } + + #[test] + fn ambiguous_or_foreign_sections_are_refused() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let two_upstreams = format!( + "GEM\n remote: https://gems.example/\n specs:\n\nGEM\n remote: https://mirror.example/\n \ + specs:\n\nGEM\n remote: {IDX}\n specs:\n rails (7.0.0)\n\nDEPENDENCIES\n rails (= 7.0.0)!\n" + ); + assert!(lock_edit(&two_upstreams, &gem(), &[], false, None, &ctx).is_err()); + // The manifest's global source singles one out. + let globals = vec!["https://mirror.example".to_string()]; + let out = done(lock_edit(&two_upstreams, &gem(), &globals, false, None, &ctx).unwrap()); + assert!( + out.contains("remote: https://mirror.example/\n specs:\n rails (7.0.0)\n"), + "{out}" + ); + // A Socket section locking another gem too is not ours to split. + let extra = format!( + "GEM\n remote: https://rubygems.org/\n specs:\n\nGEM\n remote: {IDX}\n specs:\n \ + rack (3.0.0)\n rails (7.0.0)\n\nDEPENDENCIES\n rails (= 7.0.0)!\n" + ); + assert!(lock_edit(&extra, &gem(), &[], false, None, &ctx).is_err()); + let none = format!( + "GEM\n remote: {IDX}\n specs:\n rails (7.0.0)\n\nDEPENDENCIES\n rails!\n" + ); + assert!(lock_edit(&none, &gem(), &[], false, None, &ctx).is_err()); + } + + #[test] + fn manifest_blocks_restore_in_place() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let block = format!("source \"{IDX}\" do\n gem \"rails\", \"7.0.0\"\nend"); + let direct = Decl::Direct(", \"7.0.0\"".into()); + let run = |text: &str, decl: &Decl| { + let b = find_block(text, &gem(), "Gemfile", &ctx).unwrap().unwrap(); + restore_manifest(text, &b, &gem(), decl) + }; + // After a global source: one blank line comes back. + let t = format!("source \"https://rubygems.org\"\n{block}\ngem \"puma\"\n"); + assert_eq!( + run(&t, &direct), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\ngem \"puma\"\n" + ); + // Inside a group: the group's indent + 2, options kept. + let t = format!( + "group :test do\nsource \"{IDX}\" do\n gem \"rails\", \"7.0.0\", require: false\nend\nend\n" + ); + assert_eq!( + run(&t, &direct), + "group :test do\n gem \"rails\", \"7.0.0\", require: false\nend\n" + ); + // CRLF manifest (the rewriter wrote its block in LF). + let t = format!("source \"https://rubygems.org\"\r\ngem \"puma\"\r\n{block}\n"); + assert_eq!( + run(&t, &direct), + "source \"https://rubygems.org\"\r\ngem \"puma\"\r\ngem \"rails\", \"7.0.0\"\r\n" + ); + // Transitive append removed; mixed-state constraint kept. + let t = format!("source \"https://rubygems.org\"\n\ngem \"puma\"\n\n{block}\n"); + assert_eq!( + run(&t, &Decl::Transitive), + "source \"https://rubygems.org\"\n\ngem \"puma\"\n\n" + ); + let t = format!("gem \"puma\"\n{block}\n"); + assert_eq!( + run(&t, &Decl::Direct(constraint_args("rails (>= 6, ~> 7.0)"))), + "gem \"puma\"\ngem \"rails\", \">= 6\", \"~> 7.0\"\n" + ); + } + + #[test] + fn manifest_shapes_it_cannot_unwind_are_refused() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let block = format!("source \"{IDX}\" do\n gem \"rails\", \"7.0.0\"\nend\n"); + assert!(find_block(&format!("{block}{block}"), &gem(), "Gemfile", &ctx).is_err()); + let edited = format!("source \"{IDX}\" do\n gem \"rails\", \"~> 7.0\"\nend\n"); + assert!(find_block(&edited, &gem(), "Gemfile", &ctx).is_err()); + assert!(find_block("gem \"rails\"\n", &gem(), "Gemfile", &ctx) + .unwrap() + .is_none()); + } + + #[test] + fn provably_transitive_needs_a_blank_line_before_the_block() { + let client = super::super::UpstreamClient::new(true); + let ctx = ctx_with(&client); + let block = format!("source \"{IDX}\" do\n gem \"rails\", \"7.0.0\"\nend\n"); + let appended = format!("gem \"puma\"\n\n{block}"); + let b = find_block(&appended, &gem(), "Gemfile", &ctx) + .unwrap() + .unwrap(); + assert!(provably_appended(&appended, &b)); + let ambiguous = format!("gem \"puma\"\n{block}"); + let b = find_block(&ambiguous, &gem(), "Gemfile", &ctx) + .unwrap() + .unwrap(); + assert!(!provably_appended(&ambiguous, &b)); + assert!(is_subdependency( + &[" x (1.0)", " rails (>= 7)"], + "rails" + )); + assert!(!is_subdependency(&[" rails (7.0.0)"], "rails")); + } + + #[test] + fn global_sources_skip_blocks() { + let m = format!("source 'https://rubygems.org'\nsource(\"https://b.example\")\nsource \"{IDX}\" do\nend\n"); + assert_eq!( + global_sources(&m), + vec![ + "https://rubygems.org".to_string(), + "https://b.example".to_string() + ] + ); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs b/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs new file mode 100644 index 000000000..4ce16051f --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/golang.rs @@ -0,0 +1,113 @@ +//! Go upstream restore: drop the hosted `replace M v => patch.socket.dev/ +//! gopatch/ …` directive and the socket module's go.sum lines, and put +//! the upstream module's two go.sum lines back (re-derived from the module +//! proxy exactly as `go` hashes them) — the pair the hosted rewriter pruned. +//! +//! A directive the hosted run took over from the user (a pre-existing +//! `replace` it superseded) is not recorded anywhere, so the restore always +//! lands on the plain upstream module; the refusal message of a formats the +//! restore cannot handle names the checkout remedy instead. + +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::vendor::go_mod_edit::{ + hosted_module_uuid, parse_replace_entries, remove_replace_entry, ReplaceOwner, +}; +use crate::vendor::go_sum_edit::{reinsert_lines, remove_module_prefix_lines}; + +pub(crate) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + _files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let go_mod = match view.read("go.mod").await { + Ok(Some(text)) => text, + Ok(None) => { + for pin in pins { + result.refuse(&pin.uuid, "go.mod no longer exists"); + } + return result; + } + Err(e) => { + for pin in pins { + result.refuse(&pin.uuid, e.clone()); + } + return result; + } + }; + let entries = parse_replace_entries(&go_mod); + // (uuid, module, version, socket module path) per hosted directive. + let mut hits: Vec<(String, String, String, String)> = Vec::new(); + for pin in pins { + let Some((module, version)) = pin.name_version() else { + result.refuse(&pin.uuid, format!("{} is not a golang purl", pin.purl)); + continue; + }; + let directive = entries.iter().find(|e| { + e.module == module + && e.rhs_module + .as_deref() + .and_then(hosted_module_uuid) + .is_some_and(|u| u == pin.uuid) + }); + let Some(directive) = directive else { + continue; + }; + let version = directive.version.clone().unwrap_or(version); + let socket_module = directive.rhs_module.clone().unwrap_or_default(); + hits.push((pin.uuid.clone(), module, version, socket_module)); + } + if hits.is_empty() { + return result; + } + let lookups = hits.iter().map(|(uuid, module, version, _)| async move { + (uuid.clone(), ctx.client.go_sums(module, version).await) + }); + let sums: std::collections::BTreeMap> = + futures_util::future::join_all(lookups).await.into_iter().collect(); + + let mut go_mod_next = go_mod.clone(); + let mut go_sum = view.read("go.sum").await.ok().flatten(); + let go_sum_original = go_sum.clone(); + for (uuid, module, version, socket_module) in &hits { + let sums = match sums.get(uuid) { + Some(Ok(s)) => s, + Some(Err(why)) => { + result.refuse(uuid, format!("{module}@{version}: {why}")); + continue; + } + None => continue, + }; + match remove_replace_entry(&go_mod_next, module, ReplaceOwner::Hosted) { + Ok(Some(next)) => go_mod_next = next, + Ok(None) => {} + Err(e) => { + result.refuse(uuid, format!("go.mod: {e}")); + continue; + } + } + if let Some(text) = go_sum.as_deref() { + let mut next = remove_module_prefix_lines(text, socket_module) + .unwrap_or_else(|| text.to_string()); + let upstream = format!( + "{module} {version} {}\n{module} {version}/go.mod {}\n", + sums.zip_h1, sums.mod_h1 + ); + if let Some(reinserted) = reinsert_lines(&next, &upstream) { + next = reinserted; + } + go_sum = Some(next); + } + result.handled.insert(uuid.clone()); + } + if go_mod_next != go_mod { + view.write("go.mod", go_mod_next); + } + if go_sum != go_sum_original { + if let Some(text) = go_sum { + view.write("go.sum", text); + } + } + result +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs new file mode 100644 index 000000000..35166b00d --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -0,0 +1,596 @@ +//! Maven upstream restore: the inverse of `rewrite_maven_pom`, no network. +//! +//! The hosted rewrite pins `-socket.` (the +//! literal, or an added `` entry for a GA with no +//! literal version), inserts a `` with id +//! `socket-patch-`, and — when both hashes were known — appends the +//! trusted-checksums resolver lines to `.mvn/maven.config` and the +//! suffixed jar/pom lines to `.mvn/checksums/checksums.sha256`. +//! +//! Everything but the `.mvn` provenance is derivable from the pom itself: +//! the base version is the suffix's prefix, and the added blocks have the +//! writer's exact shape. An added `` entry is told +//! from a rewritten one of the user's by that shape and position (right +//! after the section's ``, only authored entries before it), +//! by being the GA's only literal version, and — when the pom declares the +//! GA directly without a version and has no `` or BOM import to +//! manage it — never (the entry is then what supplies the version, so it +//! was the user's). The `.mvn` files are removed only when they hold +//! nothing but what hosted mode writes; otherwise the resolver lines stay +//! and a warning says so (whether they pre-existed is not recorded). +//! Checksum lines keep the file's remaining order (the rewriter re-sorted +//! and dropped malformed lines; that is not recoverable). + +use regex::Regex; + +use super::npm::{by_uuid, read_or_refuse, refuse_all_in}; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::patch::redirect::{ + maven_tag_inner_range, maven_tag_text_in, MAVEN_DEPENDENCY_BLOCK_RE, MVN_CHECKSUMS, MVN_CONFIG, + MVN_CONFIG_ARGS, +}; + +/// The line break and indent `insert_maven_dependency_management` writes +/// before its entry. +const DM_ENTRY_LEAD: &str = "\n "; + +/// The `` entry `insert_maven_dependency_management` +/// writes, from its `` tag on. +fn authored_dm_block(group: &str, artifact: &str, version: &str) -> String { + format!( + "\n {group}\n {artifact}\n {version}\n " + ) +} + +/// The wrappers the rewriter authors from scratch before ``, as +/// they read once every entry is gone. +const EMPTY_REPOSITORIES: &str = " \n \n"; +const EMPTY_DEP_MANAGEMENT: &str = + " \n \n \n \n"; + +/// One `` block of a GA. +struct DepMatch { + start: usize, + end: usize, + version: Option<(usize, usize)>, + version_text: Option, +} + +fn dep_matches(pom: &str, group: &str, artifact: &str) -> Vec { + MAVEN_DEPENDENCY_BLOCK_RE + .find_iter(pom) + .filter(|m| { + maven_tag_text_in(pom, "groupId", m.start(), m.end()).as_deref() == Some(group) + && maven_tag_text_in(pom, "artifactId", m.start(), m.end()).as_deref() + == Some(artifact) + }) + .map(|m| { + let version = maven_tag_inner_range(pom, "version", m.start(), m.end()); + DepMatch { + start: m.start(), + end: m.end(), + version, + version_text: version.map(|(s, e)| pom[s..e].trim().to_string()), + } + }) + .collect() +} + +/// Is the block at `start` in the rewriter's insertion position: right +/// after ``, with only authored +/// Socket-pinned entries between? +fn after_dm_open(pom: &str, start: usize) -> bool { + let open = Regex::new(r"(?s)\s*\z") + .expect("static dependencyManagement-open regex is valid"); + let authored = Regex::new( + r"\n \n [^<]*\n [^<]*\n [^<]*-socket\.[0-9a-f]{8}\n \z", + ) + .expect("static authored-entry regex is valid"); + let Some(mut prefix) = pom[..start].strip_suffix(DM_ENTRY_LEAD) else { + return false; + }; + loop { + if open.is_match(prefix) { + return true; + } + match authored.find(prefix) { + Some(m) => prefix = &prefix[..m.start()], + None => return false, + } + } +} + +/// `(start, end)` of every `` element. +fn dm_sections(pom: &str) -> Vec<(usize, usize)> { + let re = Regex::new(r"(?s).*?") + .expect("static dependencyManagement regex is valid"); + re.find_iter(pom).map(|m| (m.start(), m.end())).collect() +} + +/// Remove the `socket-patch-` repository of `pom`: the element and +/// the line break before it (the rewriter's own insertion), when it sits +/// on lines of its own. +fn remove_repository(pom: &str, uuid: &str, ctx: &Ctx<'_>) -> Result { + let re = + Regex::new(r"(?s).*?").expect("static repository regex is valid"); + let id = format!("socket-patch-{uuid}"); + let found: Vec<(usize, usize)> = re + .find_iter(pom) + .filter(|m| maven_tag_text_in(pom, "id", m.start(), m.end()).as_deref() == Some(&id)) + .map(|m| (m.start(), m.end())) + .collect(); + let (start, end) = match found.as_slice() { + [one] => *one, + [] => return Err(format!("pom.xml has no with id {id}")), + _ => { + return Err(format!( + "pom.xml has several elements with id {id}" + )) + } + }; + let url = maven_tag_text_in(pom, "url", start, end).unwrap_or_default(); + if ctx.hosted_uuid(&url).as_deref() != Some(uuid) { + return Err(format!( + "the pom.xml repository {id} does not point at the Socket patch server" + )); + } + let line_start = pom[..start].rfind('\n'); + let own_line = line_start.is_some_and(|ls| pom[ls + 1..start].trim().is_empty()) + && (pom[end..].starts_with('\n') || pom[end..].starts_with("\r\n")); + let Some(line_start) = line_start.filter(|_| own_line) else { + return Err(format!( + "the pom.xml repository {id} is not on lines of its own" + )); + }; + let cut = if pom[..line_start].ends_with('\r') { + line_start - 1 + } else { + line_start + }; + Ok(format!("{}{}", &pom[..cut], &pom[end..])) +} + +/// Restore one pin in `pom`, or say why not. +fn restore_pin( + pom: &str, + group: &str, + artifact: &str, + base: &str, + uuid: &str, + ctx: &Ctx<'_>, +) -> Result { + let suffixed = format!("{base}-socket.{}", &uuid[..8]); + let mut text = remove_repository(pom, uuid, ctx)?; + + let matches = dep_matches(&text, group, artifact); + if !matches + .iter() + .any(|m| m.version_text.as_deref() == Some(suffixed.as_str())) + { + return Err(format!( + "pom.xml declares no {group}:{artifact} {suffixed}" + )); + } + let versioned = matches.iter().filter(|m| m.version.is_some()).count(); + let sections = dm_sections(&text); + let managed = |pos: usize| sections.iter().any(|(s, e)| pos >= *s && pos < *e); + // A versionless direct declaration with nothing but this pom's own + // `` to manage it: that entry is the user's. + let entry_is_users = matches + .iter() + .any(|m| m.version.is_none() && !managed(m.start)) + && !text.contains("") + && !text.contains("import"); + + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + for m in matches + .iter() + .filter(|m| m.version_text.as_deref() == Some(suffixed.as_str())) + { + let authored = versioned == 1 + && !entry_is_users + && text[m.start..m.end] == authored_dm_block(group, artifact, &suffixed) + && after_dm_open(&text, m.start); + if authored { + edits.push((m.start - DM_ENTRY_LEAD.len(), m.end, String::new())); + } else { + let (s, e) = m + .version + .expect("a match with a version text has its range"); + edits.push((s, e, base.to_string())); + } + } + edits.sort_by(|a, b| b.0.cmp(&a.0)); + for (s, e, with) in edits { + text.replace_range(s..e, &with); + } + + if text.contains(&suffixed) { + return Err(format!( + "pom.xml still names {suffixed} outside a version (a property or \ + plugin configuration socket-patch did not write)" + )); + } + if text.contains(&format!("socket-patch-{uuid}")) { + return Err(format!("pom.xml still names socket-patch-{uuid}")); + } + Ok(text) +} + +/// The `` directories a pom lists (plain relative paths only). +fn modules(pom: &str) -> Vec { + let re = Regex::new(r"\s*([^<]*?)\s*").expect("static module regex is valid"); + re.captures_iter(pom) + .map(|c| c[1].trim_end_matches('/').to_string()) + .filter(|m| { + !m.is_empty() + && !m.starts_with('/') + && !m.contains('\\') + && m.split('/') + .all(|seg| !seg.is_empty() && seg != "." && seg != "..") + }) + .collect() +} + +pub(crate) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(original) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let dir = rel + .rsplit_once('/') + .map(|(d, _)| format!("{d}/")) + .unwrap_or_default(); + let module_poms: Vec = modules(&original) + .into_iter() + .map(|m| { + if m.ends_with(".xml") { + format!("{dir}{m}") + } else { + format!("{dir}{m}/pom.xml") + } + }) + .collect(); + let mut text = original.clone(); + // Checksum-file path prefixes of the restored pins. + let mut checksum_dirs: Vec = Vec::new(); + let mut restored: Vec<&str> = Vec::new(); + for pin in pins.values().filter(|p| p.files.contains(rel)) { + let Some((name, base)) = pin.name_version() else { + result.refuse(&pin.uuid, format!("{} is not a Maven purl", pin.purl)); + continue; + }; + let Some((group, artifact)) = name.split_once('/') else { + result.refuse(&pin.uuid, format!("{} names no groupId", pin.purl)); + continue; + }; + if pin.uuid.len() < 8 { + result.refuse(&pin.uuid, format!("{} is not a patch uuid", pin.uuid)); + continue; + } + let suffixed = format!("{base}-socket.{}", &pin.uuid[..8]); + let mut in_module = None; + for module in &module_poms { + if let Ok(Some(child)) = view.read(module).await { + if child.contains(&suffixed) + || child.contains(&format!("socket-patch-{}", pin.uuid)) + { + in_module = Some(module.clone()); + break; + } + } + } + if let Some(module) = in_module { + result.refuse( + &pin.uuid, + format!( + "the module pom {module} also pins {group}:{artifact} {suffixed}, and \ + socket-patch restores only the root {rel}" + ), + ); + continue; + } + match restore_pin(&text, group, artifact, &base, &pin.uuid, ctx) { + Ok(next) => { + text = next; + checksum_dirs.push(format!( + "{}/{artifact}/{suffixed}/", + group.replace('.', "/") + )); + restored.push(&pin.uuid); + } + Err(why) => result.refuse(&pin.uuid, why), + } + } + // The from-scratch wrappers, once emptied (repositories come last). + for empty in [EMPTY_REPOSITORIES, EMPTY_DEP_MANAGEMENT] { + if text.contains(empty) && !original.contains(empty) { + text = text.replacen(empty, "", 1); + } + } + if restored.is_empty() { + continue; + } + if let Err(why) = crate::vendor::maven_pom::parse_pom(&text) { + refuse_all_in( + &pins, + rel, + &mut result, + format!("restoring {rel} would not leave a readable pom: {why}"), + ); + continue; + } + view.write(rel, text); + restore_mvn(view, &dir, &checksum_dirs, &mut result).await; + result + .handled + .extend(restored.into_iter().map(str::to_string)); + } + result +} + +/// Drop the restored pins' trusted-checksum lines, and the `.mvn` files +/// themselves when nothing but hosted mode's content is left. +async fn restore_mvn( + view: &mut View<'_>, + dir: &str, + checksum_dirs: &[String], + result: &mut FormatResult, +) { + let sums_rel = format!("{dir}{MVN_CHECKSUMS}"); + let config_rel = format!("{dir}{MVN_CONFIG}"); + let Ok(Some(sums)) = view.read(&sums_rel).await else { + return; + }; + let path_of = |line: &str| { + line.trim_end_matches('\r') + .split_once(" ") + .map(|(_, p)| p.to_string()) + }; + let kept: Vec<&str> = sums + .split('\n') + .filter(|line| { + !path_of(line).is_some_and(|p| checksum_dirs.iter().any(|d| p.starts_with(d.as_str()))) + }) + .collect(); + let rest = kept.join("\n"); + if rest == sums { + return; + } + let config = view.read(&config_rel).await.ok().flatten(); + let written_config = format!("{}\n", MVN_CONFIG_ARGS.join("\n")); + let has_resolver_lines = config.as_deref().is_some_and(|c| { + c.lines() + .any(|l| MVN_CONFIG_ARGS.contains(&l.trim_end_matches('\r'))) + }); + if rest.trim().is_empty() { + view.remove(&sums_rel); + if config.as_deref() == Some(written_config.as_str()) { + view.remove(&config_rel); + return; + } + } else { + view.write(&sums_rel, rest.clone()); + } + let other_hosted = rest.lines().any(|l| { + path_of(l).is_some_and(|p| { + p.split('/') + .any(|seg| crate::vendor::maven_pom::split_socket_version(seg).is_some()) + }) + }); + if has_resolver_lines && !other_hosted { + result.warnings.push(( + "maven_trusted_checksums_left", + format!( + "{config_rel} keeps the trusted-checksums resolver lines (`-Daether.…`), which \ + hosted mode may have added; no hosted pin needs them any more, so remove them \ + if nothing else does" + ), + )); + } +} + +#[cfg(test)] +mod tests { + use super::super::{restore_upstream, HostedPin, PinStatus, RestoreOptions, RestoreOutcome}; + use std::collections::BTreeMap; + + const UUID: &str = "77777777-7777-7777-7777-777777777777"; + const SUFFIXED: &str = "1.7.36-socket.77777777"; + + fn fixture(case: &str, side: &str, rel: &str) -> String { + let p = format!( + "{}/tests/fixtures/redirect/maven/pom/{case}/{side}/{rel}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read_to_string(p).unwrap() + } + + /// Restore the slf4j pin (offline: Maven needs no network) over + /// `files`; the outcome, and the tree after. + async fn run(files: &[(&str, String)]) -> (RestoreOutcome, BTreeMap) { + let tmp = tempfile::tempdir().unwrap(); + for (rel, text) in files { + let p = tmp.path().join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + let pins = [HostedPin { + purl: "pkg:maven/org.slf4j/slf4j-api@1.7.36".into(), + uuid: UUID.into(), + files: vec!["pom.xml".into()], + }]; + let opts = RestoreOptions { + offline: true, + ..RestoreOptions::default() + }; + let outcome = restore_upstream(tmp.path(), &pins, &opts).await; + let mut after = BTreeMap::new(); + for entry in walkdir::WalkDir::new(tmp.path()) + .into_iter() + .filter_map(Result::ok) + { + if entry.file_type().is_file() { + let rel = entry.path().strip_prefix(tmp.path()).unwrap(); + after.insert( + rel.to_string_lossy().replace('\\', "/"), + std::fs::read_to_string(entry.path()).unwrap(), + ); + } + } + (outcome, after) + } + + fn refusal(outcome: &RestoreOutcome) -> String { + match &outcome.pins[0].status { + PinStatus::Refused(why) => why.clone(), + PinStatus::Restored => panic!("restored"), + } + } + + #[tokio::test] + async fn refusals_change_nothing() { + let hosted = fixture("basic", "expected", "pom.xml"); + let with_module = hosted.replace( + " jar", + " pom\n \n core\n ", + ); + let module_pom = format!("{SUFFIXED}\n"); + let with_property = hosted.replace( + " jar", + &format!( + " jar\n \n {SUFFIXED}\n " + ), + ); + let no_repo = fixture("basic", "input", "pom.xml").replace("1.7.36", SUFFIXED); + let inline_repo = hosted.replace("\n ", ""); + let cases: Vec<(Vec<(&str, String)>, &str)> = vec![ + ( + vec![("pom.xml", with_module), ("core/pom.xml", module_pom)], + "module pom core/pom.xml also pins", + ), + ( + vec![("pom.xml", with_property)], + "outside a version", + ), + (vec![("pom.xml", no_repo)], "no with id"), + (vec![("pom.xml", inline_repo)], "not on lines of its own"), + ( + vec![( + "pom.xml", + hosted.replace(SUFFIXED, "1.7.36-socket.12345678"), + )], + "declares no org.slf4j:slf4j-api", + ), + ]; + for (files, needle) in cases { + let (outcome, after) = run(&files).await; + let why = refusal(&outcome); + assert!(why.contains(needle), "{needle}: {why}"); + assert!(why.contains("git checkout -- pom.xml"), "{why}"); + for (rel, text) in &files { + assert_eq!(after.get(*rel), Some(text), "{needle}: {rel}"); + } + } + } + + #[tokio::test] + async fn crlf_pom_round_trips() { + let input = fixture("basic", "input", "pom.xml").replace('\n', "\r\n"); + let hosted = fixture("basic", "expected", "pom.xml"); + let repos = + &hosted[hosted.find(" ").unwrap()..hosted.find("").unwrap()]; + let hosted_crlf = input + .replace( + "1.7.36", + &format!("{SUFFIXED}"), + ) + .replace("", &format!("{repos}")); + let (outcome, after) = run(&[("pom.xml", hosted_crlf)]).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(after["pom.xml"], input); + } + + #[tokio::test] + async fn managed_entry_is_removed_when_a_parent_manages_the_direct_dependency() { + // No literal version anywhere, a to manage it: the rewriter + // added the pin. + let input = fixture("transitive-depmgmt", "input", "pom.xml") + .replace( + " 4.0.0\n", + " 4.0.0\n \n p\n p\n 1\n \n", + ) + .replace( + " ", + " \n org.slf4j\n slf4j-api\n \n ", + ); + let hosted = fixture("transitive-depmgmt", "expected", "pom.xml"); + let added = &hosted + [hosted.find(" ").unwrap()..hosted.find("").unwrap()]; + let hosted = input.replace("", &format!("{added}")); + let (outcome, after) = run(&[("pom.xml", hosted)]).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(after["pom.xml"], input); + } + + #[tokio::test] + async fn existing_mvn_lines_are_kept_and_warned() { + let config = format!( + "-Dmaven.test.skip=true\n{}\n", + super::MVN_CONFIG_ARGS.join("\n") + ); + let files = vec![ + ("pom.xml", fixture("basic", "expected", "pom.xml")), + (".mvn/maven.config", config.clone()), + ( + ".mvn/checksums/checksums.sha256", + fixture("basic", "expected", ".mvn/checksums/checksums.sha256"), + ), + ]; + let (outcome, after) = run(&files).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(after["pom.xml"], fixture("basic", "input", "pom.xml")); + assert_eq!(after[".mvn/maven.config"], config); + assert!(!after.contains_key(".mvn/checksums/checksums.sha256")); + assert!( + outcome + .warnings + .iter() + .any(|(code, _)| *code == "maven_trusted_checksums_left"), + "{:?}", + outcome.warnings + ); + } + + #[tokio::test] + async fn other_checksum_lines_keep_their_order() { + let other = format!( + "{} zz/other/1.0/other-1.0.jar\n{} aa/first/1.0/first-1.0.jar\n", + "e".repeat(64), + "f".repeat(64) + ); + let sums = format!( + "{other}{}", + fixture("basic", "expected", ".mvn/checksums/checksums.sha256") + ); + let files = vec![ + ("pom.xml", fixture("basic", "expected", "pom.xml")), + ( + ".mvn/maven.config", + fixture("basic", "expected", ".mvn/maven.config"), + ), + (".mvn/checksums/checksums.sha256", sums), + ]; + let (outcome, after) = run(&files).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(after[".mvn/checksums/checksums.sha256"], other); + assert!(after.contains_key(".mvn/maven.config")); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs new file mode 100644 index 000000000..b7a94f3b1 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -0,0 +1,665 @@ +//! Hosted → upstream restore: the v5 unwind of a hosted redirect. +//! +//! v5 hosted mode keeps no ledger (`scan`/`get --mode hosted` write only +//! lockfile edits), so an unwind cannot replay recorded fragments. Instead, +//! for every hosted pin the lockfiles carry (`vex::discover`'s hosted refs: +//! purl + patch uuid + the files wiring it), this module rewrites the lock +//! entry back to the DEFAULT UPSTREAM registry entry for `name@version`, +//! re-resolving whatever the entry pins (tarball URL, integrity, checksum) +//! from the public registry: the npm registry's version document, the +//! crates.io sparse index, the Go module proxy, and so on. +//! +//! Where that is impossible — a format whose entry carries fields only the +//! package manager can compute, an offline run, a registry that does not +//! answer, a binary `bun.lockb` outside a vendor takeover +//! ([`RestoreOptions::bun_lockb`]) — the pin is REFUSED with a message naming +//! the remedy (`git checkout -- `). A refusal is all-or-nothing +//! per pin: a pin refused in one of its files is restored in none of them, +//! so no pin is ever left half hosted. +//! +//! Nothing reaches disk until every pin resolved (the staged view below), +//! and a dry run resolves everything exactly like a wet run — network +//! lookups included — and skips only the flush. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use crate::vex::discover::{Discovery, PatchedRef, WiringMode}; + +mod bun_lockb; +mod cargo; +mod client; +mod composer; +mod gem; +mod golang; +mod maven; +mod npm; +mod nuget; +mod pypi; +mod pypi_locks; +mod uv; +mod vlt; + +pub(crate) use client::UpstreamClient; + +use super::staged::{flush_staged, read_rel, Staged, StagedBytes}; + +/// One hosted pin to restore: a `(purl, patch uuid)` pair and the +/// root-relative files lockfile discovery found it wired in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostedPin { + /// Canonical base purl (no qualifiers), as discovery spells it. + pub purl: String, + /// The hosted patch uuid. + pub uuid: String, + /// Root-relative files that wire this pin, sorted and deduplicated. + pub files: Vec, +} + +impl HostedPin { + /// Group a discovery's HOSTED refs into pins, one per `(purl, uuid)`. + pub fn from_refs<'a>(refs: impl IntoIterator) -> Vec { + let mut grouped: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for r in refs { + if r.mode != WiringMode::Hosted { + continue; + } + grouped + .entry((r.purl.clone(), r.uuid.clone())) + .or_default() + .insert(r.source_file.to_string_lossy().replace('\\', "/")); + } + grouped + .into_iter() + .map(|((purl, uuid), files)| HostedPin { + purl, + uuid, + files: files.into_iter().collect(), + }) + .collect() + } + + /// Every hosted pin a discovery holds. + pub fn all(discovery: &Discovery) -> Vec { + Self::from_refs(&discovery.refs) + } + + /// `(name, version)` of the purl, percent-decoded. + pub(crate) fn name_version(&self) -> Option<(String, String)> { + let (_, name, version) = crate::utils::purl::purl_parts(&self.purl)?; + Some((name, version)) + } +} + +/// Hosted wiring the lockfiles mention that is NOT an attributable pin: a +/// Socket-hosted patch identity discovery recognized in `files` but could +/// not tie to one package version (a lock another lock contradicts, a +/// malformed or unattributable reference, a lockless registry pin). It is +/// still hosted state — management commands must refuse around it, never +/// read it as "no hosted patches". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContestedWiring { + /// The hosted patch uuid the files name. + pub uuid: String, + /// Root-relative files naming it, sorted and deduplicated. + pub files: Vec, + /// Discovery's own findings for those files (`code: detail`), if any. + pub details: Vec, +} + +/// The project's hosted state as raw wiring: the attributable pins (what +/// restores and ejects act on) and the contested wiring (what they must +/// refuse around). VEX eligibility is a separate judgment over the same +/// discovery; this inventory keeps everything the lockfiles wire. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HostedInventory { + pub pins: Vec, + pub contested: Vec, +} + +impl HostedInventory { + pub fn of(discovery: &Discovery) -> Self { + let pins = HostedPin::all(discovery); + let norm = |p: &Path| p.to_string_lossy().replace('\\', "/"); + let pinned: BTreeSet<&str> = pins.iter().map(|p| p.uuid.as_str()).collect(); + // A hosted URL also carries its grant token as a uuid-shaped + // segment, so an unpinned recognized uuid in a file that DOES carry + // pins is contested only when discovery flagged that file. + let pinned_files: BTreeSet<&str> = pins + .iter() + .flat_map(|p| p.files.iter().map(String::as_str)) + .collect(); + let flagged_files: BTreeSet = discovery + .diagnostics + .iter() + .filter(|d| { + matches!( + d.code, + crate::vex::discover::DIAG_REF_INVALID + | crate::vex::discover::DIAG_REF_UNATTRIBUTABLE + ) + }) + .map(|d| norm(&d.file)) + .collect(); + let mut contested: BTreeMap> = BTreeMap::new(); + for r in &discovery.recognized { + let file = norm(&r.file); + if r.mode == WiringMode::Hosted + && !pinned.contains(r.uuid.as_str()) + && (!pinned_files.contains(file.as_str()) || flagged_files.contains(&file)) + { + contested + .entry(r.uuid.clone()) + .or_default() + .insert(norm(&r.file)); + } + } + for pin in &discovery.unlocked_pins { + if !pinned.contains(pin.uuid.as_str()) { + contested + .entry(pin.uuid.clone()) + .or_default() + .insert(norm(&pin.file)); + } + } + let contested = contested + .into_iter() + .map(|(uuid, files)| { + let details = discovery + .diagnostics + .iter() + .filter(|d| files.contains(&norm(&d.file))) + .map(|d| format!("{}: {}", d.code, d.detail)) + .collect::>() + .into_iter() + .collect(); + ContestedWiring { + uuid, + files: files.into_iter().collect(), + details, + } + }) + .collect(); + HostedInventory { pins, contested } + } + + /// Whether the lockfiles wire any hosted patch at all. + pub fn is_empty(&self) -> bool { + self.pins.is_empty() && self.contested.is_empty() + } + + /// The refusal a management command raises while contested wiring + /// exists: which files, why, and the remedy. `None` when uncontested. + pub fn contested_refusal(&self) -> Option { + if self.contested.is_empty() { + return None; + } + let files: BTreeSet<&str> = self + .contested + .iter() + .flat_map(|c| c.files.iter().map(String::as_str)) + .collect(); + let files: Vec<&str> = files.into_iter().collect(); + let details: Vec<&str> = self + .contested + .iter() + .flat_map(|c| c.details.iter().map(String::as_str)) + .collect::>() + .into_iter() + .collect(); + // Files, not uuids: a hosted URL also carries its grant token as a + // uuid-shaped segment, so the recognized set over-names patches. + let mut msg = format!( + "{} wire(s) Socket-hosted patches that cannot be attributed to one package \ + version (the lockfiles disagree, or the reference is malformed), so socket-patch \ + cannot manage them safely", + files.join(", ") + ); + if !details.is_empty() { + msg.push_str(&format!(" ({})", details.join("; "))); + } + msg.push_str(&format!( + "; reconcile the lockfiles (re-run `socket-patch scan --mode hosted`) or restore \ + them from version control (`git checkout -- {}`)", + files.join(" ") + )); + Some(msg) + } +} + +/// Knobs for [`restore_upstream`]. +#[derive(Debug, Clone, Default)] +pub struct RestoreOptions { + /// Resolve everything, write nothing. + pub dry_run: bool, + /// No network: every pin whose restore needs a registry lookup is + /// refused with the checkout remedy. + pub offline: bool, + /// Extra patch-server origins whose URLs count as hosted (the + /// operator's `--patch-server-url`), exactly as discovery takes them. + pub patch_server_origins: Vec, + /// Restore hosted pins in a binary `bun.lockb` by rebuilding the npm + /// registry record (see `bun_lockb`). Off, they are refused with the + /// checkout remedy. The rebuild is exact for a lock the hosted rewrite + /// wrote (a promoted format-1 lock is demoted back; a lock whose + /// workspace dependency behaviors it normalized is refused), but only a + /// vendor takeover — which re-records the rebuilt record as its own + /// pre-vendor original — opts in; `rollback` keeps refusing. + pub bun_lockb: bool, +} + +/// What happened to one pin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PinStatus { + /// Every file wiring the pin now resolves the upstream registry entry + /// (or would, on a dry run). + Restored, + /// Nothing was changed for this pin. The message names the remedy. + Refused(String), +} + +/// One pin's outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinResult { + pub purl: String, + pub uuid: String, + pub status: PinStatus, + /// The files that wired the pin. + pub files: Vec, +} + +/// What [`restore_upstream`] did. +#[derive(Debug, Default)] +pub struct RestoreOutcome { + /// One entry per input pin, in input order. + pub pins: Vec, + /// Root-relative files rewritten or removed (or that would be, on a + /// dry run), sorted. + pub reverted_files: Vec, + /// Advisory `(code, detail)` pairs. + pub warnings: Vec<(&'static str, String)>, + /// A write failure after every pin resolved: some files may have + /// landed. `None` on a clean flush (and always on a dry run). + pub flush_error: Option, +} + +impl RestoreOutcome { + pub fn restored(&self) -> impl Iterator { + self.pins + .iter() + .filter(|p| p.status == PinStatus::Restored) + } + + pub fn refused(&self) -> impl Iterator { + self.pins.iter().filter_map(|p| match &p.status { + PinStatus::Refused(why) => Some((p, why.as_str())), + PinStatus::Restored => None, + }) + } +} + +/// The remedy every refusal names: restore the file from version control. +pub fn checkout_remedy(files: &[String]) -> String { + if files.is_empty() { + return "restore the lockfile from version control (`git checkout -- `)" + .to_string(); + } + format!( + "restore it from version control instead (`git checkout -- {}`)", + files.join(" ") + ) +} + +/// The staged project view the restorers work over: reads fall through to +/// disk, writes stay in memory until [`restore_upstream`] flushes them. +pub(crate) struct View<'a> { + root: &'a Path, + staged: Staged, + /// Original on-disk text of every file read, so a write that restores + /// the exact original bytes is not reported as a change. + originals: BTreeMap>, + /// Binary files (bun.lockb): staged bytes and their on-disk originals. + staged_bytes: StagedBytes, + original_bytes: BTreeMap>>, +} + +impl<'a> View<'a> { + fn new(root: &'a Path) -> Self { + View { + root, + staged: Staged::new(), + originals: BTreeMap::new(), + staged_bytes: StagedBytes::new(), + original_bytes: BTreeMap::new(), + } + } + + /// The current (staged) bytes of the binary file `rel`; `Ok(None)` when + /// absent. FIFO-guarded like [`Self::read`]. + pub(crate) async fn read_bytes(&mut self, rel: &str) -> Result>, String> { + if let Some(pending) = self.staged_bytes.get(rel) { + return Ok(Some(pending.clone())); + } + if let Some(original) = self.original_bytes.get(rel) { + return Ok(original.clone()); + } + let bytes = match crate::utils::fs::read_regular_to_bytes(&self.root.join(rel)).await { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(format!("read {rel}: {e}")), + }; + self.original_bytes.insert(rel.to_string(), bytes.clone()); + Ok(bytes) + } + + pub(crate) fn write_bytes(&mut self, rel: &str, content: Vec) { + self.staged_bytes.insert(rel.to_string(), content); + } + + /// Binary files whose staged bytes differ from what was read from disk. + fn changed_bytes(&self) -> StagedBytes { + self.staged_bytes + .iter() + .filter(|(rel, pending)| { + self.original_bytes.get(*rel).and_then(Option::as_ref) != Some(*pending) + }) + .map(|(rel, pending)| (rel.clone(), pending.clone())) + .collect() + } + + pub(crate) fn root(&self) -> &Path { + self.root + } + + /// The current (staged) text of `rel`; `Ok(None)` when absent. + pub(crate) async fn read(&mut self, rel: &str) -> Result, String> { + if let Some(pending) = self.staged.get(rel) { + return Ok(pending.clone()); + } + if let Some(original) = self.originals.get(rel) { + return Ok(original.clone()); + } + let text = read_rel(self.root, rel).await?; + self.originals.insert(rel.to_string(), text.clone()); + Ok(text) + } + + pub(crate) fn write(&mut self, rel: &str, content: String) { + self.staged.insert(rel.to_string(), Some(content)); + } + + pub(crate) fn remove(&mut self, rel: &str) { + self.staged.insert(rel.to_string(), None); + } + + /// Files whose staged state differs from what was read from disk. + fn changed(&self) -> Staged { + self.staged + .iter() + .filter(|(rel, pending)| self.originals.get(*rel) != Some(*pending)) + .map(|(rel, pending)| (rel.clone(), pending.clone())) + .collect() + } +} + +/// Per-format restore result, merged across formats by the driver. +#[derive(Debug, Default)] +pub(crate) struct FormatResult { + /// Pins (by uuid) this format refused, with the reason (the driver + /// appends the remedy). + pub refused: BTreeMap, + /// Pins (by uuid) this format found wired and restored in the view. + pub handled: BTreeSet, + pub warnings: Vec<(&'static str, String)>, +} + +impl FormatResult { + pub(crate) fn refuse(&mut self, uuid: &str, why: impl Into) { + self.refused + .entry(uuid.to_string()) + .or_insert_with(|| why.into()); + } + + fn merge(&mut self, other: FormatResult) { + for (uuid, why) in other.refused { + self.refused.entry(uuid).or_insert(why); + } + self.handled.extend(other.handled); + self.warnings.extend(other.warnings); + } +} + +/// Shared context handed to every format restorer. +pub(crate) struct Ctx<'a> { + pub client: &'a UpstreamClient, + pub origins: &'a [String], + /// [`RestoreOptions::bun_lockb`]. + pub bun_lockb: bool, +} + +impl Ctx<'_> { + /// The hosted patch uuid `url` names, under the same host allowlist + /// discovery applies. + pub(crate) fn hosted_uuid(&self, url: &str) -> Option { + super::hosted_patch_uuid(url, self.origins) + } +} + +/// The lock formats a pin's files belong to. One restorer per format sees +/// every in-scope pin wired in that format's files at once. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Format { + NpmLock, + YarnLock, + PnpmLock, + BunLock, + /// Binary bun.lockb (restored only under [`RestoreOptions::bun_lockb`]). + BunLockb, + Cargo, + Golang, + Gem, + Composer, + PipfileLock, + PoetryLock, + PdmLock, + Requirements, + /// Hatch direct references (`pyproject.toml`, `hatch.toml`). + Hatch, + /// uv.lock, PEP 723 script locks and PEP 751 pylock files (the uv + /// restorer also edits their paired `pyproject.toml` / script). + PythonLock, + VltLock, + Maven, + NuGet, + Unsupported, +} + +fn format_of(rel: &str) -> Format { + let leaf = rel.rsplit('/').next().unwrap_or(rel); + match leaf { + "package-lock.json" | "npm-shrinkwrap.json" => Format::NpmLock, + "yarn.lock" => Format::YarnLock, + "pnpm-lock.yaml" | "shrinkwrap.yaml" => Format::PnpmLock, + "bun.lock" => Format::BunLock, + "bun.lockb" => Format::BunLockb, + "Cargo.toml" | "Cargo.lock" | "config.toml" | "config" => Format::Cargo, + "go.mod" | "go.sum" | "go.work" => Format::Golang, + "Gemfile.lock" | "gems.locked" | "Gemfile" | "gems.rb" => Format::Gem, + "composer.lock" => Format::Composer, + "Pipfile.lock" => Format::PipfileLock, + "poetry.lock" => Format::PoetryLock, + "pdm.lock" => Format::PdmLock, + "pyproject.toml" | "hatch.toml" => Format::Hatch, + leaf if crate::utils::python_lock::is_python_lock_name(leaf) => Format::PythonLock, + // The root requirements.txt and the `-r` includes discovery walks. + leaf if leaf.ends_with(".txt") => Format::Requirements, + "vlt-lock.json" => Format::VltLock, + "pom.xml" => Format::Maven, + "nuget.config" | "NuGet.config" | "NuGet.Config" | "packages.lock.json" => Format::NuGet, + _ => Format::Unsupported, + } +} + +/// Restore every pin in `pins` to its default upstream registry entry. +/// See the module docs for the contract. +pub async fn restore_upstream( + root: &Path, + pins: &[HostedPin], + opts: &RestoreOptions, +) -> RestoreOutcome { + let client = UpstreamClient::new(opts.offline); + let ctx = Ctx { + client: &client, + origins: &opts.patch_server_origins, + bun_lockb: opts.bun_lockb, + }; + + // Pins refused so far (uuid → reason). Each pass restores the pins not + // yet refused over a FRESH view; a pass that refuses a new pin is rerun + // without it, so the final view restores exactly the surviving set. + let mut refused: BTreeMap = BTreeMap::new(); + let (view, result) = loop { + let active: Vec<&HostedPin> = pins + .iter() + .filter(|p| !refused.contains_key(&p.uuid)) + .collect(); + let mut view = View::new(root); + let result = restore_pass(&mut view, &active, &ctx).await; + let mut grew = false; + for (uuid, why) in &result.refused { + if !refused.contains_key(uuid) { + refused.insert(uuid.clone(), why.clone()); + grew = true; + } + } + // A pin no restorer claimed has wiring nothing here can unwind. + for pin in &active { + if !result.handled.contains(&pin.uuid) && !refused.contains_key(&pin.uuid) { + refused.insert( + pin.uuid.clone(), + format!( + "no hosted wiring for {} was found in {} that socket-patch can \ + restore to the upstream registry entry", + pin.purl, + if pin.files.is_empty() { + "the lockfiles".to_string() + } else { + pin.files.join(", ") + } + ), + ); + grew = true; + } + } + if !grew { + break (view, result); + } + }; + + let changed = view.changed(); + let changed_bytes = view.changed_bytes(); + let reverted_files: BTreeSet = changed + .keys() + .chain(changed_bytes.keys()) + .cloned() + .collect(); + let flush_error = if opts.dry_run || (changed.is_empty() && changed_bytes.is_empty()) { + None + } else { + flush_staged(root, &changed, &changed_bytes).await.err() + }; + + let pins_out = pins + .iter() + .map(|pin| PinResult { + purl: pin.purl.clone(), + uuid: pin.uuid.clone(), + status: match refused.get(&pin.uuid) { + Some(why) => PinStatus::Refused(format!( + "cannot restore {} to its upstream registry entry: {why}; {}", + pin.purl, + checkout_remedy(&pin.files) + )), + None => PinStatus::Restored, + }, + files: pin.files.clone(), + }) + .collect(); + + RestoreOutcome { + pins: pins_out, + reverted_files: reverted_files.into_iter().collect(), + warnings: result.warnings, + flush_error, + } +} + +/// One restore pass over `view` for the `active` pins. +async fn restore_pass(view: &mut View<'_>, active: &[&HostedPin], ctx: &Ctx<'_>) -> FormatResult { + let mut by_format: BTreeMap, BTreeSet)> = BTreeMap::new(); + for pin in active { + for file in &pin.files { + let slot = by_format.entry(format_of(file)).or_default(); + if !slot.0.iter().any(|p| p.uuid == pin.uuid) { + slot.0.push(pin); + } + slot.1.insert(file.clone()); + } + } + let mut out = FormatResult::default(); + for (format, (pins, files)) in by_format { + let files: Vec = files.into_iter().collect(); + let result = match format { + Format::NpmLock => npm::restore_npm_locks(view, &pins, &files, ctx).await, + Format::YarnLock => npm::restore_yarn_locks(view, &pins, &files, ctx).await, + Format::PnpmLock => npm::restore_pnpm_locks(view, &pins, &files, ctx).await, + Format::BunLock => npm::restore_bun_locks(view, &pins, &files, ctx).await, + Format::BunLockb if ctx.bun_lockb => { + bun_lockb::restore(view, &pins, &files, ctx).await + } + Format::Cargo => cargo::restore(view, &pins, &files, ctx).await, + Format::Golang => golang::restore(view, &pins, &files, ctx).await, + Format::Gem => gem::restore(view, &pins, &files, ctx).await, + Format::Composer => composer::restore(view, &pins, &files, ctx).await, + Format::PipfileLock => pypi::restore_pipfile_lock(view, &pins, &files, ctx).await, + Format::PoetryLock => pypi_locks::restore_poetry(view, &pins, &files, ctx).await, + Format::PdmLock => pypi_locks::restore_pdm(view, &pins, &files, ctx).await, + Format::Requirements => pypi::restore_requirements(view, &pins, &files, ctx).await, + Format::Hatch => pypi::restore_hatch(view, &pins, &files, ctx).await, + Format::PythonLock => uv::restore(view, &pins, &files, ctx).await, + Format::VltLock => vlt::restore(view, &pins, &files, ctx).await, + Format::Maven => maven::restore(view, &pins, &files, ctx).await, + Format::NuGet => nuget::restore(view, &pins, &files, ctx).await, + Format::Unsupported | Format::BunLockb => { + let mut r = FormatResult::default(); + for pin in &pins { + let unsupported: Vec<&str> = pin + .files + .iter() + .filter(|f| format_of(f) == format) + .map(String::as_str) + .collect(); + let why = if format == Format::BunLockb { + format!( + "{} is a binary lock whose rebuilt registry record is not \ + byte-exact for every lock, so it is not restored here", + unsupported.join(", ") + ) + } else { + format!( + "socket-patch cannot re-derive the upstream entry in {}", + unsupported.join(", ") + ) + }; + r.refuse(&pin.uuid, why); + } + r + } + }; + out.merge(result); + } + // The npm-family side settings a hosted run may have written, once no + // npm-family lock entry needs them any more. + npm::cleanup_side_config(view, ctx, &mut out).await; + out +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs new file mode 100644 index 000000000..d7a0a2d9c --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -0,0 +1,742 @@ +//! npm-family upstream restores: package-lock.json / npm-shrinkwrap.json, +//! yarn.lock (classic and berry), pnpm-lock.yaml, bun.lock — plus the +//! npm-family side settings a hosted run writes (`.npmrc` +//! `allow-remote=all`, pnpm-workspace.yaml `trustLockfile: true`). +//! +//! Every restorer rewrites ONLY entries whose resolution is a hosted URL +//! naming one of the in-scope patch uuids; every other byte of the file is +//! the file's own. The upstream values come from the npm registry's +//! version document (`dist.tarball`, `dist.integrity`, `dist.shasum`). + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; +use serde_json::Value; + +use super::client::NpmDist; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::utils::line_endings::{to_lf, LineEndings}; + +/// The pins by uuid. +pub(super) fn by_uuid<'p>(pins: &[&'p HostedPin]) -> BTreeMap<&'p str, &'p HostedPin> { + pins.iter().map(|p| (p.uuid.as_str(), *p)).collect() +} + +/// Resolve the dist of every `(uuid, name, version)` wanted, concurrently. +/// A failed lookup refuses its pin. +pub(super) async fn fetch_dists( + wanted: &BTreeSet<(String, String, String)>, + ctx: &Ctx<'_>, + result: &mut FormatResult, +) -> BTreeMap<(String, String), NpmDist> { + let lookups = wanted.iter().map(|(uuid, name, version)| async move { + ( + uuid.clone(), + name.clone(), + version.clone(), + ctx.client.npm_dist(name, version).await, + ) + }); + let mut out = BTreeMap::new(); + for (uuid, name, version, dist) in futures_util::future::join_all(lookups).await { + match dist { + Ok(dist) => { + out.insert((name, version), dist); + } + Err(why) => result.refuse(&uuid, format!("{name}@{version}: {why}")), + } + } + out +} + +// ── package-lock.json / npm-shrinkwrap.json ───────────────────────────────── + +/// One hosted entry of an npm lock: its JSON pointer, and what it stands for. +struct NpmHit { + pointer: String, + uuid: String, + name: String, + version: String, +} + +fn npm_lock_hits(lock: &Value, ctx: &Ctx<'_>) -> Vec { + let mut hits = Vec::new(); + if let Some(packages) = lock.get("packages").and_then(Value::as_object) { + for (key, entry) in packages { + let Some((_, key_name)) = key.rsplit_once("node_modules/") else { + continue; + }; + let Some(uuid) = entry + .get("resolved") + .and_then(Value::as_str) + .and_then(|u| ctx.hosted_uuid(u)) + else { + continue; + }; + let name = entry + .get("name") + .and_then(Value::as_str) + .unwrap_or(key_name) + .to_string(); + let Some(version) = entry.get("version").and_then(Value::as_str) else { + continue; + }; + hits.push(NpmHit { + pointer: format!("/packages/{}", json_pointer_escape(key)), + uuid, + name, + version: version.to_string(), + }); + } + } + if let Some(deps) = lock.get("dependencies").and_then(Value::as_object) { + v2_hits(deps, "/dependencies", ctx, &mut hits, 0); + } + hits +} + +fn v2_hits( + deps: &serde_json::Map, + prefix: &str, + ctx: &Ctx<'_>, + hits: &mut Vec, + depth: usize, +) { + if depth > 64 { + return; + } + for (name, entry) in deps { + let pointer = format!("{prefix}/{}", json_pointer_escape(name)); + if let (Some(uuid), Some(version)) = ( + entry + .get("resolved") + .and_then(Value::as_str) + .and_then(|u| ctx.hosted_uuid(u)), + entry.get("version").and_then(Value::as_str), + ) { + hits.push(NpmHit { + pointer: pointer.clone(), + uuid, + name: name.clone(), + version: version.to_string(), + }); + } + if let Some(nested) = entry.get("dependencies").and_then(Value::as_object) { + v2_hits(nested, &format!("{pointer}/dependencies"), ctx, hits, depth + 1); + } + } +} + +fn json_pointer_escape(key: &str) -> String { + key.replace('~', "~0").replace('/', "~1") +} + +pub(crate) async fn restore_npm_locks( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let Ok(mut lock) = serde_json::from_str::(&text) else { + refuse_all_in(&pins, rel, &mut result, format!("{rel} is not valid JSON")); + continue; + }; + let hits: Vec = npm_lock_hits(&lock, ctx) + .into_iter() + .filter(|h| pins.contains_key(h.uuid.as_str())) + .collect(); + let wanted: BTreeSet<(String, String, String)> = hits + .iter() + .map(|h| (h.uuid.clone(), h.name.clone(), h.version.clone())) + .collect(); + let dists = fetch_dists(&wanted, ctx, &mut result).await; + let mut changed = false; + for hit in &hits { + if result.refused.contains_key(&hit.uuid) { + continue; + } + let Some(dist) = dists.get(&(hit.name.clone(), hit.version.clone())) else { + continue; + }; + let Some(integrity) = dist.integrity.as_deref() else { + result.refuse( + &hit.uuid, + format!( + "the registry records no integrity for {}@{}", + hit.name, hit.version + ), + ); + continue; + }; + let Some(entry) = lock.pointer_mut(&hit.pointer).and_then(Value::as_object_mut) + else { + continue; + }; + entry.insert("resolved".into(), Value::String(dist.tarball.clone())); + entry.insert("integrity".into(), Value::String(integrity.to_string())); + result.handled.insert(hit.uuid.clone()); + changed = true; + } + if changed { + view.write(rel, super::super::serialize_json(&lock)); + } + } + result +} + +/// Read `rel` through the view; a missing or unreadable file refuses every +/// pin discovery found in it. +pub(super) async fn read_or_refuse( + view: &mut View<'_>, + rel: &str, + pins: &BTreeMap<&str, &HostedPin>, + result: &mut FormatResult, +) -> Option { + match view.read(rel).await { + Ok(Some(text)) => Some(text), + Ok(None) => { + refuse_all_in(pins, rel, result, format!("{rel} no longer exists")); + None + } + Err(e) => { + refuse_all_in(pins, rel, result, e); + None + } + } +} + +pub(super) fn refuse_all_in( + pins: &BTreeMap<&str, &HostedPin>, + rel: &str, + result: &mut FormatResult, + why: String, +) { + for pin in pins.values() { + if pin.files.iter().any(|f| f == rel) { + result.refuse(&pin.uuid, why.clone()); + } + } +} + +// ── yarn.lock ──────────────────────────────────────────────────────────────── + +/// yarn v1's default registry host, used for a classic lock's `resolved` +/// unless `SOCKET_NPM_REGISTRY` names another. +const YARN_CLASSIC_REGISTRY: &str = "https://registry.yarnpkg.com"; + +fn yarn_classic_tarball(dist: &NpmDist) -> String { + if std::env::var("SOCKET_NPM_REGISTRY").is_ok_and(|v| !v.trim().is_empty()) { + return dist.tarball.clone(); + } + match dist + .tarball + .strip_prefix(crate::vendor::registry_fetch::DEFAULT_NPM_REGISTRY) + { + Some(rest) => format!("{YARN_CLASSIC_REGISTRY}{rest}"), + None => dist.tarball.clone(), + } +} + +pub(crate) async fn restore_yarn_locks( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(raw) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + if super::super::is_berry_lock(&raw) { + restore_berry(view, rel, &raw, &pins, ctx, &mut result).await; + } else { + restore_classic(view, rel, &raw, &pins, ctx, &mut result).await; + } + } + result +} + +async fn restore_classic( + view: &mut View<'_>, + rel: &str, + raw: &str, + pins: &BTreeMap<&str, &HostedPin>, + ctx: &Ctx<'_>, + result: &mut FormatResult, +) { + let eol = LineEndings::of(raw); + if eol == LineEndings::Mixed { + refuse_all_in(pins, rel, result, format!("{rel} mixes line endings")); + return; + } + let content = to_lf(raw); + let mut blocks: Vec = content.split("\n\n").map(String::from).collect(); + let resolved_re = + Regex::new(r#"\n {2}resolved "([^"]*)""#).expect("static resolved-line regex is valid"); + let integrity_re = + Regex::new(r"\n {2}integrity [^\n]*").expect("static integrity-line regex is valid"); + let version_re = + Regex::new(r#"\n {2}version "([^"]*)""#).expect("static version-line regex is valid"); + + // (block index, uuid, name, version) per hosted block. + let mut hits: Vec<(usize, String, String, String)> = Vec::new(); + for (i, block) in blocks.iter().enumerate() { + let Some(uuid) = resolved_re + .captures(block) + .and_then(|c| ctx.hosted_uuid(&c[1])) + else { + continue; + }; + if !pins.contains_key(uuid.as_str()) { + continue; + } + let name = super::super::yarn_classic_block_head(block).and_then(|(_, n)| n); + let version = version_re.captures(block).map(|c| c[1].to_string()); + match (name, version) { + (Some(name), Some(version)) => hits.push((i, uuid, name, version)), + _ => result.refuse( + &uuid, + format!("a {rel} entry wiring it names no single package and version"), + ), + } + } + let wanted = hits + .iter() + .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) + .collect(); + let dists = fetch_dists(&wanted, ctx, result).await; + let mut changed = false; + for (i, uuid, name, version) in hits { + if result.refused.contains_key(&uuid) { + continue; + } + let Some(dist) = dists.get(&(name.clone(), version.clone())) else { + continue; + }; + let Some(integrity) = dist.integrity.as_deref() else { + result.refuse( + &uuid, + format!("the registry records no integrity for {name}@{version}"), + ); + continue; + }; + let frag = dist + .shasum + .as_deref() + .map(|s| format!("#{s}")) + .unwrap_or_default(); + let resolved = format!( + "\n resolved \"{}{frag}\"", + yarn_classic_tarball(dist).replace('$', "$$") + ); + let mut block = resolved_re + .replace(&blocks[i], resolved.as_str()) + .into_owned(); + if integrity_re.is_match(&block) { + block = integrity_re + .replace(&block, format!("\n integrity {integrity}").as_str()) + .into_owned(); + } + blocks[i] = block; + result.handled.insert(uuid); + changed = true; + } + if changed { + view.write(rel, eol.restore(&blocks.join("\n\n")).into_owned()); + } +} + +async fn restore_berry( + view: &mut View<'_>, + rel: &str, + raw: &str, + pins: &BTreeMap<&str, &HostedPin>, + ctx: &Ctx<'_>, + result: &mut FormatResult, +) { + use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern}; + + let (bom, body) = match raw.strip_prefix('\u{feff}') { + Some(rest) => ("\u{feff}", rest), + None => ("", raw), + }; + let yarnrc_rel = match rel.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/.yarnrc.yml"), + None => ".yarnrc.yml".to_string(), + }; + let yarnrc = view.read(&yarnrc_rel).await.ok().flatten(); + if let Err(w) = super::super::preflight_yarn_berry_hosted(raw, yarnrc.as_deref()) { + refuse_all_in(pins, rel, result, w.detail); + return; + } + let eol = LineEndings::of(body); + let content = to_lf(body).into_owned(); + let mut blocks: Vec = content.split("\n\n").map(String::from).collect(); + let resolution_re = Regex::new(r#"\n {2}resolution: "([^"]*)""#) + .expect("static resolution-line regex is valid"); + let checksum_re = + Regex::new(r"\n {2}checksum: [^\n]*").expect("static checksum-line regex is valid"); + let version_re = + Regex::new(r"\n {2}version: ([^\n]*)").expect("static version-line regex is valid"); + + let mut hits: Vec<(usize, String, String, String)> = Vec::new(); + for (i, block) in blocks.iter().enumerate() { + let Some(resolution) = resolution_re.captures(block).map(|c| c[1].to_string()) else { + continue; + }; + let Some((_, archive)) = resolution.split_once("::__archiveUrl=") else { + continue; + }; + let archive = archive.split('&').next().unwrap_or(archive); + let Some(uuid) = ctx.hosted_uuid(archive) else { + continue; + }; + if !pins.contains_key(uuid.as_str()) { + continue; + } + let key = block + .lines() + .next() + .and_then(|l| l.strip_suffix(':')) + .unwrap_or(""); + let patterns = split_berry_key_patterns(key); + let names: BTreeSet = patterns + .iter() + .filter_map(|p| split_pattern(p).map(|(n, _)| n.to_string())) + .collect(); + let version = version_re + .captures(block) + .map(|c| c[1].trim().trim_matches('"').to_string()); + match (names.len(), names.into_iter().next(), version) { + (1, Some(name), Some(version)) => hits.push((i, uuid, name.to_string(), version)), + _ => result.refuse( + &uuid, + format!("a {rel} entry wiring it names no single package and version"), + ), + } + } + let mut changed = false; + for (i, uuid, name, version) in hits { + if result.refused.contains_key(&uuid) { + continue; + } + let checksum = match ctx.client.npm_tarball(&name, &version).await.and_then(|tgz| { + crate::vendor::berry_zip::berry_cache_checksum_10c0(&tgz, &name) + }) { + Ok(c) => crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(&content, &c), + Err(why) => { + result.refuse(&uuid, format!("{name}@{version}: {why}")); + continue; + } + }; + let resolution = format!("\n resolution: \"{name}@npm:{version}\"").replace('$', "$$"); + let mut block = resolution_re + .replace(&blocks[i], resolution.as_str()) + .into_owned(); + if checksum_re.is_match(&block) { + block = checksum_re + .replace(&block, format!("\n checksum: {checksum}").as_str()) + .into_owned(); + } + blocks[i] = block; + result.handled.insert(uuid); + changed = true; + } + if changed { + view.write( + rel, + format!("{bom}{}", eol.restore(&blocks.join("\n\n"))), + ); + } +} + +// ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── + +pub(crate) async fn restore_pnpm_locks( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + use super::super::pnpm; + + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + // (resolution range, uuid, name, version, rebuilt-without-integrity) + let mut hits: Vec<(std::ops::Range, String, String, String)> = Vec::new(); + for entry in pnpm::entries(&text) { + let Some(resolution) = pnpm::resolution(&entry) else { + continue; + }; + let Some(uuid) = resolution.tarball().and_then(|t| ctx.hosted_uuid(t)) else { + continue; + }; + let Some(pin) = pins.get(uuid.as_str()) else { + continue; + }; + let Some((name, version)) = pin.name_version() else { + result.refuse(&uuid, format!("{} is not an npm purl", pin.purl)); + continue; + }; + if pnpm::suffix(entry.key, &name, &version).is_none() { + result.refuse( + &uuid, + format!( + "the {rel} entry `{}` wiring it is not {name}@{version}", + entry.key + ), + ); + continue; + } + hits.push((resolution.range.clone(), uuid, name, version)); + } + let wanted = hits + .iter() + .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) + .collect(); + let dists = fetch_dists(&wanted, ctx, &mut result).await; + let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); + let mut handled: Vec = Vec::new(); + for entry in pnpm::entries(&text) { + let Some(resolution) = pnpm::resolution(&entry) else { + continue; + }; + let Some((_, uuid, name, version)) = + hits.iter().find(|(r, ..)| *r == resolution.range) + else { + continue; + }; + if result.refused.contains_key(uuid) { + continue; + } + let Some(integrity) = dists + .get(&(name.clone(), version.clone())) + .and_then(|d| d.integrity.clone()) + else { + result.refuse( + uuid, + format!("the registry records no integrity for {name}@{version}"), + ); + continue; + }; + splices.push((resolution.range.clone(), resolution.restore(&integrity))); + handled.push(uuid.clone()); + } + // A refusal recorded after a splice was planned (a second instance + // of the same pin) drops that pin's splices too. + let splices: Vec<_> = splices + .into_iter() + .zip(&handled) + .filter(|(_, u)| !result.refused.contains_key(*u)) + .map(|(s, _)| s) + .collect(); + if splices.is_empty() { + continue; + } + let mut out = String::with_capacity(text.len()); + let mut cursor = 0; + let mut sorted = splices; + sorted.sort_by_key(|(r, _)| r.start); + for (range, replacement) in sorted { + out.push_str(&text[cursor..range.start]); + out.push_str(&replacement); + cursor = range.end; + } + out.push_str(&text[cursor..]); + for uuid in handled { + if !result.refused.contains_key(&uuid) { + result.handled.insert(uuid); + } + } + view.write(rel, out); + } + result +} + +// ── bun.lock ───────────────────────────────────────────────────────────────── + +pub(crate) async fn restore_bun_locks( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + use crate::vendor::bun_lock_text::{decode_json_string, split_name_spec}; + + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let (mut lines, entries) = match super::super::parse_bun_hosted_lock(&text) { + Ok(parsed) => parsed, + Err(w) => { + refuse_all_in(&pins, rel, &mut result, w.detail); + continue; + } + }; + // (line, uuid, name, version, deps) + let mut hits: Vec<(usize, String, String, String, String)> = Vec::new(); + for entry in &entries { + if !matches!(entry.elems.len(), 2 | 3) || !entry.elems[1].starts_with('{') { + continue; + } + let Some(spec) = entry.elems.first().and_then(|e| decode_json_string(e)) else { + continue; + }; + let Some((name, url)) = split_name_spec(&spec) else { + continue; + }; + let Some(uuid) = ctx.hosted_uuid(url) else { + continue; + }; + let Some(pin) = pins.get(uuid.as_str()) else { + continue; + }; + match pin.name_version() { + Some((pin_name, version)) if pin_name == name => hits.push(( + entry.line_idx, + uuid, + name.to_string(), + version, + entry.elems[1].clone(), + )), + _ => result.refuse( + &uuid, + format!("the {rel} entry `{}` wiring it is not {}", entry.key, pin.purl), + ), + } + } + let wanted = hits + .iter() + .map(|(_, u, n, v, _)| (u.clone(), n.clone(), v.clone())) + .collect(); + let dists = fetch_dists(&wanted, ctx, &mut result).await; + let mut changed = false; + for (line_idx, uuid, name, version, deps) in hits { + if result.refused.contains_key(&uuid) { + continue; + } + let Some(integrity) = dists + .get(&(name.clone(), version.clone())) + .and_then(|d| d.integrity.clone()) + else { + result.refuse( + &uuid, + format!("the registry records no integrity for {name}@{version}"), + ); + continue; + }; + let Some(entry) = entries.iter().find(|e| e.line_idx == line_idx) else { + continue; + }; + let original = &lines[line_idx]; + let cr = if original.ends_with('\r') { "\r" } else { "" }; + let json = |s: &str| serde_json::to_string(s).expect("a str serializes to JSON"); + lines[line_idx] = format!( + "{indent}{key}: [{spec}, \"\", {deps}, {integrity}]{comma}{cr}", + indent = entry.indent, + key = entry.key_raw, + spec = json(&format!("{name}@{version}")), + integrity = json(&integrity), + comma = if entry.trailing_comma { "," } else { "" }, + ); + result.handled.insert(uuid); + changed = true; + } + if changed { + view.write(rel, lines.join("\n")); + } + } + result +} + +// ── side settings ──────────────────────────────────────────────────────────── + +/// Whether any npm / pnpm lock in the view still resolves a hosted URL. +async fn still_hosted(view: &mut View<'_>, rels: &[&str], ctx: &Ctx<'_>) -> bool { + for rel in rels { + let Ok(Some(text)) = view.read(rel).await else { + continue; + }; + if !crate::vex::discover::hosted_uuids_in_text(&text, ctx.origins).is_empty() { + return true; + } + } + false +} + +/// Remove the `.npmrc` / pnpm-workspace.yaml a hosted run CREATED (still +/// byte-identical to the scaffold) once no lock entry needs it; warn about +/// the setting when the file carries other content too (the line may be +/// the user's own, so it is never removed from a file the user wrote). +pub(crate) async fn cleanup_side_config( + view: &mut View<'_>, + ctx: &Ctx<'_>, + result: &mut FormatResult, +) { + use super::super::npmrc::{NPMRC_ALLOW_REMOTE_LINE, NPMRC_CREATED, NPMRC_REL}; + + let restored_npm_lock = view.staged.keys().any(|k| { + matches!( + k.rsplit('/').next(), + Some("package-lock.json" | "npm-shrinkwrap.json") + ) + }); + if restored_npm_lock + && !still_hosted(view, &["package-lock.json", "npm-shrinkwrap.json"], ctx).await + { + if let Ok(Some(npmrc)) = view.read(NPMRC_REL).await { + if npmrc == NPMRC_CREATED { + view.remove(NPMRC_REL); + } else if npmrc + .lines() + .any(|l| l.trim() == NPMRC_ALLOW_REMOTE_LINE) + { + result.warnings.push(( + "npm_allow_remote_left", + format!( + "{NPMRC_REL} keeps `{NPMRC_ALLOW_REMOTE_LINE}`, which hosted mode may \ + have added; no lock entry needs it any more, so remove the line if \ + nothing else does" + ), + )); + } + } + } + + let restored_pnpm = view + .staged + .keys() + .any(|k| k == "pnpm-lock.yaml"); + if restored_pnpm && !still_hosted(view, &["pnpm-lock.yaml"], ctx).await { + const WORKSPACE: &str = "pnpm-workspace.yaml"; + if let Ok(Some(ws)) = view.read(WORKSPACE).await { + if ws == "packages:\n - '.'\ntrustLockfile: true\n" { + view.remove(WORKSPACE); + } else if ws.lines().any(|l| l.trim_end() == "trustLockfile: true") { + result.warnings.push(( + "pnpm_trust_lockfile_left", + format!( + "{WORKSPACE} keeps `trustLockfile: true`, which hosted mode may have \ + added; no lock entry needs it any more, so remove the line if nothing \ + else does" + ), + )); + } + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs new file mode 100644 index 000000000..77b5a2eb3 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -0,0 +1,568 @@ +//! NuGet upstream restore: the inverse of `rewrite_nuget` over the root +//! `nuget.config` (the file discovery names) and its `packages.lock.json`. +//! +//! The config loses the `` source and its +//! exact-id `` mapping. A `` left with +//! nothing but a `*` fan-out for every remaining source — the shape the +//! rewriter authors around a from-scratch mapping — routes exactly like no +//! mapping at all, so it is dropped too. What stays: a `nuget.org` source +//! the rewriter seeded into a source-less config (indistinguishable from +//! one the user wrote), and a config it created from scratch (identical to +//! a user's default config, so it is kept and a warning says so). +//! +//! Every lock entry of the id gets nuget.org's `contentHash` back (the +//! catalog `packageHash`, see [`UpstreamClient::nuget_content_hash`]) — only +//! when the restored config resolves the id from nuget.org alone: another +//! feed (or several) may serve different bytes, and socket-patch cannot +//! tell which one the original lock came from, so such a pin is refused. +//! +//! [`UpstreamClient::nuget_content_hash`]: super::UpstreamClient::nuget_content_hash + +use regex::Regex; +use serde_json::Value; + +use super::npm::{by_uuid, read_or_refuse, refuse_all_in}; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::vendor::nuget_config::{parse_config, NugetConfig}; +use crate::vendor::nuget_feed::normalize_nuget_version; + +const PACKAGES_LOCK: &str = "packages.lock.json"; +const NUGET_ORG_INDEX: &str = "https://api.nuget.org/v3/index.json"; + +fn is_nuget_org(url: &str) -> bool { + url.trim() + .trim_end_matches('/') + .eq_ignore_ascii_case(NUGET_ORG_INDEX) +} + +/// The config's first quoted value of `attr` inside the tag text `tag`. +fn attr_value(tag: &str, attr: &str) -> Option { + let re = Regex::new(&format!( + r#"\s{attr}\s*=\s*(?:"([^"]*)"|'([^']*)')"#, + attr = regex::escape(attr) + )) + .expect("escaped attribute regex is valid"); + re.captures(tag) + .and_then(|c| c.get(1).or_else(|| c.get(2))) + .map(|m| m.as_str().to_string()) +} + +/// Remove one pin's source definition and mapping, each with the line +/// break before it (the rewriter's own insertion). +fn remove_source(config: &str, uuid: &str, id: &str, ctx: &Ctx<'_>) -> Result { + let key = format!("socket-patch-{uuid}"); + let quoted = format!(r#"(?:"{k}"|'{k}')"#, k = regex::escape(&key)); + let add_re = Regex::new(&format!( + r"(?:\r?\n[ \t]*)?]*?key\s*=\s*{quoted}[^>]*?/>" + )) + .expect("escaped add regex is valid"); + let adds: Vec<(usize, usize)> = add_re + .find_iter(config) + .filter(|m| { + attr_value(m.as_str(), "value") + .and_then(|v| ctx.hosted_uuid(&v)) + .as_deref() + == Some(uuid) + }) + .map(|m| (m.start(), m.end())) + .collect(); + let [(add_start, add_end)] = adds[..] else { + return Err(format!( + "the config does not define the Socket source {key} exactly once" + )); + }; + let mut out = format!("{}{}", &config[..add_start], &config[add_end..]); + + let map_re = Regex::new(&format!( + r"(?s)(?:\r?\n[ \t]*)?(.*?)" + )) + .expect("escaped packageSource regex is valid"); + let maps: Vec> = map_re.captures_iter(&out).collect(); + let [only] = &maps[..] else { + return Err(format!("the config does not map {key} exactly once")); + }; + let pattern_re = Regex::new(r#""#) + .expect("static package-pattern regex is valid"); + let patterns: Vec = pattern_re + .captures_iter(&only[1]) + .filter_map(|c| { + c.get(1) + .or_else(|| c.get(2)) + .map(|m| m.as_str().trim().to_string()) + }) + .collect(); + if !matches!(&patterns[..], [p] if p.eq_ignore_ascii_case(id)) { + return Err(format!( + "the config maps {key} to {patterns:?}, not to the one package {id}" + )); + } + let whole = only.get(0).expect("group 0 always matches"); + out = format!("{}{}", &out[..whole.start()], &out[whole.end()..]); + if out.contains(&key) { + return Err(format!("the config still names {key} elsewhere")); + } + Ok(out) +} + +/// Drop a `` that no longer routes anything: empty, +/// or a `*` fan-out for exactly every remaining source. +fn drop_fanout_mapping(config: &str) -> String { + let Some(cfg) = parse_config(config) else { + return config.to_string(); + }; + let mut mapped: Vec<&str> = cfg.mappings.iter().map(|(k, _)| k.as_str()).collect(); + let mut sources: Vec<&str> = cfg.sources.iter().map(|(k, _)| k.as_str()).collect(); + mapped.sort_unstable(); + sources.sort_unstable(); + let fanout_only = cfg + .mappings + .iter() + .all(|(_, patterns)| matches!(&patterns[..], [p] if p == "*")); + if !(cfg.mappings.is_empty() || (fanout_only && mapped == sources)) { + return config.to_string(); + } + let re = Regex::new(r"(?s).*?") + .expect("static packageSourceMapping regex is valid"); + let found: Vec<(usize, usize)> = re.find_iter(config).map(|m| (m.start(), m.end())).collect(); + let [(start, end)] = found[..] else { + return config.to_string(); + }; + // The whole lines when the element has them to itself. + let line_start = config[..start].rfind('\n').map_or(0, |i| i + 1); + let tail = &config[end..]; + let eol = if tail.starts_with("\r\n") { + 2 + } else if tail.starts_with('\n') { + 1 + } else { + 0 + }; + if config[line_start..start].trim().is_empty() && eol > 0 { + format!("{}{}", &config[..line_start], &config[end + eol..]) + } else { + format!("{}{}", &config[..start], tail) + } +} + +/// Does `pattern` route `id`, and how specifically (NuGet: an exact id +/// beats the longest `prefix*`, which beats `*`)? +fn pattern_score(pattern: &str, id: &str) -> Option { + match pattern.strip_suffix('*') { + Some(prefix) => (id.len() >= prefix.len() + && id[..prefix.len()].eq_ignore_ascii_case(prefix)) + .then_some(prefix.len()), + None => pattern.eq_ignore_ascii_case(id).then_some(usize::MAX), + } +} + +/// `Ok` when the restored config resolves `id` from nuget.org alone. +fn check_upstream_feed(cfg: &NugetConfig, id: &str, config_rel: &str) -> Result<(), String> { + let enabled: Vec<&(String, String)> = cfg + .sources + .iter() + .filter(|(k, _)| !cfg.disabled.contains(k)) + .collect(); + let candidates: Vec<&(String, String)> = if cfg.mappings.is_empty() { + enabled + } else { + let scored: Vec<(usize, &str)> = cfg + .mappings + .iter() + .filter_map(|(key, patterns)| { + patterns + .iter() + .filter_map(|p| pattern_score(p, id)) + .max() + .map(|s| (s, key.as_str())) + }) + .collect(); + let Some(best) = scored.iter().map(|(s, _)| *s).max() else { + return Err(format!("no package source in {config_rel} maps {id}")); + }; + enabled + .into_iter() + .filter(|(k, _)| scored.iter().any(|(s, key)| *s == best && key == k)) + .collect() + }; + // No source at all: NuGet falls back to the user-level default, nuget.org. + if candidates.iter().all(|(_, url)| is_nuget_org(url)) { + return Ok(()); + } + let names: Vec = candidates + .iter() + .map(|(k, url)| format!("{k} ({url})")) + .collect(); + Err(format!( + "{id} resolves from {} under {config_rel}, not from nuget.org alone, so the original \ + contentHash cannot be re-derived", + names.join(", ") + )) +} + +pub(crate) async fn restore( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let (dir, leaf) = match rel.rsplit_once('/') { + Some((d, l)) => (format!("{d}/"), l), + None => (String::new(), rel.as_str()), + }; + if leaf == PACKAGES_LOCK { + // Restored together with the config that wires it; a pin no + // config claims is refused by the driver. + continue; + } + let Some(original) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let wired: Vec<&HostedPin> = pins + .values() + .copied() + .filter(|p| p.files.contains(rel)) + .collect(); + let mut text = original.clone(); + let mut restored: Vec<(&HostedPin, String, String)> = Vec::new(); + for pin in &wired { + let Some((id, version)) = pin.name_version() else { + result.refuse(&pin.uuid, format!("{} is not a NuGet purl", pin.purl)); + continue; + }; + match remove_source(&text, &pin.uuid, &id, ctx) { + Ok(next) => { + text = next; + restored.push((pin, id, version)); + } + Err(why) => result.refuse(&pin.uuid, format!("{rel}: {why}")), + } + } + if restored.is_empty() { + continue; + } + text = drop_fanout_mapping(&text); + let Some(cfg) = parse_config(&text) else { + refuse_all_in( + &pins, + rel, + &mut result, + format!("restoring {rel} would not leave well-formed XML"), + ); + continue; + }; + + let lock_rel = format!("{dir}{PACKAGES_LOCK}"); + let lock_text = match view.read(&lock_rel).await { + Ok(t) => t, + Err(e) => { + refuse_all_in(&pins, rel, &mut result, e); + continue; + } + }; + let mut lock: Option = match lock_text.as_deref().map(serde_json::from_str) { + None => None, + Some(Ok(v)) => Some(v), + Some(Err(_)) => { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{lock_rel} is not valid JSON"), + ); + continue; + } + }; + for (pin, id, version) in &restored { + let Some(lock) = lock.as_mut() else { + continue; + }; + let entries: Vec<&mut serde_json::Map> = lock + .get_mut("dependencies") + .and_then(Value::as_object_mut) + .into_iter() + .flat_map(|fws| fws.values_mut()) + .filter_map(Value::as_object_mut) + .flat_map(|fw| fw.iter_mut()) + .filter(|(k, _)| k.eq_ignore_ascii_case(id)) + .filter_map(|(_, e)| e.as_object_mut()) + .filter(|e| e.contains_key("contentHash")) + .collect(); + if entries.is_empty() { + continue; + } + if let Err(why) = check_upstream_feed(&cfg, id, rel) { + result.refuse(&pin.uuid, why); + continue; + } + let norm = normalize_nuget_version(version); + let hash = match ctx.client.nuget_content_hash(id, &norm).await { + Ok(h) => h, + Err(why) => { + result.refuse(&pin.uuid, format!("{id} {version}: {why}")); + continue; + } + }; + for entry in entries { + let keeps_resolved = entry + .get("resolved") + .and_then(Value::as_str) + .is_some_and(|r| normalize_nuget_version(r).eq_ignore_ascii_case(&norm)); + if !keeps_resolved { + entry.insert("resolved".into(), Value::String(norm.clone())); + } + entry.insert("contentHash".into(), Value::String(hash.clone())); + } + } + // A refusal in this file reruns the pass without that pin; write + // only a file every wired pin restored in. + if restored + .iter() + .any(|(p, ..)| result.refused.contains_key(&p.uuid)) + { + continue; + } + if text == super::super::default_nuget_config() { + result.warnings.push(( + "nuget_default_config_left", + format!( + "{rel} now holds only the nuget.org source; if hosted mode created it, \ + delete it" + ), + )); + } + view.write(rel, text); + if let (Some(lock), Some(before)) = (lock, lock_text) { + let after = super::super::serialize_json(&lock); + if serde_json::from_str::(&before).ok().as_ref() != Some(&lock) { + view.write(&lock_rel, after); + } + } + result + .handled + .extend(restored.iter().map(|(p, ..)| p.uuid.clone())); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_scores_follow_nuget_precedence() { + assert_eq!(pattern_score("*", "Newtonsoft.Json"), Some(0)); + assert_eq!(pattern_score("Newtonsoft.*", "newtonsoft.json"), Some(11)); + assert_eq!( + pattern_score("newtonsoft.json", "Newtonsoft.Json"), + Some(usize::MAX) + ); + assert_eq!(pattern_score("Contoso.*", "Newtonsoft.Json"), None); + assert_eq!( + pattern_score("Newtonsoft.Json.Bson", "Newtonsoft.Json"), + None + ); + } + + fn cfg(text: &str) -> NugetConfig { + parse_config(text).unwrap() + } + + #[test] + fn upstream_feed_must_be_nuget_org_alone() { + let only_org = cfg( + r#""#, + ); + assert!(check_upstream_feed(&only_org, "A", "nuget.config").is_ok()); + let none = cfg(""); + assert!(check_upstream_feed(&none, "A", "nuget.config").is_ok()); + let two = cfg( + r#""#, + ); + let why = check_upstream_feed(&two, "A", "nuget.config").unwrap_err(); + assert!(why.contains("corp (https://corp/v3/index.json)"), "{why}"); + // A mapping that routes the id to nuget.org only settles it. + let mapped = cfg( + r#""#, + ); + assert!(check_upstream_feed(&mapped, "a", "nuget.config").is_ok()); + let why = check_upstream_feed(&mapped, "B", "nuget.config").unwrap_err(); + assert!(why.contains("corp"), "{why}"); + // A disabled second source is not a candidate. + let disabled = cfg( + r#""#, + ); + assert!(check_upstream_feed(&disabled, "A", "nuget.config").is_ok()); + let unmapped = cfg( + r#""#, + ); + assert!(check_upstream_feed(&unmapped, "A", "nuget.config") + .unwrap_err() + .contains("maps")); + } + + #[test] + fn fanout_mapping_is_dropped_only_when_it_routes_nothing() { + let fanout = "\n \n \n \n \n \n \n \n \n\n"; + assert_eq!( + drop_fanout_mapping(fanout), + "\n \n \n \n\n" + ); + // A second source without a fan-out is excluded by the mapping. + let partial = fanout.replace( + " ", + " \n ", + ); + assert_eq!(drop_fanout_mapping(&partial), partial); + // A real pattern keeps it. + let pinned = fanout.replace("pattern=\"*\"", "pattern=\"Foo.*\""); + assert_eq!(drop_fanout_mapping(&pinned), pinned); + } + + use super::super::{restore_upstream, HostedPin, PinStatus, RestoreOptions, RestoreOutcome}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const UUID: &str = "66666666-6666-6666-6666-666666666666"; + const UPSTREAM: &str = + "ckEKf1MtNHGmiyXVMOQUWA1NhmENd95EZ8h2znGTaccCdgF/RgjlfKWRH+iEdgEx68wOpY+UFhWisuq3tHFA=="; + const PATCHED: &str = "PATCHEDcontenthashPATCHEDcontenthashAA=="; + + fn index_url() -> String { + format!( + "https://patch.socket.dev/patch-registry/nuget/11111111-1111-1111-1111-111111111111/{UUID}/index.json" + ) + } + + /// What `rewrite_nuget` makes of `config` for Newtonsoft.Json. + fn hosted_config(config: &str) -> String { + super::super::super::add_nuget_source( + config, + &format!("socket-patch-{UUID}"), + &index_url(), + "Newtonsoft.Json", + ) + .unwrap() + } + + fn lock(hash: &str) -> String { + format!( + "{{\n \"version\": 1,\n \"dependencies\": {{\n \"net6.0\": {{\n \"Newtonsoft.Json\": {{\n \"type\": \"Direct\",\n \"requested\": \"[13.0.3, )\",\n \"resolved\": \"13.0.3\",\n \"contentHash\": \"{hash}\"\n }}\n }},\n \"net8.0\": {{\n \"newtonsoft.json\": {{\n \"type\": \"Transitive\",\n \"resolved\": \"13.0.3\",\n \"contentHash\": \"{hash}\"\n }}\n }}\n }}\n}}\n" + ) + } + + async fn nuget_org() -> MockServer { + let server = MockServer::start().await; + let catalog = format!("{}/catalog0/data/newtonsoft.json.13.0.3.json", server.uri()); + Mock::given(method("GET")) + .and(path( + "/v3/registration5-gz-semver2/newtonsoft.json/13.0.3.json", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "catalogEntry": catalog })), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/catalog0/data/newtonsoft.json.13.0.3.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "Newtonsoft.Json", + "version": "13.0.3", + "packageHash": UPSTREAM, + "packageHashAlgorithm": "SHA512", + }))) + .mount(&server) + .await; + server + } + + /// Restore the Newtonsoft.Json pin over `config` + `lock(PATCHED)`. + async fn run(config: &str, offline: bool) -> (RestoreOutcome, String, String) { + let server = nuget_org().await; + std::env::set_var("SOCKET_NUGET_URL", server.uri()); + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("nuget.config"), config).unwrap(); + std::fs::write(tmp.path().join(PACKAGES_LOCK), lock(PATCHED)).unwrap(); + let pins = [HostedPin { + purl: "pkg:nuget/Newtonsoft.Json@13.0.3".into(), + uuid: UUID.into(), + files: vec!["nuget.config".into()], + }]; + let opts = RestoreOptions { + offline, + ..RestoreOptions::default() + }; + let outcome = restore_upstream(tmp.path(), &pins, &opts).await; + std::env::remove_var("SOCKET_NUGET_URL"); + let read = |rel: &str| std::fs::read_to_string(tmp.path().join(rel)).unwrap(); + (outcome, read("nuget.config"), read(PACKAGES_LOCK)) + } + + const USER_MAPPING: &str = "\n\n \n \n \n \n \n \n \n \n \n \n \n \n\n"; + + #[tokio::test] + #[serial_test::serial] + async fn an_existing_mapping_keeps_the_users_entries() { + let (outcome, config, lock_after) = run(&hosted_config(USER_MAPPING), false).await; + assert_eq!( + outcome.pins[0].status, + PinStatus::Restored, + "{:?}", + outcome.pins + ); + assert_eq!(config, USER_MAPPING); + assert_eq!(lock_after, lock(UPSTREAM)); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + } + + #[tokio::test] + #[serial_test::serial] + async fn a_config_created_from_scratch_is_kept_and_warned() { + let default = super::super::super::default_nuget_config(); + let (outcome, config, lock_after) = run(&hosted_config(&default), false).await; + assert_eq!(outcome.pins[0].status, PinStatus::Restored); + assert_eq!(config, default); + assert_eq!(lock_after, lock(UPSTREAM)); + assert!(outcome + .warnings + .iter() + .any(|(code, _)| *code == "nuget_default_config_left")); + } + + #[tokio::test] + #[serial_test::serial] + async fn refusals_change_nothing() { + let routed_to_corp = USER_MAPPING.replace("Contoso.*", "Newtonsoft.*"); + let two_patterns = hosted_config(USER_MAPPING).replace( + "", + "\n ", + ); + let cases = [ + (hosted_config(USER_MAPPING), true, "offline"), + ( + hosted_config(&routed_to_corp), + false, + "corp (https://corp.example/v3/index.json)", + ), + (two_patterns, false, "not to the one package"), + ( + USER_MAPPING.to_string(), + false, + "does not define the Socket source", + ), + ]; + for (config, offline, needle) in cases { + let (outcome, after, lock_after) = run(&config, offline).await; + let PinStatus::Refused(why) = &outcome.pins[0].status else { + panic!("{needle}: restored"); + }; + assert!(why.contains(needle), "{needle}: {why}"); + assert!(why.contains("git checkout -- nuget.config"), "{why}"); + assert_eq!(after, config, "{needle}"); + assert_eq!(lock_after, lock(PATCHED), "{needle}"); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs b/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs new file mode 100644 index 000000000..29d1c3949 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs @@ -0,0 +1,1014 @@ +//! PyPI upstream restores for the formats whose hosted rewrite is a single +//! reference swap: `Pipfile.lock` entries, `requirements.txt` lines and +//! Hatch's PEP 508 direct references (`pyproject.toml` / `hatch.toml`). +//! The TOML locks live in [`super::pypi_locks`] (Poetry, PDM) and +//! [`super::uv`] (uv, PEP 723 script locks, PEP 751 pylock). +//! +//! Every restorer rewrites ONLY the entries whose reference is a hosted URL +//! naming an in-scope patch uuid; the version is the pin's (the hosted +//! rewriters drop every `==` pin, and discovery reads it back from the +//! url). Hashes are re-resolved from PyPI's JSON API +//! ([`super::client::UpstreamClient::pypi_files`]): every release file, +//! sorted by filename — what Pipenv and pip-compile record. + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; +use serde_json::{json, Value}; +use toml_edit::{DocumentMut, Item}; + +use super::client::PypiFile; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::python_lock::preserve_line_endings; +use crate::vendor::common::pep508_name; + +/// The pins by uuid. +pub(super) fn by_uuid<'p>(pins: &[&'p HostedPin]) -> BTreeMap<&'p str, &'p HostedPin> { + pins.iter().map(|p| (p.uuid.as_str(), *p)).collect() +} + +/// The in-scope pin a hosted `location` names. +pub(super) fn pin_of<'p>( + location: &str, + pins: &BTreeMap<&str, &'p HostedPin>, + ctx: &Ctx<'_>, +) -> Option<&'p HostedPin> { + let uuid = ctx.hosted_uuid(location)?; + pins.get(uuid.as_str()).copied() +} + +/// `(canonical name, version)` of a pin, or its refusal. +pub(super) fn pin_coords(pin: &HostedPin, result: &mut FormatResult) -> Option<(String, String)> { + match pin.name_version() { + Some((name, version)) => Some((canonicalize_pypi_name(&name), version)), + None => { + result.refuse(&pin.uuid, format!("{} is not a pypi purl", pin.purl)); + None + } + } +} + +/// Read `rel` through the view; a missing or unreadable file refuses every +/// pin discovery found in it. +pub(super) async fn read_or_refuse( + view: &mut View<'_>, + rel: &str, + pins: &BTreeMap<&str, &HostedPin>, + result: &mut FormatResult, +) -> Option { + match view.read(rel).await { + Ok(Some(text)) => Some(text), + Ok(None) => { + refuse_all_in(pins, rel, result, format!("{rel} no longer exists")); + None + } + Err(e) => { + refuse_all_in(pins, rel, result, e); + None + } + } +} + +pub(super) fn refuse_all_in( + pins: &BTreeMap<&str, &HostedPin>, + rel: &str, + result: &mut FormatResult, + why: String, +) { + for pin in pins.values() { + if pin.files.iter().any(|f| f == rel) { + result.refuse(&pin.uuid, why.clone()); + } + } +} + +/// The release files of every `(uuid, name, version)` wanted, fetched +/// concurrently and keyed by `(name, version)`. A failed lookup refuses its +/// pin. +pub(super) async fn fetch_release_files( + wanted: &BTreeSet<(String, String, String)>, + ctx: &Ctx<'_>, + result: &mut FormatResult, +) -> BTreeMap<(String, String), Vec> { + let lookups = wanted.iter().map(|(uuid, name, version)| async move { + ( + uuid.clone(), + name.clone(), + version.clone(), + ctx.client.pypi_files(name, version).await, + ) + }); + let mut out = BTreeMap::new(); + for (uuid, name, version, files) in futures_util::future::join_all(lookups).await { + match files { + Ok(files) => { + out.insert((name, version), files); + } + Err(why) => result.refuse(&uuid, format!("{name}=={version}: {why}")), + } + } + out +} + +/// Whether every wheel of a release installs on every platform and every +/// Python 3 (`py3` in its python tag, `none` ABI, `any` platform), so a +/// lock that keeps only the files its targets can install — PDM without +/// `cross_platform`, uv's `requires-python` / environment filtering — still +/// records all of them. Sdists always qualify. +pub(super) fn universal_release(files: &[PypiFile]) -> bool { + files.iter().all(|f| { + let Some(stem) = f.filename.strip_suffix(".whl") else { + return true; + }; + let tags: Vec<&str> = stem.rsplitn(4, '-').collect(); + matches!(tags.as_slice(), [platform, abi, python, _] + if *platform == "any" && *abi == "none" && python.split('.').any(|t| t == "py3")) + }) +} + +/// A TOML basic string. +pub(super) fn toml_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// A TOML value parsed from `text`, keeping its own layout; its outer +/// decor is cleared so it renders after `key = ` like any other value. +pub(super) fn toml_value(text: &str) -> Option { + let doc: DocumentMut = format!("v = {text}\n").parse().ok()?; + let mut value = doc.get("v")?.as_value()?.clone(); + value.decor_mut().clear(); + Some(value) +} + +/// `entries` as the multi-line array Poetry and PDM write (4-space indent, +/// trailing comma; `[]` when empty). +pub(super) fn multiline_toml_array(entries: &[String]) -> String { + if entries.is_empty() { + return "[]".to_string(); + } + let mut out = String::from("[\n"); + for entry in entries { + out.push_str(" "); + out.push_str(entry); + out.push_str(",\n"); + } + out.push(']'); + out +} + +// ── Pipfile.lock ───────────────────────────────────────────────────────────── + +/// Whether `url` is PyPI's simple index (the only upstream the restore can +/// re-derive hashes for). +pub(super) fn is_pypi_simple(url: &str) -> bool { + matches!( + url.trim() + .trim_end_matches('/') + .to_ascii_lowercase() + .as_str(), + "https://pypi.org/simple" | "https://pypi.python.org/simple" + ) +} + +/// Whether the registry entry restored for `name` resolves from PyPI (the +/// only upstream whose hashes the restore can re-derive). The hosted +/// rewrite keeps the entry's `index` exactly as Pipenv wrote it, so the +/// restore never picks one: `entry_index` is carried back verbatim and only +/// checked here. Pipenv records `index` by release, Pipfile spelling and +/// locking environment (2022.12.19 writes it for an `extras` table and +/// 2026.8.0 does not; neither for a marker-excluded or a transitive +/// package) — nothing the lock's other entries could reveal. +fn pipenv_index_is_pypi( + doc: &Value, + entry_index: Option<&str>, + pipfile: Option<&str>, + name: &str, +) -> Result<(), String> { + let sources: Vec<(&str, &str)> = doc + .pointer("/_meta/sources") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|s| { + Some(( + s.get("name").and_then(Value::as_str)?, + s.get("url").and_then(Value::as_str)?, + )) + }) + .collect(); + if !sources.iter().any(|(_, url)| is_pypi_simple(url)) { + return Err(format!( + "no package index in Pipfile.lock `_meta.sources` is PyPI ({}), so the \ + upstream hashes cannot be re-derived", + sources + .iter() + .map(|(_, u)| *u) + .collect::>() + .join(", ") + )); + } + let is_pypi = |index: &str| { + sources + .iter() + .any(|(n, url)| *n == index && is_pypi_simple(url)) + }; + if let Some(index) = entry_index { + if !is_pypi(index) { + return Err(format!( + "Pipfile.lock installs {name} from index {index:?}, not PyPI" + )); + } + } + if let Some(explicit) = pipfile.and_then(|p| pipfile_explicit_index(p, name)) { + if !is_pypi(&explicit) { + return Err(format!( + "the Pipfile installs {name} from index {explicit:?}, not PyPI" + )); + } + } + Ok(()) +} + +/// The `index = "…"` a Pipfile declares for `name` in any package category. +fn pipfile_explicit_index(pipfile: &str, name: &str) -> Option { + let doc: DocumentMut = pipfile.trim_start_matches('\u{feff}').parse().ok()?; + let canon = canonicalize_pypi_name(name); + for (category, table) in doc.iter() { + if matches!(category, "source" | "requires" | "pipenv" | "scripts") { + continue; + } + let Some(table) = table.as_table_like() else { + continue; + }; + for (key, item) in table.iter() { + if canonicalize_pypi_name(key) != canon { + continue; + } + if let Some(index) = item + .as_table_like() + .and_then(|t| t.get("index")) + .and_then(Item::as_str) + { + return Some(index.to_string()); + } + } + } + None +} + +/// One hosted `Pipfile.lock` entry. +struct PipenvHit { + /// Index into the lock's `pipenv::entries`. + entry: usize, + uuid: String, + name: String, + version: String, +} + +pub(crate) async fn restore_pipfile_lock( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let (entries, doc) = match ( + super::super::pipenv::entries(&text), + crate::vendor::lock_inventory::pypi::parse_pipfile_lock(&text), + ) { + (Ok(entries), Ok(doc)) => (entries, doc), + (Err(e), _) => { + refuse_all_in(&pins, rel, &mut result, format!("{rel}: {e}")); + continue; + } + (_, Err(e)) => { + refuse_all_in( + &pins, + rel, + &mut result, + format!("{rel} is not valid JSON: {e}"), + ); + continue; + } + }; + let mut hits: Vec = Vec::new(); + for (i, (_, entry)) in entries.iter().enumerate() { + let Some(object) = entry.value.as_object() else { + continue; + }; + let reference = object + .get("file") + .or_else(|| object.get("path")) + .and_then(Value::as_str); + let Some(pin) = reference.and_then(|r| pin_of(r, &pins, ctx)) else { + continue; + }; + let Some((_, version)) = pin_coords(pin, &mut result) else { + continue; + }; + if object.contains_key("file") && object.contains_key("path") { + result.refuse( + &pin.uuid, + format!("{rel}: {} carries both `file` and `path`", entry.name), + ); + continue; + } + if let Some(pinned) = object.get("version").and_then(Value::as_str) { + if pinned != format!("=={version}") { + result.refuse( + &pin.uuid, + format!( + "{rel}: {} pins {pinned} beside the hosted {version} reference", + entry.name + ), + ); + continue; + } + } + hits.push(PipenvHit { + entry: i, + uuid: pin.uuid.clone(), + name: entry.name.clone(), + version, + }); + } + if hits.is_empty() { + continue; + } + let pipfile_rel = match rel.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/Pipfile"), + None => "Pipfile".to_string(), + }; + let pipfile = view.read(&pipfile_rel).await.ok().flatten(); + let wanted = hits + .iter() + .filter(|h| !result.refused.contains_key(&h.uuid)) + .map(|h| { + ( + h.uuid.clone(), + canonicalize_pypi_name(&h.name), + h.version.clone(), + ) + }) + .collect(); + let released = fetch_release_files(&wanted, ctx, &mut result).await; + let mut splices: Vec<(std::ops::Range, String, String)> = Vec::new(); + for hit in &hits { + if result.refused.contains_key(&hit.uuid) { + continue; + } + let (_, entry) = &entries[hit.entry]; + let mut object = entry.value.as_object().cloned().unwrap_or_default(); + // `index` (and every other key but the reference, `version` and + // `hashes`) is carried back as the entry holds it: the hosted + // rewrite left Pipenv's own value, and a relock hybrid (Pipenv + // 2023+ keeps our reference on a marker-excluded entry and + // restores `version`/`hashes` around it) holds what Pipenv + // just wrote. + let entry_index = object.get("index").and_then(Value::as_str); + if let Err(why) = pipenv_index_is_pypi(&doc, entry_index, pipfile.as_deref(), &hit.name) + { + result.refuse(&hit.uuid, format!("{rel}: {why}")); + continue; + } + let Some(release) = + released.get(&(canonicalize_pypi_name(&hit.name), hit.version.clone())) + else { + continue; + }; + object.remove("file"); + object.remove("path"); + object.insert("version".into(), json!(format!("=={}", hit.version))); + let mut hashes: Vec = release + .iter() + .map(|f| format!("sha256:{}", f.sha256)) + .collect(); + hashes.sort(); + hashes.dedup(); + object.insert("hashes".into(), json!(hashes)); + let mut value = Value::Object(object); + value.sort_all_objects(); + match super::super::pipenv::format_entry(&value, &text, entry.range.start) { + Ok(rendered) => splices.push((entry.range.clone(), rendered, hit.uuid.clone())), + Err(e) => result.refuse(&hit.uuid, format!("{rel}: {e}")), + } + } + let mut next = text.clone(); + splices.sort_by_key(|(range, _, _)| std::cmp::Reverse(range.start)); + let mut changed = false; + for (range, rendered, uuid) in splices { + // A pin refused in another entry of this lock stays hosted in all. + if result.refused.contains_key(&uuid) { + continue; + } + next.replace_range(range, &rendered); + result.handled.insert(uuid); + changed = true; + } + if changed { + view.write(rel, next); + } + } + result +} + +// ── requirements.txt ───────────────────────────────────────────────────────── + +static HOSTED_LINE_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new(r"^([A-Za-z0-9][A-Za-z0-9._-]*)(\s*\[[^\]\r\n]*\])?\s*@\s*(\S+)(.*)$") + .expect("static hosted requirement regex is valid") +}); + +/// Whether a requirements line carries a `--hash` option. +fn has_hash_option(tokens: &[&str]) -> bool { + tokens + .iter() + .any(|t| *t == "--hash" || t.starts_with("--hash=")) +} + +/// A hosted requirement line, cut into what its registry spelling keeps. +struct HostedLine { + uuid: String, + version: String, + /// BOM + indentation before the name. + prefix: String, + name: String, + extras: String, + marker: String, + options: String, + comment: String, +} + +/// The in-scope hosted line `requirement` is, if any: `Err((uuid, why))` +/// when it is one that cannot be restored. +fn hosted_line( + requirement: &super::super::requirements::LogicalRequirement, + pins: &BTreeMap<&str, &HostedPin>, + ctx: &Ctx<'_>, +) -> Option> { + use super::super::requirements::{requirement_tokens, unquoted_index}; + let text = requirement.text.trim(); + let caps = HOSTED_LINE_RE.captures(text)?; + let pin = pin_of(&caps[3], pins, ctx)?; + let version = match pin.name_version() { + Some((name, version)) + if canonicalize_pypi_name(&caps[1]) == canonicalize_pypi_name(&name) => + { + version + } + _ => { + return Some(Err(( + pin.uuid.clone(), + format!( + "{:?} is wired to the hosted artifact of another package", + &caps[1] + ), + ))) + } + }; + let rest = caps.get(4).map_or("", |m| m.as_str()); + let (body, comment) = + unquoted_index(rest, '#', true).map_or((rest, ""), |i| (&rest[..i], &rest[i..])); + let tokens = requirement_tokens(body); + let marker_len = tokens.iter().take_while(|t| !t.starts_with("--")).count(); + let marker = tokens[..marker_len].join(" "); + let mut options = Vec::new(); + let mut rest_tokens = tokens[marker_len..].iter(); + while let Some(token) = rest_tokens.next() { + if *token == "--hash" { + rest_tokens.next(); + } else if !token.starts_with("--hash=") { + options.push(*token); + } + } + let unprefixed = requirement + .original + .strip_prefix('\u{feff}') + .unwrap_or(&requirement.original); + let indent = &unprefixed[..unprefixed.len() - unprefixed.trim_start_matches([' ', '\t']).len()]; + let bom = if requirement.original.starts_with('\u{feff}') { + "\u{feff}" + } else { + "" + }; + Some(Ok(HostedLine { + uuid: pin.uuid.clone(), + version, + prefix: format!("{bom}{indent}"), + name: caps[1].to_string(), + extras: caps.get(2).map_or("", |m| m.as_str().trim()).to_string(), + marker, + options: options.join(" "), + comment: comment.trim().to_string(), + })) +} + +pub(crate) async fn restore_requirements( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + use super::super::requirements::{logical_requirements, requirement_tokens}; + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some(text) = read_or_refuse(view, rel, &pins, &mut result).await else { + continue; + }; + let mut requirements = logical_requirements(&text); + let mut hits: Vec<(usize, HostedLine)> = Vec::new(); + // The file's hash-checking mode, read off every line the hosted + // rewrite did not write. + let (mut require_hashes, mut hashed, mut unhashed) = (false, 0usize, 0usize); + // The continuation indent of a hashed line pip-compile style + // (`name==v \` then one indented `--hash=…` per line). + let mut continuation: Option = None; + for (i, requirement) in requirements.iter().enumerate() { + match hosted_line(requirement, &pins, ctx) { + Some(Ok(line)) => { + hits.push((i, line)); + continue; + } + Some(Err((uuid, why))) => { + result.refuse(&uuid, format!("{rel}: {why}")); + continue; + } + None => {} + } + let (code, _) = crate::utils::requirements::split_comment(&requirement.text); + let code = code.trim(); + if code.is_empty() { + continue; + } + let tokens = requirement_tokens(code); + if tokens.contains(&"--require-hashes") { + require_hashes = true; + } + if code.starts_with('-') { + continue; + } + // Another pin's hosted line (always hashed) says nothing about + // the original mode. + if tokens + .iter() + .any(|t| ctx.hosted_uuid(t.trim_end_matches(';')).is_some()) + { + continue; + } + if has_hash_option(&tokens) { + hashed += 1; + if continuation.is_none() { + continuation = requirement + .original + .split('\n') + .nth(1) + .filter(|l| l.trim_start().starts_with("--hash")) + .map(|l| l[..l.len() - l.trim_start().len()].to_string()); + } + } else { + unhashed += 1; + } + } + if hits.is_empty() { + continue; + } + let hash_mode = match (require_hashes || hashed > 0, unhashed > 0) { + (true, false) => Ok(true), + (false, true) => Ok(false), + (true, true) => Err(format!( + "{rel} mixes hashed and unhashed requirements, so whether the original line \ + carried `--hash` options is not derivable" + )), + (false, false) => Err(format!( + "every requirement in {rel} is a hosted pin, so whether the original used pip's \ + hash-checking mode (`--hash`) is not derivable" + )), + }; + let hash_mode = match hash_mode { + Ok(mode) => mode, + Err(why) => { + for (_, line) in &hits { + result.refuse(&line.uuid, why.clone()); + } + continue; + } + }; + let released = if hash_mode { + let wanted = hits + .iter() + .map(|(_, l)| { + ( + l.uuid.clone(), + canonicalize_pypi_name(&l.name), + l.version.clone(), + ) + }) + .collect(); + fetch_release_files(&wanted, ctx, &mut result).await + } else { + BTreeMap::new() + }; + let eol = if text.contains("\r\n") { "\r\n" } else { "\n" }; + let mut rewritten: Vec<(usize, String, String)> = Vec::new(); + for (i, line) in &hits { + if result.refused.contains_key(&line.uuid) { + continue; + } + let mut out = format!( + "{}{}{}=={}", + line.prefix, line.name, line.extras, line.version + ); + for suffix in [&line.marker, &line.options] { + if !suffix.is_empty() { + out.push(' '); + out.push_str(suffix); + } + } + if hash_mode { + let Some(release) = + released.get(&(canonicalize_pypi_name(&line.name), line.version.clone())) + else { + continue; + }; + let mut hashes: Vec<&str> = release.iter().map(|f| f.sha256.as_str()).collect(); + hashes.sort(); + hashes.dedup(); + for hash in hashes { + match &continuation { + Some(indent) => { + out.push_str(&format!(" \\{eol}{indent}--hash=sha256:{hash}")) + } + None => out.push_str(&format!(" --hash=sha256:{hash}")), + } + } + } + if !line.comment.is_empty() { + out.push(' '); + out.push_str(&line.comment); + } + rewritten.push((*i, out, line.uuid.clone())); + } + let mut changed = false; + for (i, out, uuid) in rewritten { + if result.refused.contains_key(&uuid) { + continue; + } + requirements[i].original = out; + result.handled.insert(uuid); + changed = true; + } + if changed { + let next: String = requirements + .into_iter() + .map(|r| r.original + &r.ending) + .collect(); + view.write(rel, next); + } + } + result +} + +// ── Hatch: pyproject.toml / hatch.toml ─────────────────────────────────────── + +/// `declared[extras] @ [ ; marker]` → `(declared, extras, +/// location, marker)`. +fn direct_reference(spec: &str) -> Option<(&str, &str, &str, &str)> { + let spec = spec.trim(); + let declared = pep508_name(spec); + if declared.is_empty() { + return None; + } + let mut rest = spec[declared.len()..].trim_start(); + let mut extras = ""; + if rest.starts_with('[') { + let end = rest.find(']')?; + extras = &rest[..=end]; + rest = rest[end + 1..].trim_start(); + } + let rest = rest.strip_prefix('@')?.trim_start(); + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let location = rest[..end].trim_end_matches(';'); + let after = &rest[location.len()..]; + let marker = after.trim().strip_prefix(';').map_or("", str::trim); + Some((declared, extras, location, marker)) +} + +/// Restore every hosted direct reference in one dependency array; the uuids +/// restored are added to `restored`, a mismatched one refuses. +fn restore_hatch_array( + item: &mut Item, + pins: &BTreeMap<&str, &HostedPin>, + ctx: &Ctx<'_>, + result: &mut FormatResult, + restored: &mut BTreeSet, +) -> usize { + let Some(array) = item.as_array_mut() else { + return 0; + }; + let mut count = 0; + for entry in array.iter_mut() { + let Some(spec) = entry.as_str() else { + continue; + }; + let Some((declared, extras, location, marker)) = direct_reference(spec) else { + continue; + }; + let Some(pin) = pin_of(location, pins, ctx) else { + continue; + }; + let Some((name, version)) = pin_coords(pin, result) else { + continue; + }; + if canonicalize_pypi_name(declared) != name { + result.refuse( + &pin.uuid, + format!("{declared:?} is wired to the hosted artifact of another package"), + ); + continue; + } + let mut next = format!("{declared}{extras}=={version}"); + if !marker.is_empty() { + next.push_str(" ; "); + next.push_str(marker); + } + let decor = entry.decor().clone(); + *entry = toml_edit::Value::from(next); + *entry.decor_mut() = decor; + restored.insert(pin.uuid.clone()); + count += 1; + } + count +} + +/// Every `dependencies` / `extra-dependencies` array of an `envs` table. +fn restore_hatch_envs( + envs: Option<&mut Item>, + pins: &BTreeMap<&str, &HostedPin>, + ctx: &Ctx<'_>, + result: &mut FormatResult, + restored: &mut BTreeSet, +) { + let Some(envs) = envs.and_then(Item::as_table_like_mut) else { + return; + }; + for (_, env) in envs.iter_mut() { + let Some(env) = env.as_table_like_mut() else { + continue; + }; + for key in ["dependencies", "extra-dependencies"] { + if let Some(item) = env.get_mut(key) { + restore_hatch_array(item, pins, ctx, result, restored); + } + } + } +} + +/// Remove `keys`' last table's `allow-direct-references = true` (the Hatch +/// permission the hosted rewrite set), then every table on the path that is +/// left empty. +fn drop_direct_reference_permission(doc: &mut DocumentMut, keys: &[&str]) -> bool { + fn walk(table: &mut dyn toml_edit::TableLike, keys: &[&str]) -> bool { + let Some((first, rest)) = keys.split_first() else { + return table.get("allow-direct-references").and_then(Item::as_bool) == Some(true) + && table.remove("allow-direct-references").is_some(); + }; + let Some(child) = table.get_mut(first).and_then(Item::as_table_like_mut) else { + return false; + }; + let removed = walk(child, rest); + if removed && child.is_empty() { + table.remove(first); + } + removed + } + walk(doc.as_table_mut(), keys) +} + +pub(crate) async fn restore_hatch( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + use crate::utils::hatch::HATCH_FILES; + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + let mut docs: BTreeMap<&str, (String, DocumentMut)> = BTreeMap::new(); + for rel in HATCH_FILES { + let text = if files.iter().any(|f| f == rel) { + match read_or_refuse(view, rel, &pins, &mut result).await { + Some(text) => text, + None => continue, + } + } else { + match view.read(rel).await { + Ok(Some(text)) => text, + _ => continue, + } + }; + match text.trim_start_matches('\u{feff}').parse::() { + Ok(doc) => { + docs.insert(rel, (text, doc)); + } + Err(e) => refuse_all_in(&pins, rel, &mut result, format!("{rel}: {e}")), + } + } + let mut restored: BTreeSet = BTreeSet::new(); + let mut project_restored = 0; + if let Some((_, doc)) = docs.get_mut("pyproject.toml") { + if let Some(project) = doc.get_mut("project").and_then(Item::as_table_like_mut) { + if let Some(item) = project.get_mut("dependencies") { + project_restored += + restore_hatch_array(item, &pins, ctx, &mut result, &mut restored); + } + if let Some(groups) = project + .get_mut("optional-dependencies") + .and_then(Item::as_table_like_mut) + { + for (_, item) in groups.iter_mut() { + project_restored += + restore_hatch_array(item, &pins, ctx, &mut result, &mut restored); + } + } + } + if let Some(groups) = doc + .get_mut("dependency-groups") + .and_then(Item::as_table_like_mut) + { + for (_, item) in groups.iter_mut() { + project_restored += + restore_hatch_array(item, &pins, ctx, &mut result, &mut restored); + } + } + let hatch = doc + .get_mut("tool") + .and_then(Item::as_table_like_mut) + .and_then(|t| t.get_mut("hatch")); + restore_hatch_envs( + hatch.and_then(|h| h.get_mut("envs")), + &pins, + ctx, + &mut result, + &mut restored, + ); + } + if let Some((_, doc)) = docs.get_mut("hatch.toml") { + restore_hatch_envs(doc.get_mut("envs"), &pins, ctx, &mut result, &mut restored); + } + // The permission the rewrite set once a PROJECT table got a direct + // reference: dropped when none is left there (whatever its prior value, + // it then governs nothing). + let project_direct = docs.get("pyproject.toml").is_some_and(|(_, doc)| { + crate::vendor::common::pyproject_dependency_specs(doc) + .into_iter() + .any(|(_, spec)| spec.split(';').next().is_some_and(|r| r.contains('@'))) + }); + if project_restored > 0 && !project_direct { + let external = docs + .get("hatch.toml") + .is_some_and(|(_, doc)| doc.contains_key("metadata")); + let (file, keys): (&str, &[&str]) = if external { + ("hatch.toml", &["metadata"]) + } else { + ("pyproject.toml", &["tool", "hatch", "metadata"]) + }; + if let Some((_, doc)) = docs.get_mut(file) { + drop_direct_reference_permission(doc, keys); + } + } + let restored: BTreeSet = restored + .into_iter() + .filter(|u| !result.refused.contains_key(u)) + .collect(); + if !restored.is_empty() { + for (rel, (text, doc)) in docs { + let bom = if text.starts_with('\u{feff}') { + "\u{feff}" + } else { + "" + }; + let next = preserve_line_endings(&text, format!("{bom}{doc}")); + if next != text { + view.write(rel, next); + } + } + } + result.handled.extend(restored); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(name: &str) -> PypiFile { + PypiFile { + filename: name.into(), + url: format!("https://files.example/{name}"), + sha256: "a".repeat(64), + size: None, + upload_time: None, + } + } + + #[test] + fn universal_release_accepts_only_pure_python3_wheels() { + assert!(universal_release(&[ + file("x-1.tar.gz"), + file("x-1-py3-none-any.whl") + ])); + assert!(universal_release(&[file("x-1-py2.py3-none-any.whl")])); + assert!(universal_release(&[file("x-1-2-py3-none-any.whl")])); + assert!(!universal_release(&[file("x-1-py27-none-any.whl")])); + assert!(!universal_release(&[file( + "x-1-cp311-cp311-manylinux_2_17_x86_64.whl" + )])); + assert!(!universal_release(&[file("x-1-py3-abi3-any.whl")])); + } + + #[test] + fn direct_references_split_like_the_hatch_rewriter_writes_them() { + assert_eq!( + direct_reference("Urllib3[socks] @ https://h/u.whl#sha256=ab ; python_version >= '3'"), + Some(( + "Urllib3", + "[socks]", + "https://h/u.whl#sha256=ab", + "python_version >= '3'" + )) + ); + assert_eq!( + direct_reference("urllib3 @ https://h/u.whl"), + Some(("urllib3", "", "https://h/u.whl", "")) + ); + assert_eq!(direct_reference("urllib3==1.0"), None); + } + + #[test] + fn pipenv_index_must_name_pypi_and_is_never_chosen() { + let doc = json!({"_meta": {"sources": [ + {"name": "private", "url": "https://mirror.example/simple"}, + {"name": "pypi", "url": "https://pypi.org/simple/"}, + ]}}); + // The entry's own index (or none at all) is checked, never picked. + assert!(pipenv_index_is_pypi(&doc, Some("pypi"), None, "x").is_ok()); + assert!(pipenv_index_is_pypi(&doc, None, None, "x").is_ok()); + assert!(pipenv_index_is_pypi(&doc, Some("private"), None, "x") + .unwrap_err() + .contains("not PyPI")); + assert!(pipenv_index_is_pypi(&doc, Some("gone"), None, "x") + .unwrap_err() + .contains("not PyPI")); + let pipfile = "[packages]\nX = { version = \"==1\", index = \"private\" }\n"; + assert!(pipenv_index_is_pypi(&doc, None, Some(pipfile), "x") + .unwrap_err() + .contains("the Pipfile installs")); + let pipfile = "[packages]\nX = { version = \"==1\", index = \"pypi\" }\n"; + assert!(pipenv_index_is_pypi(&doc, Some("pypi"), Some(pipfile), "x").is_ok()); + let mirror = json!({"_meta": {"sources": [{"name": "m", "url": "https://m/simple"}]}}); + assert!(pipenv_index_is_pypi(&mirror, None, None, "x") + .unwrap_err() + .contains("is PyPI")); + // Two spellings of PyPI: whichever the entry names is PyPI. + let twice = json!({"_meta": {"sources": [ + {"name": "a", "url": "https://pypi.org/simple"}, + {"name": "b", "url": "https://pypi.python.org/simple"}, + ]}}); + assert!(pipenv_index_is_pypi(&twice, Some("b"), None, "x").is_ok()); + } + + #[test] + fn toml_values_keep_their_layout() { + let rendered = multiline_toml_array(&[format!( + "{{file = {}, hash = \"sha256:ab\"}}", + toml_quote("a.whl") + )]); + let value = toml_value(&rendered).unwrap(); + let mut doc: DocumentMut = "files = []\n".parse().unwrap(); + doc["files"] = Item::Value(value); + assert_eq!( + doc.to_string(), + "files = [\n {file = \"a.whl\", hash = \"sha256:ab\"},\n]\n" + ); + assert_eq!(multiline_toml_array(&[]), "[]"); + assert_eq!(toml_quote("a\"b\\"), "\"a\\\"b\\\\\""); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs b/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs new file mode 100644 index 000000000..ac105569f --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/pypi_locks.rs @@ -0,0 +1,403 @@ +//! Poetry and PDM upstream restores. +//! +//! * `poetry.lock` (`utils::poetry_lock::rewrite_poetry_lock`): the hosted +//! rewrite adds `[package.source] type = "url"` (lock 1.0 also a +//! `reference = ""` and a `#sha256=…&` url fragment) and replaces the +//! package's files with the patched wheel — `files = [...]` on lock 2.x, +//! `[metadata.files].` on 1.0/1.1, where it ALSO adds a package-level +//! `files` no Poetry 1.x lock carries. The restore drops the source table +//! (a package without one is a PyPI package — the rewriter refuses every +//! pre-existing source) and re-derives every release file from PyPI. +//! A 1.0/1.1 `[metadata.files]` entry is either every release file (a +//! lock written while PyPI's JSON API still fed old Poetry its files) or +//! `[]` (what Poetry 1.0/1.1 record against today's PyPI), and the +//! registry cannot say which. The rewriter keeps that bit in the patched +//! entry's layout (`utils::poetry_lock::legacy_files_entry`): one file +//! per line, as Poetry renders a non-empty entry, when the original listed +//! files; inline when it was `[]`. The restore reads the layout back and +//! writes the full release list or `[]`, so every generation round-trips +//! byte-exactly. +//! * `pdm.lock` (`utils::pdm_lock::rewrite_pdm_lock`): the rewrite adds a +//! package `url` and replaces its files, inline or in the lock_version 2 +//! `[metadata.files]."[extras] "` table, in every extras +//! variant. A lock without `cross_platform` keeps only the files its +//! targets install; which ones is re-derivable only when every wheel is +//! pure Python 3 ([`super::pypi::universal_release`]), otherwise the pin +//! is refused. +//! +//! Both edit a parsed `toml_edit` document, so every byte outside the +//! restored package entries is the file's own. + +use std::collections::BTreeSet; + +use toml_edit::{DocumentMut, Item}; + +use super::client::PypiFile; +use super::pypi::{ + by_uuid, fetch_release_files, multiline_toml_array, pin_of, read_or_refuse, refuse_all_in, + toml_quote, toml_value, universal_release, +}; +use super::{Ctx, FormatResult, HostedPin, View}; +use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::utils::poetry_lock::is_multiline_array; +use crate::utils::python_lock::preserve_line_endings; + +/// One hosted package entry of a TOML lock. +struct LockHit { + /// Index into the lock's `[[package]]` array. + index: usize, + uuid: String, + name: String, + version: String, +} + +/// The `files` array value Poetry / PDM write for `release`: one +/// `{ = …, hash = "sha256:…"}` per file. +fn files_value(release: &[PypiFile], by_url: bool) -> Option { + let key = if by_url { "url" } else { "file" }; + let mut located: Vec<(&str, &PypiFile)> = release + .iter() + .map(|f| (if by_url { f.url.as_str() } else { f.filename.as_str() }, f)) + .collect(); + // PDM orders each entry's files by the location it writes: a `static_urls` + // lock by URL (so an sdist under `0c/…` precedes a wheel under `b0/…`), + // a plain lock by filename. + located.sort_by(|a, b| a.0.cmp(b.0)); + let entries: Vec = located + .iter() + .map(|(location, f)| { + format!( + "{{{key} = {}, hash = {}}}", + toml_quote(location), + toml_quote(&format!("sha256:{}", f.sha256)) + ) + }) + .collect(); + toml_value(&multiline_toml_array(&entries)) +} + +/// Replace `key`'s value in place (keeping the key and its position), or +/// insert it. +fn set_value(table: &mut dyn toml_edit::TableLike, key: &str, value: toml_edit::Value) { + match table.get_mut(key) { + Some(item) => *item = Item::Value(value), + None => { + table.insert(key, Item::Value(value)); + } + } +} + +/// Parse `rel`'s lock; a malformed one refuses every pin wired in it. +async fn parse_lock( + view: &mut View<'_>, + rel: &str, + pins: &std::collections::BTreeMap<&str, &HostedPin>, + result: &mut FormatResult, +) -> Option<(String, DocumentMut)> { + let text = read_or_refuse(view, rel, pins, result).await?; + match text.parse::() { + Ok(doc) => Some((text, doc)), + Err(e) => { + refuse_all_in(pins, rel, result, format!("{rel} is not valid TOML: {e}")); + None + } + } +} + +/// The hosted entries of a lock's `[[package]]` array: `location` reads a +/// package's install location (Poetry's `[package.source]` url, PDM's +/// `url`). An entry naming another package or version than its pin +/// refuses it. +fn lock_hits( + doc: &DocumentMut, + rel: &str, + pins: &std::collections::BTreeMap<&str, &HostedPin>, + ctx: &Ctx<'_>, + location: impl Fn(&toml_edit::Table) -> Option<&str>, + result: &mut FormatResult, +) -> Vec { + let mut hits = Vec::new(); + let Some(packages) = doc.get("package").and_then(Item::as_array_of_tables) else { + return hits; + }; + for (index, package) in packages.iter().enumerate() { + let Some(pin) = location(package).and_then(|l| pin_of(l, pins, ctx)) else { + continue; + }; + let name = package.get("name").and_then(Item::as_str).unwrap_or(""); + let version = package.get("version").and_then(Item::as_str).unwrap_or(""); + let agrees = pin.name_version().is_some_and(|(n, v)| { + canonicalize_pypi_name(&n) == canonicalize_pypi_name(name) && v == version + }); + if !agrees { + result.refuse( + &pin.uuid, + format!( + "{rel}: the entry wiring it names {name:?} {version:?}, not {}", + pin.purl + ), + ); + continue; + } + hits.push(LockHit { + index, + uuid: pin.uuid.clone(), + name: name.to_string(), + version: version.to_string(), + }); + } + hits +} + +fn wanted(hits: &[LockHit], result: &FormatResult) -> BTreeSet<(String, String, String)> { + hits.iter() + .filter(|h| !result.refused.contains_key(&h.uuid)) + .map(|h| { + ( + h.uuid.clone(), + canonicalize_pypi_name(&h.name), + h.version.clone(), + ) + }) + .collect() +} + +/// Write `doc` back when any hit survived, marking the survivors handled. +fn finish( + view: &mut View<'_>, + rel: &str, + text: &str, + doc: &DocumentMut, + restored: BTreeSet, + result: &mut FormatResult, +) { + if restored.iter().all(|u| result.refused.contains_key(u)) { + return; + } + view.write(rel, preserve_line_endings(text, doc.to_string())); + result.handled.extend(restored); +} + +// ── poetry.lock ────────────────────────────────────────────────────────────── + +pub(crate) async fn restore_poetry( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some((text, mut doc)) = parse_lock(view, rel, &pins, &mut result).await else { + continue; + }; + let hits = lock_hits( + &doc, + rel, + &pins, + ctx, + |package| { + let source = package.get("source")?; + (source.get("type").and_then(Item::as_str) == Some("url")) + .then(|| source.get("url").and_then(Item::as_str)) + .flatten() + }, + &mut result, + ); + if hits.is_empty() { + continue; + } + let format = match crate::utils::poetry_lock::lock_version(&doc) { + Ok("0") => Err("Poetry 0.12 locks (format \"0\") ignore url sources".to_string()), + Ok(format) => Ok(format.to_string()), + Err(e) => Err(e), + }; + let format = match format { + Ok(format) => format, + Err(why) => { + for hit in &hits { + result.refuse(&hit.uuid, format!("{rel}: {why}")); + } + continue; + } + }; + let released = fetch_release_files(&wanted(&hits, &result), ctx, &mut result).await; + let legacy = !format.starts_with('2'); + // Poetry 1.x locks keep files in `[metadata.files]` only; the + // package-level copy is the rewriter's unless a package it never + // touched (no source table) has one too. + let siblings_carry_files = doc + .get("package") + .and_then(Item::as_array_of_tables) + .is_some_and(|packages| { + packages + .iter() + .any(|p| !p.contains_key("source") && p.contains_key("files")) + }); + let mut restored = BTreeSet::new(); + for hit in &hits { + if result.refused.contains_key(&hit.uuid) { + continue; + } + let Some(release) = + released.get(&(canonicalize_pypi_name(&hit.name), hit.version.clone())) + else { + continue; + }; + let Some(value) = files_value(release, false) else { + result.refuse(&hit.uuid, "the release file list does not render as TOML"); + continue; + }; + if legacy { + let canon = canonicalize_pypi_name(&hit.name); + let table = doc + .get_mut("metadata") + .and_then(Item::as_table_like_mut) + .and_then(|m| m.get_mut("files")) + .and_then(Item::as_table_like_mut); + let key = table.as_ref().and_then(|t| { + t.iter() + .find(|(k, _)| canonicalize_pypi_name(k) == canon) + .map(|(k, _)| k.to_string()) + }); + let (Some(table), Some(key)) = (table, key) else { + result.refuse( + &hit.uuid, + format!("{rel} has no [metadata.files] entry for {}", hit.name), + ); + continue; + }; + // The rewriter lays the patched entry out one file per line + // only when the original listed files; an inline one + // replaced Poetry's empty `[]`. + let listed = table.get(&key).is_some_and(is_multiline_array); + let entry = if listed { + value.clone() + } else { + toml_edit::Value::Array(toml_edit::Array::new()) + }; + set_value(table, &key, entry); + } + let Some(package) = doc + .get_mut("package") + .and_then(Item::as_array_of_tables_mut) + .and_then(|p| p.get_mut(hit.index)) + else { + continue; + }; + package.remove("source"); + if legacy && !siblings_carry_files { + package.remove("files"); + } else { + set_value(package, "files", value); + } + restored.insert(hit.uuid.clone()); + } + finish(view, rel, &text, &doc, restored, &mut result); + } + result +} + +// ── pdm.lock ───────────────────────────────────────────────────────────────── + +pub(crate) async fn restore_pdm( + view: &mut View<'_>, + pins: &[&HostedPin], + files: &[String], + ctx: &Ctx<'_>, +) -> FormatResult { + use crate::utils::pdm_lock::{legacy_files_key, lock_version, validate_strategy}; + let mut result = FormatResult::default(); + let pins = by_uuid(pins); + for rel in files { + let Some((text, mut doc)) = parse_lock(view, rel, &pins, &mut result).await else { + continue; + }; + let hits = lock_hits( + &doc, + rel, + &pins, + ctx, + |package| package.get("url").and_then(Item::as_str), + &mut result, + ); + if hits.is_empty() { + continue; + } + let shape = lock_version(doc.as_table()).and_then(|version| { + let flags = validate_strategy(doc.as_table())?; + Ok((version.to_string(), flags)) + }); + let (version, flags) = match shape { + Ok(shape) => shape, + Err(why) => { + for hit in &hits { + result.refuse(&hit.uuid, format!("{rel}: {why}")); + } + continue; + } + }; + let static_urls = flags.iter().any(|f| f == "static_urls"); + // lock_version 2 (PDM 0.x/1.x) always recorded every release file. + let cross_platform = version == "2" || flags.iter().any(|f| f == "cross_platform"); + let released = fetch_release_files(&wanted(&hits, &result), ctx, &mut result).await; + let mut restored = BTreeSet::new(); + for hit in &hits { + if result.refused.contains_key(&hit.uuid) { + continue; + } + let Some(release) = + released.get(&(canonicalize_pypi_name(&hit.name), hit.version.clone())) + else { + continue; + }; + if !cross_platform && !universal_release(release) { + result.refuse( + &hit.uuid, + format!( + "{rel} (lock_version {version}, no cross_platform strategy) records only \ + the files its lock targets install, and {}=={} ships platform- or \ + interpreter-specific wheels, so which ones it kept is not derivable", + hit.name, hit.version + ), + ); + continue; + } + let Some(value) = files_value(release, static_urls) else { + result.refuse(&hit.uuid, "the release file list does not render as TOML"); + continue; + }; + let Some(package) = doc + .get_mut("package") + .and_then(Item::as_array_of_tables_mut) + .and_then(|p| p.get_mut(hit.index)) + else { + continue; + }; + if package.contains_key("files") { + package.remove("url"); + set_value(package, "files", value); + } else { + let key = legacy_files_key(package); + package.remove("url"); + let table = doc + .get_mut("metadata") + .and_then(Item::as_table_like_mut) + .and_then(|m| m.get_mut("files")) + .and_then(Item::as_table_like_mut); + let (Some(table), Some(key)) = (table, key) else { + result.refuse( + &hit.uuid, + format!("{rel} has no [metadata.files] entry for {}", hit.name), + ); + continue; + }; + set_value(table, &key, value); + } + restored.insert(hit.uuid.clone()); + } + finish(view, rel, &text, &doc, restored, &mut result); + } + result +} diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs new file mode 100644 index 000000000..321b01de7 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -0,0 +1,1197 @@ +//! uv upstream restore: `uv.lock`, PEP 723 script locks (`