diff --git a/CHANGELOG.md b/CHANGELOG.md index 919bbd19..635838ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,33 @@ into the new version's section — see docs/releasing.md. `gem_setup` / `composer_setup` / `pth_hook` aliases are removed from `socket-patch-core`, along with the setup-only `npm_family` table column (`FileRow::detects_pnpm`) and `VLT_SETUP_MARKERS`. +- **v3/v4 compatibility spellings are gone.** + - The v3.0 legacy env names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` + and `SOCKET_PATCH_TELEMETRY_DISABLED` are no longer read and no longer + print a deprecation warning. Use `SOCKET_PROXY_URL`, `SOCKET_DEBUG` and + `SOCKET_TELEMETRY_DISABLED`. + - The hidden `scan --redirect` flag (use `--mode hosted`) and the hidden + no-op `scan --detached` flag (vendored mode is always manifest-free) are + removed. Both are now unknown-flag usage errors (exit 2). + - The hidden `--mode` values `host`, `redirect` and `vendor` on `scan` and + `get` are rejected; only `hosted`, `vendored` and `agent` are accepted. + The hidden `scan --apply` and `scan --vendor` spellings stay. +- **`get --one-off` and `rollback --one-off`** (and `SOCKET_ONE_OFF`) are + removed. They were never implemented and only failed with a usage error; + `--one-off` is now an unknown-flag error (still exit 2) and + `SOCKET_ONE_OFF` is ignored. +- **`.socket/packages/` package archives are no longer read.** Nothing has + written them for several releases. `apply`, `vendor` and `repair` stop + probing and staging the directory, and `apply`'s JSON `appliedVia` loses + its `"package"` value (`"diff"` or `"blob"` remain). The GC sweeps + (`scan --prune`, `rollback`, `remove`, `repair`) delete any leftover + `.socket/packages/` files whole (`rollback` and `scan --prune` still + report them as `removedPackageArchives`). +- **Core crate:** removed uncalled public helpers + (`bun_lock::snapshot_binary_workspace_artifacts`, `vlt_lock_sniff_ok`, + and several `lock_inventory::view` accessors) and the never-read + `DepOverride::berry_zip_url` field (a `berryZipUrl` key in a patch + reference still parses). ### Changed (BREAKING): patch UI streamlining @@ -96,10 +123,10 @@ into the new version's section — see docs/releasing.md. confirmation, in `--json` too (no `selection_required` outside agent mode). Agent-mode `get` keeps its picker and `Download and apply N patches?` prompt. -- **`get` and `rollback` usage errors exit 2** (were 1): `get`'s - `--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`, - `--mode hosted|vendored --save-only`, `--one-off`, a malformed forced - identifier, and `rollback --one-off`. Every usage error now exits 2. +- **`get` usage errors exit 2** (were 1): `get`'s + `--id`/`--cve`/`--ghsa`/`--package` multi-select, + `--mode hosted|vendored --save-only` and a malformed forced identifier. + Every usage error now exits 2. - **Human output:** warning lines no longer carry the `(code)` tag (`Warning: …`, `GC: skipped: …`); the codes stay in the JSON envelope. Error lines keep theirs (`Error (): …`). Hosted mode is called "hosted", not "redirect", in human @@ -502,9 +529,8 @@ into the new version's section — see docs/releasing.md. selected patch records are fetched into memory and every vendor-ledger entry carries `detached: true` plus the embedded `record` as its verification source, so a vendored project's footprint is `.socket/vendor/**` only. The - former `--detached` opt-in is now the only vendored posture — the flag is - hidden, accepted as a no-op for compatibility, and still a usage error - without vendored mode. JSON uses the detached download vocabulary for both + former `--detached` opt-in is now the only vendored posture, and the flag + itself is removed (see "Removed"). JSON uses the detached download vocabulary for both commands (`downloaded: N`, `detached: true`, `patches[].action` = `downloaded` | `skipped` | `failed`). The vendor step vendors exactly what discovery selected — the "whole manifest is vendored" re-vendor from a diff --git a/README.md b/README.md index 0f507b6c..6045f81d 100644 --- a/README.md +++ b/README.md @@ -644,7 +644,7 @@ socket-patch scan [PATHS]... [options] |------|---------|-------------| | `--mode ` | — | Selects one of the three [patch modes](#three-patch-modes) (default: `hosted`). Combining `--mode` with a legacy boolean flag of a *different* mode is an error (exit 2); the same mode spelled both ways is accepted. | | `--package ` | `SOCKET_SCAN_PACKAGES` | Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`; case-insensitive) or a purl with or without its version (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or separate with commas. | -| `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff/package-archive files. [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | +| `--prune` | — | Agent-mode garbage collection after the scan: remove manifest entries for packages no longer present in the crawl (installed trees + lockfiles — a wiped `node_modules` alone doesn't prune lockfile-listed entries) and delete orphan blob/diff-archive files (plus any legacy package archives). [Vendored](#vendor) packages are exempt from the crawl-based prune, but a vendored entry whose dependency has left the lockfile is reverted. Ignored, with a `redirect_prune_ignored` warning, in hosted mode; without a mode the scan is report-only. | | `--sync` | — | Shorthand for `--mode agent --prune`: the one-flag agent-mode auto-update run. | | `--batch-size ` | `SOCKET_BATCH_SIZE` | Packages per API request (default: `500` on the authenticated API, `100` on the public proxy). A request whose body would exceed 256 KiB is split into smaller ones. | | `--all-releases` | `SOCKET_ALL_RELEASES` | Store patches for every release/distribution variant, not just the installed one — PyPI wheel/sdist, RubyGems platform, Maven classifier. Makes the manifest portable across environments (e.g. cross-platform CI caches). | @@ -652,8 +652,7 @@ socket-patch scan [PATHS]... [options] | `--vex-product`, `--vex-no-verify`, `--vex-doc-id`, `--vex-compact` | `SOCKET_VEX_*` | Passthrough to the embedded VEX builder; mirror the standalone [`vex`](#vex) knobs. Inert unless `--vex` is set. | > Deprecated, hidden spellings (still accepted): `--apply` (== `--mode agent`) and -> `--vendor` (== `--mode vendored`). `--detached` is a hidden no-op kept for compatibility (vendored mode is -> always manifest-free); it is still an error without vendored mode. +> `--vendor` (== `--mode vendored`). **Examples:** ```bash @@ -901,7 +900,6 @@ socket-patch get [options] | `--ghsa` | — | Force identifier to be treated as a GHSA ID. | | `-p, --package` | — | Force identifier to be treated as a package name. | | `--save-only` | `SOCKET_SAVE_ONLY` | Download the patch without applying it (alias: `--no-apply`). | -| `--one-off` | `SOCKET_ONE_OFF` | Reserved (hidden from `--help`): apply the patch immediately without saving to the `.socket` folder. **Not yet implemented** — the command currently errors up front. | | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package (PyPI wheel/sdist, RubyGems platform, Maven classifier), not just the installed one. | | `--mode ` | — | How to consume the patch; the same modes as `scan --mode` (default: `agent`). | @@ -1020,7 +1018,6 @@ socket-patch rollback [targets]... [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 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:** ```bash diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index b3a58ab1..9eb0c4af 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -45,7 +45,7 @@ In v3.0 every subcommand accepts the same set of "global" flags via a single sha | `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug | | `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token | | `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | -| `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `package` \| `file` | Patch artifact format | +| `--download-mode` | — | `SOCKET_DOWNLOAD_MODE` | **`diff`** | enum: `diff` \| `file` (`package` was removed and is rejected) | Patch artifact format | | `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`auto`** | enum: `auto` \| `service` \| `build` | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") | | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | | `--patch-server-url` | — | `SOCKET_PATCH_SERVER_URL` | (server-returned) | string | Override the host of the prebuilt-archive download URL (local-dev / testing) | @@ -82,16 +82,15 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `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 | -| `scan` | `--mode ` | — | The documented selector for the three patch-application modes (v5.0 default: `hosted`, except that a `--prune` or `--global`/`--global-prefix` scan with no mode is report-only). Hidden value aliases: `host`/`redirect` (hosted), `vendor` (vendored). Each value is equivalent to one legacy boolean spelling: `hosted` == `--redirect`, `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | -| `scan` | `--redirect` | — | Hosted mode's legacy boolean spelling (**hidden from `--help`** and **deprecated** — `--mode hosted` is the documented spelling): rewrite lockfiles / registry configs so ONLY the patched dependencies resolve to Socket's hosted patch server; no artifact bytes land in the repo. Conflicts with `--apply`/`--sync`/`--vendor` | +| `scan` | `--mode ` | — | The documented selector for the three patch-application modes (v5.0 default: `hosted`, except that a `--prune` or `--global`/`--global-prefix` scan with no mode is report-only). v5.0 removes the hidden value aliases `host`/`redirect`/`vendor` (now an invalid-value usage error). `vendored` and `agent` each keep one hidden, deprecated boolean spelling: `vendored` == `--vendor`, `agent` == `--apply` (`--sync` counts as an agent spelling); hosted has none (v5.0 removes `--redirect`). Combining `--mode` with a boolean of a DIFFERENT mode is a usage error (exit 2, enforced in `resolve_mode_flags` — clap's `conflicts_with` is value-independent); the same mode spelled both ways is accepted. `--prune` is an orthogonal GC knob and never conflicts — but hosted mode runs no GC, so `--mode hosted --prune` emits an explicit `redirect_prune_ignored` warning (JSON `redirect.warnings[]` + stderr) instead of silently dropping the flag | | `scan` | `--apply` / `--prune` / `--sync` | — | `--apply` == `--mode agent` (deprecated spelling); `--prune` = GC after the scan (ignored with a `redirect_prune_ignored` warning in hosted mode); `--sync` = `--mode agent --prune` | | `scan` | `--package ` (repeatable or comma-separated) | `SOCKET_SCAN_PACKAGES` | (v5.0) Only scan these packages: a name (`lodash`, `@scope/pkg`, `requests`, `group:artifact`; matched against the full name or its last segment, case-insensitively) or a purl with or without a version (`pkg:npm/lodash` matches every version, `pkg:pypi/requests@2.31.0` only that one). Qualifiers are ignored. Filters the crawl like `--ecosystems`, after the prune universe is captured, so `--prune` still judges the full crawl | -| `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | +| `scan` | `--vendor` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. The former opt-in for exactly that, `--detached`, is removed in v5.0 (unknown-flag usage error) | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size. Unset (v5.0): `500` on the authenticated API (the server's per-request maximum), `100` on the public proxy; a given value applies on either endpoint (`0` is floored to `1`). A chunk whose request body would exceed 256 KiB (the public proxy's body cap) is split into consecutive smaller chunks, deterministically (greedy, in crawl order). A mid-run downgrade to the proxy keeps the chunks already formed | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | -| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off` (hidden from `--help`: always fails "not yet implemented"); `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | +| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--mode ` | `SOCKET_SAVE_ONLY` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default (v5.0): `hosted`, like scan; `agent` (save + apply in place) when `--save-only` or `--global`/`--global-prefix` is given. An explicit `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | -| `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--one-off`; `--preserve-state` (v5.0) | `SOCKET_ONE_OFF`, `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | +| `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--preserve-state` (v5.0) | `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | | `repair` | `--download-only` | `SOCKET_DOWNLOAD_ONLY` | Repair-specific cleanup mode (mutually exclusive with `--offline`; combining them is a usage error, exit 2) | @@ -111,7 +110,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--redirect`, `--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. An explicit `--mode hosted` (or `--redirect`) with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect). +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. An explicit `--mode hosted` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect). **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` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts. @@ -119,7 +118,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **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: ` (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. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob and diff-archive files, and every legacy package archive, 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. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. @@ -144,9 +143,9 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc `scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). -**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), and `scan --prune` (lockfile-driven reconcile). 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. +**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), and `scan --prune` (lockfile-driven reconcile). 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). -`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). +`scan --mode hosted` 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). @@ -166,7 +165,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **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): agent-mode get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--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. +* **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `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 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. @@ -713,6 +712,8 @@ 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). + Package archives (`.socket/packages/`) are legacy in v5.0: nothing writes or reads them, so + every GC sweep removes the whole directory. * **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 @@ -778,7 +779,7 @@ worse, lets a warm cache silently serve unpatched bytes): * **Path targets select installed copies; entries with no installed copy are reachable only by identifier or unscoped runs.** * **Rollback restores every installed copy of a selected patch** — patches are tracked per-package, not per-path; copies restored outside the given patterns are surfaced as an `out_of_scope_copies_restored` warning, never skipped. -`--ecosystems` narrows every leg. `--one-off` still requires ≥ 1 identifier-shaped target and still fails "not yet implemented" before any network or disk activity. +`--ecosystems` narrows every leg. ### Default behavior: full-state rollback (MAJOR) @@ -903,9 +904,9 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_API_URL` | `--api-url` | `https://api.socket.dev` | — | | `SOCKET_API_TOKEN` | `--api-token` | (none) | Absence selects the public proxy. | | `SOCKET_ORG_SLUG` | `--org` / `-o` | (auto-resolve) | — | -| `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | **Renamed in v3.0** (was `SOCKET_PATCH_PROXY_URL`). | +| `SOCKET_PROXY_URL` | `--proxy-url` | `https://patches-api.socket.dev` | — | | `SOCKET_ECOSYSTEMS` | `--ecosystems` / `-e` | (all) | Comma-separated list. | -| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `package` / `file`. | +| `SOCKET_DOWNLOAD_MODE` | `--download-mode` | `diff` | One of `diff` / `file`. | | `SOCKET_VENDOR_SOURCE` | `--vendor-source` | `auto` | One of `auto` / `service` / `build`. | | `SOCKET_VENDOR_URL` | `--vendor-url` | (active API/proxy base) | Vendoring-service package-reference host. | | `SOCKET_PATCH_SERVER_URL` | `--patch-server-url` | (server-returned) | Rewrites the prebuilt-archive download host. | @@ -919,17 +920,16 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_DRY_RUN` | `--dry-run` | `false` | — | | `SOCKET_YES` | `--yes` / `-y` | `false` | Skips the prompts of `get`, `rollback`, `remove` and `--update`; `scan` never prompts, so it has no effect there. | | `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock` on the lock-taking subcommands (incl. hosted/vendored `scan`/`get`); unset/`0` = single non-blocking try. | -| `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | -| `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | +| `SOCKET_DEBUG` | `--debug` | `false` | — | +| `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | — | | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `--no-trust-lockfile-config` | `false` | Hosted mode: skip the `trustLockfile: true` write to `pnpm-workspace.yaml`. | | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `--no-npm-allow-remote-config` | `false` | Hosted mode: skip the `allow-remote=all` write to the project `.npmrc`. | | `SOCKET_NO_VLT_INSTALL_CLEANUP` | `--no-vlt-install-cleanup` | `false` | Hosted mode, `rollback`, `remove`: keep stale vlt installed copies. | | `SOCKET_FORCE` | `apply --force` / `-f`, `vendor --force` / `-f`, `--update --force` | `false` | Local to `apply`, `vendor` and `--update`. | -| `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | +| `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem launcher honor. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `500` authenticated / `100` proxy | Local to `scan`. | | `SOCKET_SCAN_PACKAGES` | `scan --package` | (none) | Local to `scan` (v5.0); comma-separated names or purls. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | -| `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity (on `rollback`, with no identifier-shaped target it instead fails "requires an identifier", equally up front). | | `SOCKET_ALL_RELEASES` | `get --all-releases` / `scan --all-releases` | `false` | Local to `get`/`scan`. Download patches for every release/distribution variant, not just the installed one. | | `SOCKET_SKIP_ROLLBACK` | `remove --skip-rollback` | `false` | Local to `remove`. Conflicts with `--preserve-state`/`SOCKET_PRESERVE_STATE` (exit 2 — see below). | | `SOCKET_PRESERVE_STATE` | `rollback --preserve-state` / `remove --preserve-state` | `false` | (v5.0) Shared by `rollback`/`remove` (boolish, empty-tolerant parse like the other bool flags): restore the system but keep the local patch state — manifest entries, vendored artifacts + ledger entries — and skip all GC. On `remove`, combining it with `--skip-rollback` is a usage error (exit 2) **whether either side is flag- or env-sourced** (`SOCKET_PRESERVE_STATE=true remove --skip-rollback` exits 2 too). | @@ -1005,13 +1005,9 @@ These exist for staged rollouts and the launcher wrappers. They are **internal** | `SOCKET_UPDATE_NOTIFIER_FORCE` | Test hook: bypasses the update notice's stderr-TTY guard — and nothing else (opt-out, offline, `--silent`, `--json`, CI all still win). | | `SOCKET_UPDATE_GRACE_MS` | Test hook: overrides the notice's post-command join grace (default 500 ms — how long the run waits for the background check before abandoning it and exiting). Lets the e2e suite await the loopback fetch to completion so its observable effect is deterministic; production keeps the tight 500 ms ceiling. | -### Deprecated env vars +### Removed env vars -| Legacy | Renamed to | Status | -|---|---|---| -| `SOCKET_PATCH_PROXY_URL` | `SOCKET_PROXY_URL` | Honored with warning; to be removed in a future major release. | -| `SOCKET_PATCH_DEBUG` | `SOCKET_DEBUG` | Honored with warning; to be removed in a future major release. | -| `SOCKET_PATCH_TELEMETRY_DISABLED` | `SOCKET_TELEMETRY_DISABLED` | Honored with warning; to be removed in a future major release. | +The v3.0 legacy names `SOCKET_PATCH_PROXY_URL`, `SOCKET_PATCH_DEBUG` and `SOCKET_PATCH_TELEMETRY_DISABLED` were removed in v5.0 and are ignored; use `SOCKET_PROXY_URL`, `SOCKET_DEBUG` and `SOCKET_TELEMETRY_DISABLED`. ## CSV value parsing @@ -1059,7 +1055,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified { "path": "package/index.js", "verified": true, - "appliedVia": "package" | "diff" | "blob" // only on action=applied + "appliedVia": "diff" | "blob" // only on action=applied; v5.0 drops "package" } ], "bytes": 1234, // optional (downloaded/removed) @@ -1476,7 +1472,7 @@ Exit `1` when `status` is `partialFailure` (any `events[*].action == "failed"`) |---|---| | `0` | Success | | `1` | Error (missing/invalid manifest, fetch failed, apply failed, selection cancelled in non-JSON mode, etc.) | -| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--one-off --save-only`, `--mode hosted\|vendored --save-only`, the unimplemented `--one-off`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`), as does `rollback --one-off` — previously `1` (MAJOR). | +| `2` | Usage error: clap parse failures (unknown flag/value, missing required arg, an unknown subcommand such as the removed `setup`) 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`) 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). v5.0: `get`'s self-enforced conflicts exit `2` too (`--id`/`--cve`/`--ghsa`/`--package` multi-select, `--mode hosted\|vendored --save-only`, a malformed identifier for a forced `--id`/`--cve`/`--ghsa`) — previously `1` (MAJOR). The never-implemented `get --one-off` / `rollback --one-off` (and `SOCKET_ONE_OFF`) are removed in v5.0; `--one-off` is now an ordinary unknown-flag clap error. | `list` returns **`0`** for every project it can read, empty or not (**v5.0, BREAKING**: a project with no manifest and no ledger record — normal for hosted mode, which writes no manifest — used to exit `1` with `manifest_not_found`; it is now an empty list: `No patches in this project. Run \`socket-patch scan\`.` on stdout, and under `--json` the success envelope with `events: []`). Only an unreadable or invalid manifest (`manifest_unreadable` / `manifest_invalid`) exits `1`. 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`. diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 5aa13158..412ed9d7 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -9,9 +9,7 @@ //! //! Precedence for every flag: CLI arg > env var > default. //! -//! All env-var names use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` -//! names are still read at runtime (via `socket_patch_core::env_compat`) with -//! a one-shot deprecation warning; they will be removed in the next major. +//! All env-var names use the `SOCKET_*` prefix. use std::path::{Path, PathBuf}; @@ -614,7 +612,6 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_FORCE", "SOCKET_PATCH_VERSION", "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", "SOCKET_SKIP_ROLLBACK", "SOCKET_PRESERVE_STATE", @@ -641,7 +638,7 @@ pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ /// per-token validator) outright — a single stray blank var crashed every /// subcommand — and an empty `SOCKET_DOWNLOAD_MODE` / `SOCKET_MANIFEST_PATH` /// (or `SOCKET_VEX_OUTPUT`, which would silently target `""`) leaked `""` -/// past the documented defaults. Called from `main` after legacy-name +/// past the documented defaults. Called from `main` after peer-alias /// promotion and before clap runs. Only exactly-empty values are scrubbed; /// whitespace is significant in paths, so it is left for the parsers to /// judge. @@ -763,11 +760,10 @@ mod tests { /// Clear the extra env the core telemetry gate reads beyond the /// `SOCKET_*` set (`is_telemetry_disabled` also consults `VITEST` — the - /// kill-switch socket-cli's vitest suite relies on — and the legacy - /// `SOCKET_PATCH_TELEMETRY_DISABLED` name), so the airgap tests below - /// can't pass or fail vacuously. Restores afterwards. + /// kill-switch socket-cli's vitest suite relies on), so the airgap tests + /// below can't pass or fail vacuously. Restores afterwards. fn with_clean_telemetry_env(f: impl FnOnce()) { - with_env_cleared(&["VITEST", "SOCKET_PATCH_TELEMETRY_DISABLED"], f); + with_env_cleared(&["VITEST"], f); } /// `--offline` promises "never contact the network", but the telemetry @@ -1571,12 +1567,10 @@ mod tests { ("SOCKET_FORCE", &["socket-patch", "vendor"]), ("SOCKET_FORCE", &["socket-patch", "self-update"]), ("SOCKET_SAVE_ONLY", &["socket-patch", "get", "x"]), - ("SOCKET_ONE_OFF", &["socket-patch", "get", "x"]), - ("SOCKET_ONE_OFF", &["socket-patch", "rollback"]), ("SOCKET_ALL_RELEASES", &["socket-patch", "get", "x"]), ("SOCKET_ALL_RELEASES", &["socket-patch", "scan"]), ("SOCKET_SKIP_ROLLBACK", &["socket-patch", "remove", "x"]), - // Shared by rollback and remove, like SOCKET_ONE_OFF above. + // Shared by rollback and remove. ("SOCKET_PRESERVE_STATE", &["socket-patch", "rollback"]), ("SOCKET_PRESERVE_STATE", &["socket-patch", "remove", "x"]), ("SOCKET_DOWNLOAD_ONLY", &["socket-patch", "repair"]), diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index a0dae200..327f93e0 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2332,7 +2332,7 @@ mod tests { fn applied_event_emits_one_file_entry_per_patched_file() { let mut applied_via = HashMap::new(); applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff); - applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package); + applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Diff); applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob); let result = ApplyResult { package_key: "pkg:npm/foo@1.0.0".to_string(), @@ -2359,7 +2359,7 @@ mod tests { .map(|f| (f["path"].as_str().unwrap().to_string(), f)) .collect(); assert_eq!(by_path["package/a.js"]["appliedVia"], "diff"); - assert_eq!(by_path["package/b.js"]["appliedVia"], "package"); + assert_eq!(by_path["package/b.js"]["appliedVia"], "diff"); assert_eq!(by_path["package/c.js"]["appliedVia"], "blob"); } diff --git a/crates/socket-patch-cli/src/commands/bun_preflight.rs b/crates/socket-patch-cli/src/commands/bun_preflight.rs index f972b7e6..3655295e 100644 --- a/crates/socket-patch-cli/src/commands/bun_preflight.rs +++ b/crates/socket-patch-cli/src/commands/bun_preflight.rs @@ -1,6 +1,6 @@ //! The Bun vendored-mode preflight shared by EVERY path that feeds the -//! vendor engine: `scan --mode vendored` (its in-memory download phase; -//! the hidden `--detached` flag is a no-op), `get … --mode vendored` +//! vendor engine: `scan --mode vendored` (its in-memory download phase), +//! `get … --mode vendored` //! (search and uuid paths), their `--dry-run` previews, and the `vendor` command's engine //! loop itself ([`crate::commands::vendor::vendor_records`], where it runs //! BEFORE the hosted→vendored takeover reverts anything). diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index c3e11faa..dbde61fc 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -1,8 +1,8 @@ //! Shared patch-source staging for the mutating commands (`apply`, `vendor`). //! -//! Resolves where the patch pipeline should read blob/diff/package artifacts -//! from, downloading what's missing into a transient overlay tempdir. The -//! persistent `.socket/{blobs,diffs,packages}` cache is only ever *read* — +//! Resolves where the patch pipeline should read blob/diff artifacts from, +//! downloading what's missing into a transient overlay tempdir. The +//! persistent `.socket/{blobs,diffs}` cache is only ever *read* — //! downloads land in the tempdir and are discarded when it drops (filling the //! cache is `repair`'s job, keeping these commands read-only against //! `.socket/`). @@ -33,7 +33,6 @@ use crate::ui::{plural, StatusLine}; pub(crate) struct StagedSources { pub(crate) blobs: PathBuf, diffs: PathBuf, - packages: PathBuf, _stage: Option, } @@ -42,7 +41,6 @@ impl StagedSources { pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, - packages_path: Some(&self.packages), diffs_path: Some(&self.diffs), mem_blobs: None, } @@ -201,11 +199,10 @@ fn format_blob_fallback(diff_failed: usize, blobs: usize) -> String { } /// The manifest PURLs with no usable local source. A patch is "locally -/// applicable" iff its package archive is on disk, or every file it -/// touches has its `after_hash` blob on disk or is covered by the patch's -/// diff archive. A diff covers only files that exist before the patch: a -/// created file (empty `before_hash`) has nothing to diff against, so it -/// always needs its blob. +/// applicable" iff every file it touches has its `after_hash` blob on +/// disk or is covered by the patch's diff archive. A diff covers only files +/// that exist before the patch: a created file (empty `before_hash`) has +/// nothing to diff against, so it always needs its blob. /// /// The patch pipeline picks whichever is present per file. Shared by the /// offline gate (probed against `.socket/`) and the post-download gate @@ -214,19 +211,17 @@ fn patches_without_source<'m>( manifest: &'m PatchManifest, missing_blobs: &HashSet, missing_diff_archives: &HashSet, - missing_package_archives: &HashSet, ) -> Vec<&'m str> { manifest .patches .iter() .filter_map(|(purl, record)| { let diff_present = !missing_diff_archives.contains(&record.uuid); - let pkg_present = !missing_package_archives.contains(&record.uuid); let files_covered = record.files.values().all(|f| { !missing_blobs.contains(&f.after_hash) || (diff_present && !f.before_hash.is_empty()) }); - if pkg_present || files_covered { + if files_covered { None } else { Some(purl.as_str()) @@ -304,7 +299,6 @@ pub(crate) async fn stage_patch_sources( let quiet = common.silent || common.json; let socket_blobs_path = socket_dir.join("blobs"); let socket_diffs_path = socket_dir.join("diffs"); - let socket_packages_path = socket_dir.join("packages"); let download_mode = DownloadMode::parse(&common.download_mode).map_err(|e| e.to_string())?; @@ -313,14 +307,9 @@ pub(crate) async fn stage_patch_sources( // on disk. These probes are read-only. let missing_blobs = get_missing_blobs(manifest, &socket_blobs_path).await; let missing_diff_archives = get_missing_archives(manifest, &socket_diffs_path).await; - let missing_package_archives = get_missing_archives(manifest, &socket_packages_path).await; - let no_source_purls = patches_without_source( - manifest, - &missing_blobs, - &missing_diff_archives, - &missing_package_archives, - ); + let no_source_purls = + patches_without_source(manifest, &missing_blobs, &missing_diff_archives); if common.offline { // Offline: bail only if some patch has no usable local source. @@ -335,7 +324,7 @@ pub(crate) async fn stage_patch_sources( // Decide what (if anything) needs downloading. // - // The patch pipeline tries sources in the order package → diff → blob + // The patch pipeline tries sources in the order diff → blob // locally. We honor `--download-mode` for the primary fetch when there's // actually a gap to close. Skip the archive fetch entirely when all file // blobs are already present locally — the pipeline will succeed via the @@ -353,7 +342,6 @@ pub(crate) async fn stage_patch_sources( return Ok(StageOutcome::Ready(StagedSources { blobs: socket_blobs_path, diffs: socket_diffs_path, - packages: socket_packages_path, _stage: None, })); } @@ -366,17 +354,15 @@ pub(crate) async fn stage_patch_sources( let staged = StagedSources { blobs: stage.path().join("blobs"), diffs: stage.path().join("diffs"), - packages: stage.path().join("packages"), _stage: Some(stage), }; - for dir in [&staged.blobs, &staged.diffs, &staged.packages] { + for dir in [&staged.blobs, &staged.diffs] { tokio::fs::create_dir_all(dir) .await .map_err(|e| e.to_string())?; } overlay_dir(&socket_blobs_path, &staged.blobs).await; overlay_dir(&socket_diffs_path, &staged.diffs).await; - overlay_dir(&socket_packages_path, &staged.packages).await; // Progress: a transient status line on stderr (stdout is data); the // result lines below are what stays on screen. @@ -436,19 +422,11 @@ pub(crate) async fn stage_patch_sources( // Download failures only matter per patch: bail iff some patch is left // with no usable source at the staged paths — the same coverage rule as // the offline gate. Aggregate counters can't decide this (a patch whose - // diff failed may be covered by its blobs and vice versa, and a local - // package archive covers its patch even though packages are never - // downloaded). + // diff failed may be covered by its blobs and vice versa). if fetch_result.failed > 0 || blob_fetch_failed { let missing_blobs = get_missing_blobs(manifest, &staged.blobs).await; let missing_diff_archives = get_missing_archives(manifest, &staged.diffs).await; - let missing_package_archives = get_missing_archives(manifest, &staged.packages).await; - let uncovered = patches_without_source( - manifest, - &missing_blobs, - &missing_diff_archives, - &missing_package_archives, - ); + let uncovered = patches_without_source(manifest, &missing_blobs, &missing_diff_archives); if !uncovered.is_empty() { // An error, not progress chatter: prints even under --silent // (same rule as report_offline_missing above). @@ -480,7 +458,6 @@ pub(crate) async fn stage_patch_sources( pub(crate) struct MemStagedSources { blobs: PathBuf, diffs: PathBuf, - packages: PathBuf, mem: HashMap>, /// The purls this staging could NOT obtain patch content for, each with /// the reason, while at least one other patch staged fine. Each is an @@ -497,7 +474,6 @@ impl MemStagedSources { pub(crate) fn as_patch_sources(&self) -> PatchSources<'_> { PatchSources { blobs_path: &self.blobs, - packages_path: Some(&self.packages), diffs_path: Some(&self.diffs), mem_blobs: Some(&self.mem), } @@ -528,8 +504,8 @@ fn needs_blob(file: &PatchFileInfo) -> bool { } /// Stage patch sources for a VENDOR run without writing anything: -/// a record is locally satisfied when all its after-blobs are on disk or -/// a package archive is (a diff archive is NOT sufficient — vendor's +/// a record is locally satisfied when all its after-blobs are on disk (a +/// diff archive is NOT sufficient — vendor's /// auto-force policy can need the full after-blob for files a diff cannot /// reproduce); anything else has its full per-file content fetched into /// memory from the patch view endpoint (`blobContent`), preceded by the @@ -570,10 +546,8 @@ pub(crate) async fn stage_vendor_sources_in_memory( ) -> MemStageOutcome { let blobs = socket_dir.join("blobs"); let diffs = socket_dir.join("diffs"); - let packages = socket_dir.join("packages"); let missing_blobs = get_missing_blobs(manifest, &blobs).await; - let missing_package_archives = get_missing_archives(manifest, &packages).await; let mut mem = seed; let mut unavailable: Vec<(String, String)> = Vec::new(); @@ -581,7 +555,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( // stager: vendoring runs the auto-force policy, where a beforeHash // mismatch (already-applied tree, patch built against different bytes) // is overwritten with the FULL after-blob — which a diff cannot - // produce. On-disk diffs still serve Strategy 2 for clean files; the + // produce. On-disk diffs still serve Strategy 1 for clean files; the // after-blob content must additionally exist (disk, seed/harvest, or // fetch). // @@ -599,7 +573,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( !needs_blob(f) || !missing_blobs.contains(&f.after_hash) || mem.contains_key(&f.after_hash) - }) || !missing_package_archives.contains(&record.uuid) + }) }; let mut to_fetch: Vec<(&str, &str)> = manifest .patches @@ -795,7 +769,6 @@ pub(crate) async fn stage_vendor_sources_in_memory( MemStageOutcome::Ready(MemStagedSources { blobs, diffs, - packages, mem, unavailable, }) @@ -1227,12 +1200,10 @@ mod tests { } } - /// A local package archive is a usable source (the pipeline's Strategy 1, - /// and exactly what the offline gate rules), so an online run whose - /// downloads all fail must still be Ready when the package archive covers - /// every patch, exactly as it succeeds with --offline. + /// A leftover legacy `.socket/packages/.tar.gz` is not a source: + /// nothing reads it, so it must not mask failed downloads. #[tokio::test] - async fn stage_online_fetch_failure_accepts_local_package_archive() { + async fn stage_online_fetch_failure_ignores_legacy_package_archive() { let tmp = tempfile::tempdir().unwrap(); let socket_dir = tmp.path().join(".socket"); std::fs::create_dir_all(socket_dir.join("packages")).unwrap(); @@ -1252,8 +1223,8 @@ mod tests { .await .expect("no hard failure"); assert!( - matches!(outcome, StageOutcome::Ready(_)), - "a local package archive covers the patch even when every download fails" + matches!(outcome, StageOutcome::Unavailable), + "a legacy package archive must not cover the patch" ); } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 55d9fef7..16a34a65 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -430,23 +430,6 @@ pub struct GetArgs { )] pub save_only: bool, - /// Apply the patch without saving it to the .socket folder (not yet - /// implemented). - // Hidden: it always fails with "not yet implemented" (see `run`), but - // stays parseable so scripts and `SOCKET_ONE_OFF` keep getting that - // explicit error instead of a clap parse failure. - // `value_parser = parse_bool_flag`: same reason as `--save-only` above — - // and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, which parses - // boolishly too; the two must not diverge. - #[arg( - long = "one-off", - env = "SOCKET_ONE_OFF", - default_value_t = false, - value_parser = crate::args::parse_bool_flag, - hide = true, - )] - pub one_off: bool, - /// Download patches for every release variant of a matched package, /// not just the one matching the locally-installed distribution. /// @@ -2565,13 +2548,6 @@ pub async fn run(args: GetArgs) -> i32 { ); return 2; } - if args.one_off && args.save_only { - report_error( - args.common.json, - "--one-off and --save-only cannot be used together", - ); - return 2; - } // v5: hosted by default, like scan. `--save-only` (records a manifest // entry) and global installs (no project lockfile) mean agent mode. // Usage errors exit 2, like clap's and scan's (v5.0). @@ -2592,14 +2568,6 @@ pub async fn run(args: GetArgs) -> i32 { ); return 2; } - if args.one_off { - // The flag parses but is not implemented: fail loudly rather than - // save to the manifest anyway. Mirrors `rollback --one-off`'s - // not-yet-implemented contract; rejected before any network or disk - // activity. - report_error(args.common.json, "One-off get mode is not yet implemented"); - return 2; - } // Strict airgap (CLI_CONTRACT.md `--offline`: never contact the // network; operations that need remote data fail loudly). Every `get` // mode fetches remote patch data — proceeding would hit the API (and @@ -5609,8 +5577,6 @@ mod tests { "parse_bool_flag", "No env binding", "locally- installed", - "SOCKET_ONE_OFF", - "--one-off", ] { assert!(!help.contains(leak), "get --help leaks {leak:?}:\n{help}"); } @@ -5705,7 +5671,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let purl = "pkg:npm/covgap-no-after@1.0.0"; @@ -5749,7 +5715,7 @@ mod tests { async fn download_patch_records_view_404_is_fetch_miss() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); // No view mock mounted: wiremock answers 404, which the API client // maps to Ok(None) — the "could not fetch details" fetch-miss arm. let server = MockServer::start().await; @@ -5776,7 +5742,7 @@ mod tests { async fn download_patch_records_uninstalled_variant_base_warns_and_keeps_all() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); // Two qualified PyPI variants sharing an UNINSTALLED base: release // narrowing must keep both (with the not-installed warning), and the // warnings key must ride the detached envelope. Views stay unmounted @@ -6063,7 +6029,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let purl = "pkg:npm/covgap-blobfail@1.0.0"; @@ -6114,7 +6080,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let purl = "pkg:npm/covgap-badblob@1.0.0"; @@ -6168,7 +6134,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let good_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let good_purl = "pkg:npm/covgap-good@1.0.0"; @@ -6248,7 +6214,7 @@ mod tests { async fn download_patch_records_already_vendored_detached_skips_offline() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); let uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -6354,7 +6320,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; let purl = "pkg:npm/covgap-bun@1.0.0"; @@ -6411,7 +6377,7 @@ mod tests { async fn download_patch_records_bun_v1_workspace_refuses_before_fetch() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); @@ -6452,7 +6418,7 @@ mod tests { async fn download_patch_records_bun_refusal_skips_non_npm_purls() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lockb"), b"\x00binary").unwrap(); @@ -6483,7 +6449,7 @@ mod tests { async fn download_patch_records_bun_refusal_rejects_unwired_ledger_entries() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("bun.lock"), BUN_V1_WORKSPACE_LOCK).unwrap(); @@ -6706,7 +6672,7 @@ mod tests { async fn download_patch_records_with_prefetched_view_never_fetches() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; // trap: no mounts let tmp = tempfile::tempdir().unwrap(); // Two files: one with served `blobContent` (→ the blob seed), one @@ -6774,7 +6740,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let purl = "pkg:npm/covgap-supersede@1.0.0"; let old_uuid = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -6844,7 +6810,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let uuid = |c: char| { format!("{0}{0}{0}{0}{0}{0}{0}{0}-{0}{0}{0}{0}-4{0}{0}{0}-8{0}{0}{0}-{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}{0}", c) @@ -7019,7 +6985,7 @@ mod tests { use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let site = tempfile::tempdir().unwrap(); // Two installed pypi distributions, each with its own bytes. let installed = |name: &str, body: &[u8]| { @@ -7235,7 +7201,7 @@ mod tests { async fn download_patches_json_is_purl_ordered() { use wiremock::MockServer; - let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"]); + let _env = EnvVarGuard::scrub(&["SOCKET_PROXY_URL"]); let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); let names = [ diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 3d72b58c..3287aa81 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -960,8 +960,9 @@ pub async fn run(args: RemoveArgs) -> i32 { ); } } - // Diff/package archives use the same manifest-uuid keep rule - // (parity with repair and scan --prune). + // Diff archives use the same manifest-uuid keep rule; legacy + // package archives are swept whole (parity with repair and scan + // --prune). for (dir, result) in [("diffs", sweep.diffs), ("packages", sweep.packages)] { if let Some(detail) = sweep_failure(dir, &result) { if loud { diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 9444b0ac..69cdecb8 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -345,7 +345,6 @@ fn format_final_line( /// The `.socket/` source directories a download pass writes into. struct SourcePaths<'a> { blobs: &'a Path, - packages: &'a Path, diffs: &'a Path, } @@ -406,7 +405,6 @@ async fn download_pass( let client = client.as_ref().expect("client built just above"); let sources = PatchSources { blobs_path: paths.blobs, - packages_path: Some(paths.packages), diffs_path: Some(paths.diffs), mem_blobs: None, }; @@ -473,7 +471,6 @@ async fn repair_inner( let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); let diffs_path = socket_dir.join("diffs"); - let packages_path = socket_dir.join("packages"); let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?; @@ -553,7 +550,6 @@ async fn repair_inner( let noun = download_mode.noun(); let paths = SourcePaths { blobs: &blobs_path, - packages: &packages_path, diffs: &diffs_path, }; // Whether stdout already carries a line, so the blank separators @@ -994,18 +990,22 @@ mod tests { ); } - /// Cleanup must sweep orphaned diff *and* package archives in addition to - /// blobs, and the reclaimed counts/bytes from all three directories must - /// aggregate into a single `RepairCounts`. Guards against a regression - /// where a cleanup pass uses the wrong directory or drops its tallies. + /// Cleanup must sweep orphaned diff archives and every legacy + /// `.socket/packages/` archive (nothing reads them, so even one named + /// after a manifest UUID goes) in addition to blobs, and the reclaimed + /// counts/bytes from all three directories must aggregate into a single + /// `RepairCounts`. Guards against a regression where a cleanup pass uses + /// the wrong directory or drops its tallies. #[tokio::test] async fn cleanup_sweeps_diff_and_package_archives() { let tmp = tempfile::tempdir().unwrap(); let socket = make_socket(tmp.path()); - // Referenced archives (named after the manifest UUID) must survive. + // A referenced diff archive (named after the manifest UUID) must + // survive; a legacy package archive under the same name must not. write_archive(&socket, "diffs", REFERENCED_UUID, b"kept-diff"); - write_archive(&socket, "packages", REFERENCED_UUID, b"kept-package"); + let legacy_pkg = b"legacy package"; // 14 bytes + write_archive(&socket, "packages", REFERENCED_UUID, legacy_pkg); // Orphan archives (unknown UUIDs) must be swept. let orphan_diff = b"orphan diff archive bytes"; // 25 bytes @@ -1029,16 +1029,17 @@ mod tests { .await .expect("repair_inner"); - // Two orphans removed (one diff, one package); the referenced ones stay. - assert_eq!(counts.cleaned, 2, "both orphan archives should be swept"); + // Both orphans and the legacy package archive go; the referenced + // diff archive stays. + assert_eq!(counts.cleaned, 3, "orphans and legacy archives should be swept"); assert_eq!( counts.bytes_freed, - (orphan_diff.len() + orphan_pkg.len()) as u64, + (orphan_diff.len() + orphan_pkg.len() + legacy_pkg.len()) as u64, "bytes_freed must aggregate diff + package reclaim" ); // Cleanup is reported as a SINGLE batched `removed` artifact event whose // `details.count` carries the tally — so the event-count summary is 1 - // (`Summary::bump` increments once per event), and the 2-artifact count + // (`Summary::bump` increments once per event), and the 3-artifact count // is asserted via `counts.cleaned` above and the event details here. assert_eq!(env.summary.removed, 1, "one batched removal event"); let removed = env @@ -1052,15 +1053,15 @@ mod tests { .as_ref() .and_then(|d| d.get("count")) .and_then(serde_json::Value::as_u64), - Some(2), - "the batched removal event must report 2 swept artifacts" + Some(3), + "the batched removal event must report 3 swept artifacts" ); assert!(socket .join("diffs") .join(format!("{REFERENCED_UUID}.tar.gz")) .exists()); - assert!(socket + assert!(!socket .join("packages") .join(format!("{REFERENCED_UUID}.tar.gz")) .exists()); diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index a84a1b3f..79c9a239 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -100,21 +100,6 @@ pub struct RollbackArgs { #[command(flatten)] pub common: GlobalArgs, - // `value_parser = parse_bool_flag` matches the `GlobalArgs` bool flags: - // clap's default bool parser accepts only the literal strings - // `true`/`false` from the env binding, so `SOCKET_ONE_OFF=1` (or an - // exported-but-empty `SOCKET_ONE_OFF=`) aborted every `rollback` - // invocation. This flag is also outside `GLOBAL_ARG_ENV_VARS`, so - // `main`'s empty-var scrub never rescues it. - /// Roll back a patch by fetching beforeHash blobs from the API (no manifest required). - #[arg( - long = "one-off", - env = "SOCKET_ONE_OFF", - default_value_t = false, - value_parser = parse_bool_flag, - )] - pub one_off: bool, - /// Restore the system (files and lockfiles) but PRESERVE the local /// patch state for a later re-apply: manifest entries are kept, /// vendored artifacts and their ledger entries are kept (only the @@ -848,7 +833,14 @@ pub(crate) async fn sweep_unused_artifacts( ArtifactSweep { blobs: cleanup_unused_blobs(reference, &socket_dir.join("blobs"), dry_run).await, diffs: cleanup_unused_archives(reference, &socket_dir.join("diffs"), dry_run).await, - packages: cleanup_unused_archives(reference, &socket_dir.join("packages"), dry_run).await, + // Nothing writes or reads `.socket/packages/` any more; sweep the + // leftover directory whole. + packages: cleanup_unused_archives( + &PatchManifest::default(), + &socket_dir.join("packages"), + dry_run, + ) + .await, } } @@ -1077,8 +1069,8 @@ pub(crate) async fn retire_legacy_redirect_ledger(common: &GlobalArgs) -> Option pub async fn run(args: RollbackArgs) -> i32 { apply_env_toggles(&args.common); - // Classify targets up front: the one-off stub and the glob validation - // are pre-network usage checks. + // Classify targets up front: the glob validation is a pre-network + // usage check. let mut identifiers: Vec = Vec::new(); let mut path_patterns: Vec = Vec::new(); for token in &args.targets { @@ -1088,32 +1080,6 @@ pub async fn run(args: RollbackArgs) -> i32 { } } - // Bail on the unimplemented flag BEFORE constructing the API client: - // client construction can auto-resolve the org slug over the network, - // and the contract promises the one-off stub fails before any network - // or disk activity. - if args.one_off { - let msg = if identifiers.is_empty() { - "--one-off requires an identifier (UUID or PURL)" - } else { - "One-off rollback mode is not yet implemented" - }; - if args.common.json { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "error", - "error": msg, - })) - .expect("serializing an in-memory JSON value cannot fail") - ); - } else { - eprintln!("Error: {msg}"); - } - // A usage error (v5.0: exit 2, like every other one). - return 2; - } - // An unparseable glob is a usage error — same exit-2 stderr shape as // scan's self-enforced mode conflicts. let path_scope = match crate::path_scope::PathScope::parse(&path_patterns) { @@ -4590,7 +4556,7 @@ mod tests { assert_eq!(capitalize_first("path pattern x"), "Path pattern x"); assert_eq!(capitalize_first("Already"), "Already"); assert_eq!(capitalize_first("ülk"), "Ülk"); - assert_eq!(capitalize_first("--one-off"), "--one-off"); + assert_eq!(capitalize_first("--preserve-state"), "--preserve-state"); assert_eq!(capitalize_first("cannot read x: y"), "Cannot read x: y"); assert_eq!(capitalize_first("can't, really"), "Can't, really"); // Values the user may copy back are never altered. diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index b0afa29c..6c999e80 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1,4 +1,4 @@ -//! The hosted-mode (`--mode hosted` / `--redirect`) flow: rewrite ONLY the +//! The hosted-mode (`--mode hosted`) flow: rewrite ONLY the //! patched dependencies' lockfile / registry-config entries to point at //! Socket's hosted vendored patches. Self-contained — reuses `run`'s //! discovery, then returns without touching the apply/vendor branches. @@ -2523,7 +2523,6 @@ mod tests { token: "tok".to_string(), patch_uuid: "11111111-1111-4111-8111-111111111111".to_string(), artifact_url: artifact_url.to_string(), - berry_zip_url: None, registry_override: None, integrity: Default::default(), } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs index 376526ab..c53b32dd 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -579,7 +579,6 @@ mod tests { token: String::new(), patch_uuid: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into(), artifact_url: url.to_string(), - berry_zip_url: None, registry_override: None, integrity: socket_patch_core::patch::redirect::Integrity { sha512: Some("sha512-new".into()), diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index cf1bb5c8..aa7b2ead 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -139,8 +139,8 @@ fn batch_chunks(purls: &[String], batch_size: usize, max_body_bytes: usize) -> V } /// The three patch-application modes `scan` can drive, selectable via -/// `--mode` (the documented spelling). Each variant is equivalent to one -/// legacy boolean flag, which remains supported as an alias. +/// `--mode`. Vendored and agent also keep a hidden deprecated boolean +/// spelling (`--vendor`, `--apply`/`--sync`). // // The `///` docs on the variants are user-facing `--help` text (shared // with `get --mode`); keep implementation notes in `//` comments. @@ -149,15 +149,10 @@ pub enum ScanMode { /// Rewrite lockfiles so only patched dependencies resolve to Socket's /// hosted patch server: no artifact bytes land in the repo, but /// installs must reach the patch server - // Equivalent to the hidden `--redirect` boolean. The hidden value - // aliases mirror legacy spellings (`apply` is deliberately NOT an alias - // of agent). - #[value(alias = "host", alias = "redirect")] Hosted, /// Commit patched artifacts to `.socket/vendor/`: hermetic, /// offline-safe installs at the cost of repo size // Equivalent to `--vendor`. - #[value(alias = "vendor")] Vendored, /// Record patches in `.socket/manifest.json` plus blobs and re-apply /// them in place (e.g. from CI): smallest repo footprint, but every @@ -178,8 +173,8 @@ impl ScanMode { } } -/// Fold the legacy boolean spellings (`--redirect` / `--vendor` / -/// `--apply` / `--sync`) into `args.mode`, so `ScanMode` is the single +/// Fold the boolean spellings (`--vendor` / `--apply` / `--sync`) into +/// `args.mode`, so `ScanMode` is the single /// source of truth everything downstream reads (the booleans are input /// spellings only, never consulted after this returns), and enforce the /// cross-flag rules clap cannot express: @@ -196,8 +191,6 @@ impl ScanMode { /// Hosted mode runs no GC, so `--mode hosted --prune` stays accepted but /// emits an explicit `redirect_prune_ignored` warning in `run` rather /// than silently dropping the flag. -/// * `--detached` requires vendored mode in either spelling (clap's -/// `requires = "vendor"` cannot see `--mode vendored`). /// /// Public (not `pub(crate)`) so the CLI-contract tests can exercise the /// fold without driving a full `run()`. @@ -205,9 +198,6 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { if let Some(mode) = args.mode { // First boolean that selects a mode OTHER than the requested one. let mut conflicting: Option<&'static str> = None; - if args.redirect && mode != ScanMode::Hosted { - conflicting = Some("--redirect"); - } if args.vendor && mode != ScanMode::Vendored { conflicting = Some("--vendor"); } @@ -220,20 +210,13 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { if let Some(flag) = conflicting { // "cannot be used with" phrasing matches clap's conflict errors — // the scan_vendor_e2e contract test accepts exactly that shape. - // The hidden --redirect is only explained when it was typed. - let meaning = if flag == "--redirect" { - "--redirect means --mode hosted" - } else { - "--vendor means --mode vendored; --apply and --sync mean --mode agent" - }; return Err(format!( "--mode {} cannot be used with {flag}: the flags select different \ - modes ({meaning})", + modes (--vendor means --mode vendored; --apply and --sync mean \ + --mode agent)", mode.cli_name(), )); } - } else if args.redirect { - args.mode = Some(ScanMode::Hosted); } else if args.vendor { args.mode = Some(ScanMode::Vendored); } else if args.apply || args.sync { @@ -258,14 +241,6 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { }, )); } - if args.detached && args.mode != Some(ScanMode::Vendored) { - // "required" phrasing matches clap's requires errors — the - // scan_vendor_e2e contract test accepts exactly that shape. - return Err( - "--detached requires vendored mode: --mode vendored or --vendor is required" - .to_string(), - ); - } Ok(()) } @@ -314,27 +289,13 @@ pub struct ScanArgs { #[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync"])] pub vendor: bool, - /// Accepted for compatibility; has no effect - // Hidden: vendored mode is always manifest-free (the vendor ledger - // embeds each patch record and `.socket/manifest.json` is never - // written), so the flag is a no-op. It still requires vendored mode in - // either spelling (`--mode vendored` / `--vendor`), enforced in - // `resolve_mode_flags` rather than clap `requires` so `--mode vendored` - // satisfies it too. - #[arg(long, default_value_t = false, hide = true)] - pub detached: bool, - - // Hidden legacy spelling of `--mode hosted`. - #[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync", "vendor"])] - pub redirect: bool, - /// How discovered patches are consumed [default: hosted]. A `--prune` /// or `--global` scan with no mode only reports - // The hidden `--vendor` and `--apply` are older spellings of - // `--mode vendored` and `--mode agent`. Each mode is equivalent to one - // boolean flag (hosted == the hidden `--redirect`, vendored == `--vendor`, agent == `--apply`/`--sync`). - // Combining `--mode` with a boolean from a DIFFERENT mode is rejected in - // `resolve_mode_flags`; the same mode spelled both ways is accepted. + // The hidden `--vendor` and `--apply` are deprecated spellings of + // `--mode vendored` and `--mode agent` (`--sync` also selects agent); + // hosted has no boolean spelling. Combining `--mode` with a boolean + // from a DIFFERENT mode is rejected in `resolve_mode_flags`; the same + // mode spelled both ways is accepted. #[arg(long = "mode", value_enum)] pub mode: Option, 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 db4aa9bb..eb38a147 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -9,8 +9,7 @@ //! embeds each record in its ledger entry (`detached: true`), and //! `.socket/manifest.json` is never written — a project vendored by an //! older, manifest-mode CLI is migrated on its next vendored run (see -//! [`migrate_legacy_manifest_records`]). `--detached` is accepted as a -//! no-op for compatibility. +//! [`migrate_legacy_manifest_records`]). //! //! One API client per run: `scan`/`get` build it once (proxy fallback //! included) and thread it through the download phase and into the vendor diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 093de219..ef05f992 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -4075,7 +4075,6 @@ mod dispatch_tests { }; let sources = PatchSources { blobs_path: tmp.path(), - packages_path: None, diffs_path: None, mem_blobs: None, }; @@ -4387,7 +4386,6 @@ mod variant_probe_tests { }; let sources = PatchSources { blobs_path: tmp.path(), - packages_path: None, diffs_path: None, mem_blobs: None, }; @@ -4471,7 +4469,6 @@ mod variant_probe_tests { }; let sources = PatchSources { blobs_path: tmp.path(), - packages_path: None, diffs_path: None, mem_blobs: None, }; diff --git a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 2744cd6a..a12e8f8d 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -1303,7 +1303,6 @@ mod tests { token: String::new(), patch_uuid: uuid.into(), artifact_url: format!("./.socket/vendor/npm/{uuid}/minimist-1.2.2.tgz"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(format!("sha512-{}", "A".repeat(86) + "==")), diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index a0eaabe5..22f752f5 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -350,7 +350,6 @@ pub enum PatchAction { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum AppliedVia { - Package, Diff, Blob, } @@ -359,7 +358,6 @@ impl AppliedVia { pub fn from_core(via: socket_patch_core::patch::apply::AppliedVia) -> Self { use socket_patch_core::patch::apply::AppliedVia as Core; match via { - Core::Package => AppliedVia::Package, Core::Diff => AppliedVia::Diff, Core::Blob => AppliedVia::Blob, } diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 38ad79dd..371a8c88 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -181,7 +181,7 @@ fn short_help_hidden_own(sub: &str) -> &'static [&'static str] { "vex_doc_id", "vex_compact", ], - "get" => &["id", "cve", "ghsa", "package", "save_only", "one_off", "all_releases"], + "get" => &["id", "cve", "ghsa", "package", "save_only", "all_releases"], "vex" => &["doc_id", "compact"], "apply" => &["vex_product", "vex_no_verify", "vex_doc_id", "vex_compact"], "vendor" => &["vex_product", "vex_no_verify", "vex_doc_id", "vex_compact"], diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index 57947511..418403a1 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -1,5 +1,5 @@ use socket_patch_cli::{commands, parse_argv_with_shortcuts, Commands}; -use socket_patch_core::utils::env_compat::{promote_legacy_env_vars, promote_peer_env_vars}; +use socket_patch_core::utils::env_compat::promote_peer_env_vars; use socket_patch_core::utils::socket_cli_config; /// Restore the default SIGPIPE disposition. The Rust runtime starts every @@ -23,16 +23,11 @@ fn restore_default_sigpipe() {} #[tokio::main] async fn main() { - // Must precede any output: the deprecation warnings and clap help both - // write to possibly-already-closed pipes. + // Must precede any output: clap help writes to a possibly-already-closed + // pipe. restore_default_sigpipe(); - // Migrate legacy SOCKET_PATCH_* env vars into the new SOCKET_* names - // before clap parses, so downstream code only needs to know the new - // names. A one-shot deprecation warning fires per legacy name set. - promote_legacy_env_vars(); - - // Then accept the JS socket-cli's SOCKET_CLI_* peer names (silently — + // Accept the JS socket-cli's SOCKET_CLI_* peer names (silently — // they are aliases, not deprecations) so `socket login` / socket-cli // env setups work for socket-patch unchanged. Canonical names win. promote_peer_env_vars(); @@ -47,8 +42,7 @@ async fn main() { // Then drop exported-but-empty SOCKET_* flag vars — global and // subcommand-local (`SOCKET_CWD=` means "unset", not "crash the - // parse"). Must run after the promotion so a blanked legacy name is - // scrubbed too. + // parse"). socket_patch_cli::args::scrub_empty_env_vars(); // The parser surface is `String`-typed, but argv is raw bytes on Unix — diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 431c8da9..837057e1 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -901,22 +901,20 @@ fn write_package_archive(packages: &Path, uuid: &str, entries: &[(&str, &[u8])]) .unwrap(); } -/// A cached `.socket/packages/.tar.gz` is a complete source for the -/// patch: the same tree applies fine under `--offline`. Going online must -/// not make it FAIL: when the (default) diff fetch and the blob fallback -/// both fail (no archives served, blob GC'd, entitlement change, dead -/// network), the stage step must only bail if some patch is actually left -/// without a source. +/// A leftover `.socket/packages/.tar.gz` (v5.0 no longer reads package +/// archives) is not a patch source: when the diff fetch and the blob +/// fallback both fail, apply must report the patch as unavailable instead of +/// silently patching from the stale archive. #[tokio::test] -async fn apply_online_uses_cached_package_archive_when_downloads_fail() { +async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { let before = b"pkgcache before\n"; let after = b"pkgcache after\n"; let before_hash = git_sha256(before); let after_hash = git_sha256(after); let uuid = "44444444-4444-4444-8444-444444444444"; - // Nothing is served: diff, package and blob endpoints all 404 (wiremock - // default for unmounted routes), i.e. every download attempt fails. + // Nothing is served: diff and blob endpoints 404 (wiremock default for + // unmounted routes), i.e. every download attempt fails. let mock = MockServer::start().await; let tmp = tempfile::tempdir().expect("tempdir"); @@ -930,8 +928,6 @@ async fn apply_online_uses_cached_package_archive_when_downloads_fail() { &before_hash, &after_hash, ); - // The only local source: a package archive holding the patched bytes - // (what `repair --download-mode package` leaves behind). No blobs. write_package_archive( &socket.join("packages"), uuid, @@ -939,25 +935,13 @@ async fn apply_online_uses_cached_package_archive_when_downloads_fail() { ); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); - assert_eq!( + assert_ne!( code, 0, - "a cached package archive is a usable source; failed downloads for \ - artifacts we don't need must not abort the run; \ - stdout={stdout}\nstderr={stderr}" - ); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!( - v["summary"]["applied"], 1, - "the patch must apply from the cached package archive; stdout={stdout}" + "a legacy package archive must not cover the patch; stdout={stdout}\nstderr={stderr}" ); - assert_eq!(v["summary"]["failed"], 0, "stdout={stdout}"); - - // The patched bytes came from the archive. let content = std::fs::read(tmp.path().join("node_modules/pkgcache/index.js")).unwrap(); - assert_eq!(content, after, "file must carry the patched content"); + assert_eq!(content, before, "the file must not be patched from the legacy archive"); - // Keep the test honest: the downloads really were attempted and really - // did fail (otherwise this would pass for the wrong reason). let requests = mock.received_requests().await.unwrap_or_default(); let blob_path = format!("/v0/orgs/{ORG_SLUG}/patches/blob/{after_hash}"); assert!( @@ -968,16 +952,6 @@ async fn apply_online_uses_cached_package_archive_when_downloads_fail() { .map(|r| r.url.path().to_string()) .collect::>() ); - - // Apply stays read-only against the persistent cache. - let blobs_dir = socket.join("blobs"); - if blobs_dir.exists() { - let entries: Vec<_> = std::fs::read_dir(&blobs_dir).unwrap().collect(); - assert!( - entries.is_empty(), - "apply must not write to .socket/blobs/; found {entries:?}" - ); - } } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/cli_env_deprecation.rs b/crates/socket-patch-cli/tests/cli_env_deprecation.rs deleted file mode 100644 index 5e4d9bc5..00000000 --- a/crates/socket-patch-cli/tests/cli_env_deprecation.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! Tests for the legacy → new env-var compatibility shim. -//! -//! v3.0 renamed three env vars from the `SOCKET_PATCH_*` prefix to the -//! unified `SOCKET_*` prefix. The shim in `socket_patch_core::utils::env_compat` -//! reads the legacy name when the new name is unset and emits a one-shot -//! deprecation warning to stderr — even under `--silent` / `--json`. -//! -//! These tests run the compiled binary as a subprocess so we can observe -//! the actual stderr output. In-process testing would race with parallel -//! tests that also touch env vars. - -use std::process::Command; - -const BINARY: &str = env!("CARGO_BIN_EXE_socket-patch"); - -/// Every legacy/new env-var name the shim knows about. We wipe ALL of these -/// from the child env so the parent process's environment can never leak a -/// stray var that fires (or suppresses) a deprecation warning and makes a -/// test falsely pass or falsely fail. -const ALL_RENAME_VARS: &[&str] = &[ - "SOCKET_PROXY_URL", - "SOCKET_PATCH_PROXY_URL", - "SOCKET_DEBUG", - "SOCKET_PATCH_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_TELEMETRY_DISABLED", -]; - -/// Other env vars that perturb the run; wiped for hermeticity. -const OTHER_VARS: &[&str] = &["SOCKET_API_TOKEN", "SOCKET_API_URL", "SOCKET_ORG_SLUG"]; - -/// Captured output of a child invocation. -struct Output { - stdout: String, - stderr: String, - /// Process exit code. `None` only if the child was killed by a signal — - /// which we treat as a hard failure (a crash that happened to print the - /// warning before dying must not count as a pass). - code: Option, -} - -/// Count non-overlapping occurrences of `needle` in `haystack`. -fn count_occurrences(haystack: &str, needle: &str) -> usize { - haystack.matches(needle).count() -} - -/// Build a `socket-patch list` command in a hermetic env (every rename var -/// and friend removed) pointed at a fresh empty tempdir. -fn base_cmd(tmp: &std::path::Path, extra_args: &[&str]) -> Command { - let mut cmd = Command::new(BINARY); - cmd.arg("list").arg("--cwd").arg(tmp); - for a in extra_args { - cmd.arg(a); - } - for k in ALL_RENAME_VARS.iter().chain(OTHER_VARS.iter()) { - cmd.env_remove(k); - } - cmd -} - -/// Helper: invoke `socket-patch list` (the cheapest read-only subcommand) -/// in a clean env, set the given legacy env var, and capture stdout+stderr. -fn run_with_legacy_env(legacy: &str, value: &str, extra_args: &[&str]) -> Output { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cmd = base_cmd(tmp.path(), extra_args); - cmd.env(legacy, value); - let out = cmd.output().expect("run socket-patch list"); - Output { - stdout: String::from_utf8_lossy(&out.stdout).into_owned(), - stderr: String::from_utf8_lossy(&out.stderr).into_owned(), - code: out.status.code(), - } -} - -/// Assert that `stderr` carries a *well-formed* deprecation warning for the -/// `legacy` → `new` rename: it must name the legacy var, name the new var, -/// call the legacy var "deprecated", phrase it as a "use instead" -/// directive, and fire exactly once (the warning is documented as one-shot). -fn assert_deprecation_warning(stderr: &str, legacy: &str, new: &str) { - assert!( - stderr.contains(legacy), - "stderr should mention the legacy var name `{legacy}`; stderr was:\n{stderr}" - ); - assert!( - stderr.contains(new), - "stderr should mention the new var name `{new}`; stderr was:\n{stderr}" - ); - assert!( - stderr.to_lowercase().contains("deprecated"), - "stderr should call the legacy var deprecated; stderr was:\n{stderr}" - ); - // The message must steer the user to the *correct* replacement, not just - // happen to contain both strings somewhere. Guard the "use `` instead" - // directive so a regression that prints the wrong replacement is caught. - assert!( - stderr.contains(&format!("use `{new}`")), - "warning should direct users to `use `{new}``; stderr was:\n{stderr}" - ); - // One-shot: exactly one deprecation line, not a duplicated/looping warn. - assert_eq!( - count_occurrences(&stderr.to_lowercase(), "deprecated"), - 1, - "deprecation warning should fire exactly once; stderr was:\n{stderr}" - ); - // The warning belongs on stderr only — never let it appear more than once - // for a single legacy var name either. - assert_eq!( - count_occurrences(stderr, legacy), - 1, - "legacy var name should appear exactly once in the warning; stderr was:\n{stderr}" - ); - // Strongest guard, and the one that defeats reward-hacking: the warning - // line must match the full documented contract *verbatim*, not merely - // contain a scatter of the right substrings. The expected text is spelled - // out here independently of the implementation (it is not read back from - // the binary), so a regression that mangles the `[socket-patch] warning:` - // prefix, drops the "removed in a future major release" notice, reorders - // clauses, or alters punctuation will fail this test rather than slip past - // the looser `contains` checks above. - let expected_line = format!( - "[socket-patch] warning: env var `{legacy}` is deprecated; \ - use `{new}` instead. The legacy name will be removed in a \ - future major release." - ); - assert!( - stderr.contains(&expected_line), - "stderr must contain the exact deprecation line:\n {expected_line}\nstderr was:\n{stderr}" - ); - // And it must appear as a standalone line on stderr (not embedded in some - // other message), terminated by a newline — i.e. emitted via `eprintln!`. - assert!( - stderr.lines().any(|l| l == expected_line), - "the deprecation warning must be its own stderr line; stderr was:\n{stderr}" - ); -} - -#[test] -fn legacy_proxy_url_warns() { - let out = run_with_legacy_env("SOCKET_PATCH_PROXY_URL", "https://legacy.example", &[]); - assert_deprecation_warning(&out.stderr, "SOCKET_PATCH_PROXY_URL", "SOCKET_PROXY_URL"); - // The warning is diagnostic output and must not contaminate stdout. - assert!( - !out.stdout.to_lowercase().contains("deprecated"), - "deprecation warning must not leak onto stdout; stdout was:\n{}", - out.stdout - ); - // The warning must fire on the *real* code path: `list` against an empty - // tempdir runs to its normal empty-project result (exit 0). Pinning this - // rejects a child that crashed (signal → `None`) after emitting the line, - // and proves the shim ran inside an actual command invocation. - assert_eq!( - out.code, - Some(0), - "expected the empty-project list exit; stderr was:\n{}", - out.stderr - ); -} - -#[test] -fn legacy_debug_warns() { - let out = run_with_legacy_env("SOCKET_PATCH_DEBUG", "1", &[]); - assert_deprecation_warning(&out.stderr, "SOCKET_PATCH_DEBUG", "SOCKET_DEBUG"); - assert!( - !out.stdout.to_lowercase().contains("deprecated"), - "deprecation warning must not leak onto stdout; stdout was:\n{}", - out.stdout - ); - assert_eq!( - out.code, - Some(0), - "expected the empty-project list exit; stderr was:\n{}", - out.stderr - ); -} - -#[test] -fn legacy_telemetry_disabled_warns() { - let out = run_with_legacy_env("SOCKET_PATCH_TELEMETRY_DISABLED", "1", &[]); - assert_deprecation_warning( - &out.stderr, - "SOCKET_PATCH_TELEMETRY_DISABLED", - "SOCKET_TELEMETRY_DISABLED", - ); - assert!( - !out.stdout.to_lowercase().contains("deprecated"), - "deprecation warning must not leak onto stdout; stdout was:\n{}", - out.stdout - ); - assert_eq!( - out.code, - Some(0), - "expected the empty-project list exit; stderr was:\n{}", - out.stderr - ); -} - -/// `--silent` suppresses informational output but the deprecation warning -/// is a transition signal users need to see, so it must still fire — and it -/// must still be a complete, correct warning, not a degraded one. -#[test] -fn legacy_warning_fires_under_silent() { - let out = run_with_legacy_env( - "SOCKET_PATCH_PROXY_URL", - "https://legacy.example", - &["--silent"], - ); - // The exact-line check inside this helper is the real guard: passing - // `--silent` must not degrade, truncate, or suppress the warning — under - // `--silent` it must be byte-for-byte the same line emitted without it. - assert_deprecation_warning(&out.stderr, "SOCKET_PATCH_PROXY_URL", "SOCKET_PROXY_URL"); - // `--silent` is parsed and accepted (no clap usage error, which would be - // exit 2); the command still runs to its normal empty-project result. - assert_eq!( - out.code, - Some(0), - "--silent should be accepted and the command reach its normal exit; stderr was:\n{}", - out.stderr - ); - // The warning is diagnostic output: it must stay on stderr and never bleed - // onto stdout, regardless of verbosity flags. - assert!( - !out.stdout.to_lowercase().contains("deprecated") - && !out.stdout.contains("SOCKET_PATCH_PROXY_URL"), - "deprecation warning must not leak onto stdout under --silent; stdout was:\n{}", - out.stdout - ); -} - -/// Same precedence as `--silent`: `--json` is for machine output but the -/// deprecation belongs on stderr, separate from the JSON payload on stdout. -#[test] -fn legacy_warning_fires_under_json() { - let out = run_with_legacy_env( - "SOCKET_PATCH_PROXY_URL", - "https://legacy.example", - &["--json"], - ); - assert_deprecation_warning(&out.stderr, "SOCKET_PATCH_PROXY_URL", "SOCKET_PROXY_URL"); - // The whole point of routing the warning to stderr under --json is that - // stdout stays parseable. Prove stdout is untouched JSON, free of the - // human-facing warning. - assert!( - !out.stdout.to_lowercase().contains("deprecated") - && !out.stdout.contains("SOCKET_PATCH_PROXY_URL"), - "warning must not leak into the --json stdout payload; stdout was:\n{}", - out.stdout - ); - let trimmed = out.stdout.trim(); - assert!( - !trimmed.is_empty(), - "--json should still emit a JSON document on stdout; stdout was:\n{}", - out.stdout - ); - let parsed: serde_json::Value = serde_json::from_str(trimmed).unwrap_or_else(|e| { - panic!( - "stdout must be valid JSON ({e}); stdout was:\n{}", - out.stdout - ) - }); - assert_eq!( - parsed.get("command").and_then(|v| v.as_str()), - Some("list"), - "JSON payload should be the structured `list` command result; got:\n{}", - out.stdout - ); - // An empty tempdir lists as an empty project, so the structured result - // must be a success with no events — proving the JSON path itself ran - // rather than some short-circuited stub. - assert_eq!( - parsed.get("status").and_then(|v| v.as_str()), - Some("success"), - "JSON payload should report the empty-project success; got:\n{}", - out.stdout - ); - assert_eq!( - out.code, - Some(0), - "expected the empty-project list exit under --json; stderr was:\n{}", - out.stderr - ); -} - -/// When the new var is set, the legacy var must be ignored — no warning, and -/// the legacy name must not even be mentioned on stderr. -#[test] -fn new_var_takes_precedence_and_silences_warning() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cmd = base_cmd(tmp.path(), &[]); - // New var set, legacy var also set: the new one must win, the legacy one - // must be silently ignored. - cmd.env("SOCKET_PROXY_URL", "https://new.example"); - cmd.env("SOCKET_PATCH_PROXY_URL", "https://legacy.example"); - let out = cmd.output().expect("run socket-patch list"); - let stderr = String::from_utf8_lossy(&out.stderr); - // Guard against a vacuous pass: if the binary never launched (or crashed - // before promoting env vars) stderr would also lack "deprecated". Require - // the real empty-project list exit so "no warning" means the shim - // ran and chose to stay quiet — not that nothing ran at all. - assert_eq!( - out.status.code(), - Some(0), - "expected the binary to run to its empty-project list result; stderr was:\n{stderr}" - ); - assert!( - !stderr.to_lowercase().contains("deprecated"), - "no deprecation warning expected when new var is set; stderr was:\n{stderr}" - ); - assert!( - !stderr.contains("SOCKET_PATCH_PROXY_URL"), - "legacy var name must not appear when the new var takes precedence; stderr was:\n{stderr}" - ); -} - -/// Sanity guard against a false-positive in the "warns" tests: with NO legacy -/// var set at all, the binary must emit zero deprecation noise. This proves -/// the warnings above are caused by the legacy var, not by ambient output the -/// substring checks would otherwise rubber-stamp. -#[test] -fn no_warning_when_no_legacy_var_set() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cmd = base_cmd(tmp.path(), &[]); - let out = cmd.output().expect("run socket-patch list"); - let stderr = String::from_utf8_lossy(&out.stderr); - // As above: require the real error exit so a "clean" stderr can't be the - // result of the binary failing to start. - assert_eq!( - out.status.code(), - Some(0), - "expected the binary to run to its empty-project list result; stderr was:\n{stderr}" - ); - assert!( - !stderr.to_lowercase().contains("deprecated"), - "no deprecation warning expected with no legacy var set; stderr was:\n{stderr}" - ); - // Cross-check the positive tests are not rubber-stamping ambient output: - // with no legacy var set, none of the legacy names may appear on stderr. - for legacy in ALL_RENAME_VARS { - assert!( - !stderr.contains(legacy), - "no legacy var name should appear with none set; saw `{legacy}` in stderr:\n{stderr}" - ); - } -} diff --git a/crates/socket-patch-cli/tests/cli_get_silent.rs b/crates/socket-patch-cli/tests/cli_get_silent.rs index 4552d78e..4e43c353 100644 --- a/crates/socket-patch-cli/tests/cli_get_silent.rs +++ b/crates/socket-patch-cli/tests/cli_get_silent.rs @@ -27,11 +27,7 @@ fn run_get(cwd: &Path, args: &[&str]) -> (i32, String) { } for var in [ "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", - "SOCKET_PATCH_API_URL", - "SOCKET_PATCH_API_TOKEN", - "SOCKET_PATCH_PROXY_URL", ] { cmd.env_remove(var); } diff --git a/crates/socket-patch-cli/tests/cli_parse_get.rs b/crates/socket-patch-cli/tests/cli_parse_get.rs index 9d50c0e2..164c9c02 100644 --- a/crates/socket-patch-cli/tests/cli_parse_get.rs +++ b/crates/socket-patch-cli/tests/cli_parse_get.rs @@ -59,7 +59,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_NO_VLT_INSTALL_CLEANUP", // GetArgs-specific "SOCKET_SAVE_ONLY", - "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", ]; @@ -119,7 +118,7 @@ fn parse_get(extra: &[&str]) -> GetArgs { /// This is what makes the per-flag tests honest. A field-at-a-time assertion /// (`assert!(a.package)`) only proves the flag set *its* field; it says nothing /// about whether the same flag also flipped an unrelated one. A clap-derive -/// copy/paste regression (e.g. `--package` accidentally wired to `one_off`) +/// copy/paste regression (e.g. `--package` accidentally wired to `save_only`) /// would set both and still pass a single-field check. Comparing the whole /// snapshot against the independently-declared defaults — with only the field /// under test mutated — fails loudly the instant any other field moves. @@ -154,7 +153,6 @@ struct Snap { ghsa: bool, package: bool, save_only: bool, - one_off: bool, all_releases: bool, mode: Option, } @@ -190,7 +188,6 @@ fn snapshot(a: &GetArgs) -> Snap { ghsa: a.ghsa, package: a.package, save_only: a.save_only, - one_off: a.one_off, all_releases: a.all_releases, mode: a.mode, } @@ -234,7 +231,6 @@ fn expected_defaults(identifier: &str) -> Snap { ghsa: false, package: false, save_only: false, - one_off: false, all_releases: false, mode: None, } @@ -414,17 +410,6 @@ fn global_prefix_flag_sets_global_prefix() { assert_eq!(snapshot(&a), want); } -#[test] -#[serial_test::serial] -fn one_off_flag_sets_one_off() { - let a = parse_get(&["some-id", "--one-off"]); - let mut want = expected_defaults("some-id"); - want.one_off = true; - // `--one-off` and `--save-only` are semantic opposites; this guards that - // setting one does not also flip the other. - assert_eq!(snapshot(&a), want); -} - #[test] #[serial_test::serial] fn json_flag_sets_json() { @@ -521,7 +506,7 @@ fn mode_hosted_parses() { let mut want = expected_defaults("some-id"); want.mode = Some(socket_patch_cli::commands::scan::ScanMode::Hosted); // Full-snapshot equality: `--mode hosted` sets `mode` and nothing else - // (in particular it must NOT flip save_only/one_off or any GlobalArgs + // (in particular it must NOT flip save_only or any GlobalArgs // field — the runtime conflicts are run()'s job, not the parser's). assert_eq!(snapshot(&a), want); } @@ -546,43 +531,32 @@ fn mode_agent_parses() { #[test] #[serial_test::serial] -fn mode_hidden_value_aliases_parse() { - // The hidden value aliases mirror the legacy scan flag spellings: - // `host` (old mode name) and `redirect` (the `--redirect` boolean) - // for hosted; `vendor` (the `--vendor` boolean) for vendored. Each - // must parse byte-identically to its canonical spelling across the - // ENTIRE surface, not merely land on the right variant. - for (alias, canonical) in [ - ("host", "hosted"), - ("redirect", "hosted"), - ("vendor", "vendored"), - ] { - let via_alias = parse_get(&["some-id", "--mode", alias]); - let via_canonical = parse_get(&["some-id", "--mode", canonical]); - assert_eq!( - snapshot(&via_alias), - snapshot(&via_canonical), - "--mode {alias} must parse identically to --mode {canonical}" +fn removed_mode_value_aliases_are_rejected() { + // v5.0 dropped the hidden `host` / `redirect` / `vendor` value aliases + // (shared with `scan --mode`); only the canonical names parse. + let _scrub = EnvScrub::new(); + for alias in ["host", "redirect", "vendor"] { + let err = match Cli::try_parse_from(["socket-patch", "get", "some-id", "--mode", alias]) { + Ok(_) => panic!("--mode {alias} should fail to parse"), + Err(e) => e, + }; + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue + ), + "--mode {alias}: expected ValueValidation or InvalidValue, got {:?}", + err.kind() ); - // ...and the canonical parse itself is default-everything + mode, - // so the alias equality above can't be satisfied by two equally - // wrong parses. - let mut want = expected_defaults("some-id"); - want.mode = Some(match canonical { - "hosted" => socket_patch_cli::commands::scan::ScanMode::Hosted, - _ => socket_patch_cli::commands::scan::ScanMode::Vendored, - }); - assert_eq!(snapshot(&via_canonical), want); } } #[test] #[serial_test::serial] fn mode_rejects_unknown_value() { - // The shared value_enum restricts `--mode` to the three known names - // (+ hidden aliases). `apply` is deliberately NOT an alias of agent — - // applying is not a scan-mode name anywhere else (see ScanMode's doc) - // — so it must be rejected exactly like a bogus value. + // The shared value_enum restricts `--mode` to the three known names. + // `apply` is not a scan-mode name anywhere, so it must be rejected + // exactly like a bogus value. let _scrub = EnvScrub::new(); for bad in ["bogus", "apply"] { let err = match Cli::try_parse_from(["socket-patch", "get", "some-id", "--mode", bad]) { @@ -642,6 +616,17 @@ fn unknown_flag_errors() { assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } +#[test] +#[serial_test::serial] +fn removed_one_off_flag_is_unknown() { + let _scrub = EnvScrub::new(); + let err = match Cli::try_parse_from(["socket-patch", "get", "some-id", "--one-off"]) { + Err(e) => e, + Ok(_) => panic!("expected parse error for the v5-removed --one-off"), + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} + // --- Hermeticity of the scrub itself ------------------------------------------- #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index 79787517..c8b77af5 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -25,7 +25,7 @@ fn parse_rollback(extra: &[&str]) -> RollbackArgs { /// Every boolean toggle on `rollback`, as `(contract name, current value)`. /// Used to prove that a single flag flips *only* its own field — without this, /// each positive test ignores all other fields, so a parser bug that -/// cross-wired e.g. `--one-off` into `--global`, `--silent` into `--yes` +/// cross-wired e.g. `--preserve-state` into `--global`, `--silent` into `--yes` /// (auto-approving prompts), or any flag into another would still stay green. /// Keep this in sync with the boolean flags in the contract. fn bool_flags(a: &RollbackArgs) -> Vec<(&'static str, bool)> { @@ -39,7 +39,6 @@ fn bool_flags(a: &RollbackArgs) -> Vec<(&'static str, bool)> { ("yes", a.common.yes), ("debug", a.common.debug), ("no_telemetry", a.common.no_telemetry), - ("one_off", a.one_off), ("preserve_state", a.preserve_state), ] } @@ -71,7 +70,6 @@ fn defaults_no_positional() { assert!(!args.common.offline); assert!(!args.common.global); assert_eq!(args.common.global_prefix, None); - assert!(!args.one_off); assert_eq!(args.common.org, None); assert_eq!(args.common.api_url, None); // default applied in core resolver assert_eq!(args.common.api_token, None); @@ -185,15 +183,6 @@ fn global_prefix_long() { assert_eq!(args.common.global_prefix, Some(PathBuf::from("/foo"))); } -#[test] -fn one_off_long() { - let args = parse_rollback(&["--one-off"]); - assert!(args.one_off); - // `--one-off` is rollback-specific (fetch beforeHash blobs from API). It - // must NOT silently imply `--offline`, `--global`, or any other toggle. - assert_only_true(&args, &["one_off"]); -} - #[test] fn org_long() { let args = parse_rollback(&["--org", "myorg"]); @@ -317,7 +306,6 @@ fn all_bools_settable_together() { "--yes", "--debug", "--no-telemetry", - "--one-off", "--preserve-state", ]); assert_only_true( @@ -332,7 +320,6 @@ fn all_bools_settable_together() { "yes", "debug", "no_telemetry", - "one_off", "preserve_state", ], ); @@ -360,16 +347,17 @@ fn all_short_flags_map_to_distinct_fields() { } /// Bare boolean flags are `SetTrue` (num_args = 0): they must NOT swallow the -/// following token as a value. If `--one-off` silently became value-taking, a -/// wrapper invoking `rollback --one-off ` would change meaning (the purl +/// following token as a value. If `--preserve-state` silently became +/// value-taking, a wrapper invoking `rollback --preserve-state ` would +/// change meaning (the purl /// would be consumed as the flag's value, not the `targets` positional). #[test] fn bare_bool_does_not_consume_next_token() { - let args = parse_rollback(&["--one-off", "pkg:npm/foo@1"]); - assert!(args.one_off); - // The trailing token landed in `targets`, not as a value for `--one-off`. + let args = parse_rollback(&["--preserve-state", "pkg:npm/foo@1"]); + assert!(args.preserve_state); + // The trailing token landed in `targets`, not as a value for the flag. assert_eq!(args.targets, vec!["pkg:npm/foo@1".to_string()]); - assert_only_true(&args, &["one_off"]); + assert_only_true(&args, &["preserve_state"]); } /// Variadic targets (v4 duality rework): multiple positionals parse in @@ -416,3 +404,12 @@ fn unknown_flag_fails() { }; assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } + +#[test] +fn removed_one_off_flag_is_unknown() { + let err = match Cli::try_parse_from(["socket-patch", "rollback", "--one-off"]) { + Ok(_) => panic!("expected parse error for the v5-removed --one-off"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_scan.rs b/crates/socket-patch-cli/tests/cli_parse_scan.rs index 1ed6a66d..64bb72a1 100644 --- a/crates/socket-patch-cli/tests/cli_parse_scan.rs +++ b/crates/socket-patch-cli/tests/cli_parse_scan.rs @@ -143,7 +143,6 @@ fn defaults_match_contract() { ); assert!(!args.sync, "--sync default is false"); assert!(!args.vendor, "--vendor default is false"); - assert!(!args.detached, "--detached default is false"); assert_eq!(args.mode, None, "--mode default is None (no mode selector)"); assert!(!args.common.dry_run, "--dry-run default is false"); assert!( @@ -558,10 +557,9 @@ fn scan_json_empty_cwd_emits_updates_key() { // `--mode ` is the RELEASED spelling of the three // mode flags. `resolve_mode_flags` (run at the top of `scan::run`, // exercised directly here) makes `args.mode` the single source of truth: -// the legacy `--redirect`/`--vendor`/`--apply`/`--sync` booleans fold INTO -// the enum (they are input spellings, never read downstream), and the -// cross-mode rules clap can't express (a value-dependent conflict) are -// enforced. These tests lock both the fold and the legacy aliases. +// the `--vendor`/`--apply`/`--sync` booleans fold INTO the enum (they are +// input spellings, never read downstream), and the cross-mode rules clap +// can't express (a value-dependent conflict) are enforced. /// Parse `extra` (must parse cleanly at the clap level), then run the mode /// fold — mirroring exactly what `scan::run` does before it reads the @@ -579,13 +577,6 @@ fn mode_hosted_is_the_source_of_truth() { // single source of truth (the booleans are inputs, not outputs). let folded = parse_and_resolve(&["--mode", "hosted"]).expect("fold ok"); assert_eq!(folded.mode, Some(ScanMode::Hosted)); - // ...and the legacy boolean spelling folds INTO the enum. - let folded = parse_and_resolve(&["--redirect"]).expect("fold ok"); - assert_eq!( - folded.mode, - Some(ScanMode::Hosted), - "--redirect == --mode hosted" - ); } #[test] @@ -681,39 +672,15 @@ fn mode_agent_with_sync_boolean_is_allowed() { assert!(folded.sync); } -#[test] -#[serial_test::serial] -fn mode_vendored_with_detached_ok() { - // --detached is legal under vendored mode selected via --mode. - let folded = parse_and_resolve(&["--mode", "vendored", "--detached"]).expect("fold ok"); - assert_eq!(folded.mode, Some(ScanMode::Vendored)); - assert!(folded.detached); -} - -#[test] -#[serial_test::serial] -fn detached_without_vendored_mode_errors() { - // --detached now requires vendored mode via resolve_mode_flags (the - // former clap `requires = "vendor"` could not see `--mode vendored`, so - // the requirement moved into the fold). Parsing alone succeeds. - let mut args = parse_scan(&["--detached"]); - assert!( - resolve_mode_flags(&mut args).is_err(), - "--detached without vendored mode must error" - ); -} - #[test] #[serial_test::serial] fn legacy_mode_spellings_still_parse() { // The boolean aliases keep working with no `--mode` given; the fold // derives the mode enum from them (the inverse of the historical // direction — `args.mode` is now the single source of truth). - assert!(parse_scan(&["--redirect"]).redirect); assert!(parse_scan(&["--vendor"]).vendor); assert!(parse_scan(&["--apply"]).apply); - let folded = parse_and_resolve(&["--vendor", "--detached"]).expect("legacy fold ok"); - assert!(folded.detached); + let folded = parse_and_resolve(&["--vendor"]).expect("legacy fold ok"); assert_eq!( folded.mode, Some(ScanMode::Vendored), @@ -730,8 +697,8 @@ fn legacy_mode_spellings_still_parse() { /// `Debug` derive) are formatted individually. fn snap(a: &ScanArgs) -> String { format!( - "{:?} paths={:?} batch_size={:?} apply={} prune={} sync={} vendor={} detached={} \ - redirect={} mode={:?} all_releases={} vex={:?} vex_product={:?} \ + "{:?} paths={:?} batch_size={:?} apply={} prune={} sync={} vendor={} \ + mode={:?} all_releases={} vex={:?} vex_product={:?} \ vex_no_verify={} vex_doc_id={:?} vex_compact={}", a.common, a.paths, @@ -740,8 +707,6 @@ fn snap(a: &ScanArgs) -> String { a.prune, a.sync, a.vendor, - a.detached, - a.redirect, a.mode, a.all_releases, a.vex.vex, @@ -793,107 +758,50 @@ fn scrub_covers_every_scan_env_var_clap_consults() { } } -// --- hidden `--mode` value aliases ("vendor" / "host" / "redirect") --------- -// -// `--mode vendor`, `--mode host`, and `--mode redirect` are UNDOCUMENTED -// spellings accepted for muscle-memory reasons (they match the legacy -// boolean flag names / the old mode name). They are clap value aliases on -// the `ScanMode` variants, which clap keeps out of help output — the tests -// below lock in both the acceptance and the hiding. `--mode apply` is -// deliberately NOT an alias: applying is not a scan mode name anywhere -// (the canonical name is `agent`), so it must stay rejected. - #[test] #[serial_test::serial] -fn mode_alias_vendor_folds_to_vendor() { - // The parser resolves the hidden alias to the canonical variant... - assert_eq!( - parse_scan(&["--mode", "vendor"]).mode, - Some(ScanMode::Vendored) - ); - // ...and the fold keeps it as the single source of truth, exactly as if - // `--mode vendored` were given. - let folded = parse_and_resolve(&["--mode", "vendor"]).expect("fold ok"); - assert_eq!( - folded.mode, - Some(ScanMode::Vendored), - "--mode vendor (hidden alias) == --mode vendored" - ); -} - -#[test] -#[serial_test::serial] -fn mode_alias_host_folds_to_redirect() { - assert_eq!(parse_scan(&["--mode", "host"]).mode, Some(ScanMode::Hosted)); - let folded = parse_and_resolve(&["--mode", "host"]).expect("fold ok"); - assert_eq!( - folded.mode, - Some(ScanMode::Hosted), - "--mode host (hidden alias) == --mode hosted" - ); -} - -#[test] -#[serial_test::serial] -fn mode_alias_redirect_folds_to_hosted() { - // `redirect` is the legacy FLAG spelling (`--redirect`); the value - // aliases stay symmetric with `--mode vendor`/`--mode host`. - assert_eq!( - parse_scan(&["--mode", "redirect"]).mode, - Some(ScanMode::Hosted) - ); - let folded = parse_and_resolve(&["--mode", "redirect"]).expect("fold ok"); - assert_eq!( - folded.mode, - Some(ScanMode::Hosted), - "--mode redirect (hidden alias) == --mode hosted" - ); - // The alias agrees with its own boolean: redundant, not contradictory. - let folded = parse_and_resolve(&["--mode", "redirect", "--redirect"]).expect("fold ok"); - assert_eq!(folded.mode, Some(ScanMode::Hosted)); -} - -#[test] -#[serial_test::serial] -fn mode_apply_stays_rejected() { - // `apply` is NOT a scan mode name (canonical: `agent`); accepting it - // would mint a fourth spelling nothing else recognizes. Clap must - // reject it at parse time like any unknown value. - let parsed = - with_clean_env(|| Cli::try_parse_from(["socket-patch", "scan", "--mode", "apply"])); - let Err(err) = parsed else { - panic!("--mode apply must be rejected"); - }; - let rendered = err.to_string(); - assert!( - rendered.contains("apply"), - "the error must echo the rejected value: {rendered}" - ); +fn non_canonical_mode_values_are_rejected() { + // Only the three canonical names parse: `apply` was never a mode name, + // and the v3/v4 value aliases `host` / `redirect` / `vendor` were + // removed in v5.0. + for value in ["apply", "host", "redirect", "vendor"] { + let parsed = + with_clean_env(|| Cli::try_parse_from(["socket-patch", "scan", "--mode", value])); + let Err(err) = parsed else { + panic!("--mode {value} must be rejected"); + }; + let rendered = err.to_string(); + assert!( + rendered.contains(value), + "the error must echo the rejected value: {rendered}" + ); + } } +/// The v3/v4 hidden `--redirect` and no-op `--detached` flags were removed +/// in v5.0: clap rejects them like any unknown argument. #[test] #[serial_test::serial] -fn mode_alias_host_with_vendor_boolean_errors_with_canonical_name() { - // The alias resolves to `ScanMode::Hosted` at parse time, so the - // cross-mode contradiction fires exactly as with the canonical - // spelling — and the error message names the canonical mode - // (`cli_name()`), never echoing the alias the user typed. - let mut args = parse_scan(&["--mode", "host", "--vendor"]); - let err = resolve_mode_flags(&mut args).expect_err("cross-mode contradiction"); - assert!( - err.contains("--mode hosted cannot be used with --vendor"), - "error must name the canonical mode (\"hosted\"), got: {err}" - ); +fn removed_legacy_scan_flags_are_rejected() { + for flag in ["--redirect", "--detached"] { + let parsed = with_clean_env(|| Cli::try_parse_from(["socket-patch", "scan", flag])); + let Err(err) = parsed else { + panic!("{flag} must be rejected"); + }; + assert_eq!( + err.kind(), + clap::error::ErrorKind::UnknownArgument, + "{flag}: {err}" + ); + } } #[test] #[serial_test::serial] -fn mode_aliases_hidden_from_help() { +fn mode_help_lists_only_canonical_names() { use clap::CommandFactory; // clap embeds live env values into help as `[env: VAR=value]` at - // command-build/render time; an ambient value containing "host:" or - // "vendor:" (e.g. SOCKET_PROXY_URL=http://localhost:8080) would trip - // the alias-leak asserts below, so the whole build+render runs clean. + // command-build/render time, so the whole build+render runs clean. let (short, long) = with_clean_env(|| { let mut cmd = Cli::command(); let scan = cmd.find_subcommand_mut("scan").expect("scan subcommand"); @@ -904,57 +812,21 @@ fn mode_aliases_hidden_from_help() { }); // Short help (`scan -h`) renders the compact bracketed list. Assert on - // the exact rendered segment — NOT on substring absence, because - // "vendored" contains "vendor" as a substring — so any extra value - // inside the brackets (a leaked alias) breaks the match. + // the exact rendered segment so any extra value inside the brackets + // breaks the match. assert!( short.contains("[possible values: hosted, vendored, agent]"), "scan -h must list exactly the canonical mode names; help was:\n{short}" ); // Long help (`scan --help`) itemizes each possible value with its doc - // comment. The canonical items render as "hosted:" / "vendored:" / - // "agent:"; an alias leaking into the itemized list would render as - // "host:" or "vendor:" (name immediately followed by the colon). + // comment. for canonical in ["hosted:", "vendored:", "agent:"] { assert!( long.contains(canonical), "scan --help must itemize `{canonical}`; help was:\n{long}" ); } - for alias in ["host:", "vendor:", "redirect:"] { - // "host:" is NOT a substring of "hosted:" (the canonical item has - // 'e' after "host"), so any literal hit is a genuine leak. - assert!( - !long.contains(alias), - "hidden alias `{alias}` leaked into scan --help; help was:\n{long}" - ); - } -} - -/// `--detached` is a compatibility no-op (vendored mode is always -/// manifest-free): still parsed, still requiring vendored mode, but hidden -/// from help like the legacy `--redirect` spelling. -#[test] -#[serial_test::serial] -fn detached_flag_is_hidden_from_help() { - use clap::CommandFactory; - let long = with_clean_env(|| { - let mut cmd = Cli::command(); - let scan = cmd.find_subcommand_mut("scan").expect("scan subcommand"); - scan.render_long_help().to_string() - }); - assert!( - !long.contains("--detached"), - "--detached must be hidden from scan --help; help was:\n{long}" - ); - assert!( - long.contains("--prune"), - "control: a documented flag renders; help was:\n{long}" - ); - // Still parsed (compatibility), still folded under vendored mode. - let folded = parse_and_resolve(&["--mode", "vendored", "--detached"]).expect("fold ok"); - assert!(folded.detached); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_scan_silent.rs b/crates/socket-patch-cli/tests/cli_scan_silent.rs index fe3b4456..3a12211a 100644 --- a/crates/socket-patch-cli/tests/cli_scan_silent.rs +++ b/crates/socket-patch-cli/tests/cli_scan_silent.rs @@ -514,7 +514,7 @@ fn scan_silent_vex_failure_keeps_error_output() { } /// The redirect flow's embedded-VEX failure path must keep its error -/// under `--silent` too ("errors only", not "nothing"): `scan --redirect +/// under `--silent` too ("errors only", not "nothing"): `scan --mode hosted /// --vex out.json --silent` with nothing to attest (the reference is /// forbidden, no manifest exists) exits 1, and the failure message must /// still reach stderr: `run_redirect`'s `vex_error` must not sit inside the @@ -544,7 +544,7 @@ async fn scan_redirect_silent_vex_failure_keeps_error_output() { let vex_arg = vex_path.to_str().unwrap().to_string(); let args = |silent: bool| { - let mut v = vec!["--redirect", "--yes"]; + let mut v = vec!["--mode=hosted", "--yes"]; if silent { v.push("--silent"); } 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 index e0b8d253..c0f7616a 100644 --- 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 @@ -83,7 +83,6 @@ async fn rollback_in_process(cwd: &Path, ecosystems: Option>) -> i32 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; diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index d923fc84..a67f2fe7 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -103,7 +103,6 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { ghsa: false, package: false, save_only: true, - one_off: false, all_releases: false, mode: Some(socket_patch_cli::commands::scan::ScanMode::Agent), } @@ -1751,12 +1750,12 @@ async fn human_uuid_paid_via_proxy_prints_upgrade_message() { let tmp = tempfile::tempdir().unwrap(); let uri = mock.uri(); // No --api-token / --org: the scrubbed child env falls back to the - // public proxy seeded via the legacy env var (get_invariants' recipe). + // public proxy seeded via `SOCKET_PROXY_URL` (get_invariants' recipe). let (code, stdout, stderr) = common::run_with_env( tmp.path(), &["get", UUID, "--save-only", "--yes", "--api-url", &uri], &[ - ("SOCKET_PATCH_PROXY_URL", uri.as_str()), + ("SOCKET_PROXY_URL", uri.as_str()), ("SOCKET_TELEMETRY_DISABLED", "1"), ], ); @@ -2862,7 +2861,7 @@ async fn proxy_403_on_uuid_is_paid_required() { tmp.path(), &args, &[ - ("SOCKET_PATCH_PROXY_URL", uri.as_str()), + ("SOCKET_PROXY_URL", uri.as_str()), ("SOCKET_TELEMETRY_DISABLED", "1"), ], ) diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair.rs b/crates/socket-patch-cli/tests/covgap_commands_repair.rs index 4282dd9b..b960ce6f 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair.rs @@ -353,15 +353,17 @@ fn repair_dry_run_preview_truncates_missing_list_after_ten() { } /// The loud orphan-archive removal print — each directory's summary names -/// its own artifact kind (`format_cleanup_result_for` takes the noun). One orphan in `diffs/` and one in `packages/`, each next -/// to the referenced `.tar.gz` that must survive. +/// its own artifact kind (`format_cleanup_result_for` takes the noun). One orphan in `diffs/` next to the +/// referenced `.tar.gz` that must survive, and two legacy archives in +/// `packages/` (one under the referenced uuid) that both go: v5.0 reads no +/// package archives, so the sweep keeps none. #[test] fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { let tmp = tempfile::tempdir().expect("tempdir"); let socket = make_socket_dir(tmp.path()); write_blob(&socket, REFERENCED_HASH, b"kept"); - // Referenced archives keep the default diff mode's missing-check happy - // AND must survive the sweep. + // The referenced diff archive keeps the default diff mode's + // missing-check happy AND must survive the sweep. write_archive(&socket, "diffs", REFERENCED_UUID, b"kept-diff"); write_archive(&socket, "packages", REFERENCED_UUID, b"kept-package"); const ORPHAN_DIFF: &str = "99999999-9999-4999-8999-999999999999"; @@ -387,7 +389,7 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { "the diffs sweep must print its own summary; stdout=\n{stdout}" ); assert!( - stdout.contains("Removed 1 unused package archive (16 B freed)"), + stdout.contains("Removed 2 unused package archives (28 B freed)"), "the packages sweep must print its own summary; stdout=\n{stdout}" ); assert!( @@ -400,7 +402,8 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { stdout.contains("All diff archives are present locally."), "diff mode with the referenced archive present is all-present; stdout=\n{stdout}" ); - // Disk effects: orphans gone, referenced archives intact. + // Disk effects: orphans and legacy archives gone, the referenced diff + // archive intact. assert!( !socket .join("diffs") @@ -419,7 +422,7 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { .join("diffs") .join(format!("{REFERENCED_UUID}.tar.gz")) .exists()); - assert!(socket + assert!(!socket .join("packages") .join(format!("{REFERENCED_UUID}.tar.gz")) .exists()); 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 c11d71b8..47fa7166 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -319,7 +319,7 @@ fn seed_manifest(root: &Path, entries: &[(&str, &str)]) { // resolve_mode_flags — the remaining cross-mode conflict arms // --------------------------------------------------------------------------- // Only the `--mode hosted --vendor` arm is pinned in cli_parse_scan.rs; -// these cover the --redirect / --apply / --sync booleans against a +// these cover the --apply / --sync / --vendor booleans against a // different --mode, plus ScanMode::Agent.cli_name() rendering into the // message. Clap parses each combination fine (no value-dependent conflict // is expressible); the fold is what rejects them. @@ -367,16 +367,6 @@ mod mode_fold { resolve_mode_flags(&mut args).expect_err("cross-mode contradiction must error") } - #[test] - #[serial_test::serial] - fn mode_vendored_with_redirect_boolean_errors() { - let err = fold_err(&["--mode", "vendored", "--redirect"]); - assert!( - err.contains("--mode vendored cannot be used with --redirect"), - "clap-style 'cannot be used with' phrasing naming both spellings: {err}" - ); - } - #[test] #[serial_test::serial] fn mode_vendored_with_apply_boolean_errors() { @@ -2270,22 +2260,6 @@ fn scan_mode_conflict_error_is_capitalized_and_names_no_hidden_flag() { "{stderr:?}" ); assert!(!stderr.contains("--redirect"), "{stderr:?}"); - // Typing the hidden --redirect gets it explained. - let (code, _, stderr) = run_scan(tmp.path(), &["--mode", "agent", "--redirect"]); - assert_eq!(code, 2); - assert!( - stderr.starts_with( - "Error: --mode agent cannot be used with --redirect: the flags select \ - different modes (--redirect means --mode hosted)" - ), - "{stderr:?}" - ); - let (code, _, stderr) = run_scan(tmp.path(), &["--detached"]); - assert_eq!(code, 2); - assert!( - stderr.starts_with("Error: --detached requires vendored mode"), - "{stderr:?}" - ); } /// A selection the manifest already records at the same uuid is not diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs index 763b870c..d2c5a377 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs @@ -252,7 +252,7 @@ exit 0 "#; /// Stage 2b (`--network none`, the fresh checkout stage 2 just installed): -/// MANIFEST-LESS VEX, the depscan / `vendor --detached` shape. Shared by +/// MANIFEST-LESS VEX, the depscan shape. Shared by /// both flavors. /// /// 1. `.socket/manifest.json` deleted → `vex --offline` attests from the diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index ae24a544..ea0386e3 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -960,40 +960,27 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] -async fn native_binary_scan_vendored_and_detached() { - for detached in [false, true] { - let Some(fixture) = Fixture::new("direct") else { - return; - }; - let server = MockServer::start().await; - mock_api(&server, &fixture, "minimist").await; - let flags: &[&str] = if detached { &["--detached"] } else { &[] }; - let result = scan(&fixture.project, &server, "vendored", flags); - assert_eq!( - result["vendor"]["summary"]["applied"], 1, - "scan vendored detached={detached}: {result}" - ); - // Vendored mode is manifest-free either way: `--detached` is an - // accepted no-op. - assert!( - !fixture.project.join(".socket/manifest.json").exists(), - "vendored scan must not write a manifest (detached={detached})" - ); - fixture.frozen("scan-vendored", &fixture.patched, "minimist"); - fixture.manifestless_vex( - if detached { - "scan-vendored-detached" - } else { - "scan-vendored" - }, - bun_vex::BunMode::Vendored, - &server.uri(), - ); - let result = cli(&fixture.project, &["vendor", "--revert"]); - assert_eq!(result["summary"]["removed"], 1, "vendor revert: {result}"); - fixture.pristine(); - fixture.frozen("reverted", &fixture.original, "minimist"); - } +async fn native_binary_scan_vendored() { + let Some(fixture) = Fixture::new("direct") else { + return; + }; + let server = MockServer::start().await; + mock_api(&server, &fixture, "minimist").await; + let result = scan(&fixture.project, &server, "vendored", &[]); + assert_eq!( + result["vendor"]["summary"]["applied"], 1, + "scan vendored: {result}" + ); + assert!( + !fixture.project.join(".socket/manifest.json").exists(), + "vendored scan must not write a manifest" + ); + fixture.frozen("scan-vendored", &fixture.patched, "minimist"); + fixture.manifestless_vex("scan-vendored", bun_vex::BunMode::Vendored, &server.uri()); + let result = cli(&fixture.project, &["vendor", "--revert"]); + assert_eq!(result["summary"]["removed"], 1, "vendor revert: {result}"); + fixture.pristine(); + fixture.frozen("reverted", &fixture.original, "minimist"); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index 65581526..3978aff9 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -66,7 +66,6 @@ async fn run(args: &[&str], cwd: &Path, proxy_url: &str) -> Output { .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") - .env_remove("SOCKET_PATCH_PROXY_URL") .env_remove("SOCKET_BATCH_SIZE") .output() .expect("Failed to run socket-patch binary") diff --git a/crates/socket-patch-cli/tests/e2e_composer.rs b/crates/socket-patch-cli/tests/e2e_composer.rs index 92810ff8..8f8081df 100644 --- a/crates/socket-patch-cli/tests/e2e_composer.rs +++ b/crates/socket-patch-cli/tests/e2e_composer.rs @@ -61,7 +61,6 @@ async fn run(args: &[&str], cwd: &std::path::Path, proxy_url: &str) -> Output { .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") - .env_remove("SOCKET_PATCH_PROXY_URL") .env_remove("SOCKET_BATCH_SIZE") .output() .expect("Failed to run socket-patch binary") diff --git a/crates/socket-patch-cli/tests/e2e_golang.rs b/crates/socket-patch-cli/tests/e2e_golang.rs index 165c33c3..6a560ae2 100644 --- a/crates/socket-patch-cli/tests/e2e_golang.rs +++ b/crates/socket-patch-cli/tests/e2e_golang.rs @@ -96,7 +96,6 @@ async fn run(args: &[&str], cwd: &Path, gomodcache: &Path, api_url: &str) -> Out .env_remove("GOPATH") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") - .env_remove("SOCKET_PATCH_PROXY_URL") .env_remove("SOCKET_BATCH_SIZE") .output() .expect("Failed to run socket-patch binary") 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 225bba3c..59a96a70 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_hosted_build.rs @@ -394,7 +394,6 @@ fn day2_machine_builds_patched_module_from_committed_files_alone() { token: String::new(), patch_uuid: UUID.into(), artifact_url: format!("{proxy_url}/{smod}/@v/{SVER}.zip"), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "goproxy".into(), index_url: proxy_url.clone(), diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index ddab397e..be8b9b90 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -293,15 +293,6 @@ fn has_command(cmd: &str) -> bool { probe_cmd.status().map(|s| s.success()).unwrap_or(false) } -/// The three legacy `SOCKET_PATCH_*` names still honored at runtime via -/// `socket_patch_core::utils::env_compat` — not in the clap-bound lists, so they need -/// scrubbing separately. -const LEGACY_ENV_VARS: &[&str] = &[ - "SOCKET_PATCH_PROXY_URL", - "SOCKET_PATCH_DEBUG", - "SOCKET_PATCH_TELEMETRY_DISABLED", -]; - /// Run the CLI with a hermetically pinned environment. /// /// The scrub matters more here than in any offline suite. An ambient @@ -324,11 +315,7 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { .env("SOCKET_API_TOKEN", "hostile-seed-must-be-scrubbed") .env("SOCKET_PROXY_URL", "http://127.0.0.1:1/hostile") .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json"); - for var in GLOBAL_ARG_ENV_VARS - .iter() - .chain(LOCAL_ARG_ENV_VARS) - .chain(LEGACY_ENV_VARS) - { + for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS) { cmd.env_remove(var); } let out: Output = cmd.output().expect("failed to execute socket-patch binary"); diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 006c8a1f..6f447440 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -70,7 +70,6 @@ async fn run(args: &[&str], cwd: &Path, m2_repo: &Path, proxy_url: &str) -> Outp .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") - .env_remove("SOCKET_PATCH_PROXY_URL") .env_remove("SOCKET_BATCH_SIZE") .output() .expect("Failed to run socket-patch binary") diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index e9bd1551..ce4cc499 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -67,7 +67,6 @@ async fn run(args: &[&str], cwd: &Path, nuget_packages: &Path, proxy_url: &str) .env_remove("SOCKET_API_URL") .env_remove("SOCKET_OFFLINE") .env_remove("SOCKET_PROXY_URL") - .env_remove("SOCKET_PATCH_PROXY_URL") .env_remove("SOCKET_BATCH_SIZE") .output() .expect("Failed to run socket-patch binary") diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index ffa19f60..4531d117 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -61,21 +61,11 @@ fn git_sha256_file(path: &Path) -> String { git_sha256(&content) } -/// The three legacy `SOCKET_PATCH_*` names still honored at runtime via -/// `socket_patch_core::utils::env_compat` — not in the clap-bound lists, so they -/// need scrubbing separately. -const LEGACY_ENV_VARS: &[&str] = &[ - "SOCKET_PATCH_PROXY_URL", - "SOCKET_PATCH_DEBUG", - "SOCKET_PATCH_TELEMETRY_DISABLED", -]; - /// Run the CLI binary with the given args, setting `cwd` as the working dir. /// /// The environment is pinned hard: every env var the CLI binds (the canonical -/// `GLOBAL_ARG_ENV_VARS` / `LOCAL_ARG_ENV_VARS` lists plus [`LEGACY_ENV_VARS`]) -/// is scrubbed so ambient developer/CI configuration can't change the -/// lifecycle under test. Scrubbing `SOCKET_API_TOKEN` also forces the +/// `GLOBAL_ARG_ENV_VARS` / `LOCAL_ARG_ENV_VARS` lists) is scrubbed so ambient +/// developer/CI configuration can't change the lifecycle under test. Scrubbing `SOCKET_API_TOKEN` also forces the /// public-proxy (free-tier) path this suite relies on. The stakes are higher /// here than in the offline suites: an inherited `SOCKET_GLOBAL=true` takes /// `get`/`apply` out of the temp venv and patches the host's real @@ -94,11 +84,7 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { .env("SOCKET_DRY_RUN", "true") .env("SOCKET_SAVE_ONLY", "true") .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json"); - for var in GLOBAL_ARG_ENV_VARS - .iter() - .chain(LOCAL_ARG_ENV_VARS) - .chain(LEGACY_ENV_VARS) - { + for var in GLOBAL_ARG_ENV_VARS.iter().chain(LOCAL_ARG_ENV_VARS) { cmd.env_remove(var); } let out: Output = cmd.output().expect("failed to execute socket-patch binary"); 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 3df2d5bb..62345cce 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_cargo_build.rs @@ -512,8 +512,7 @@ async fn redirect_scanned_project( .await; // The driver invocation. Scan: `--mode hosted --vex` — the three-file - // rewrite + the in-run (unverified) attestation (`--mode hosted` is the - // documented spelling of `--redirect`). Get: `get --mode hosted` + // rewrite + the in-run (unverified) attestation. Get: `get --mode hosted` // — same engine, get's confirm gate auto-accepted by --json/--yes, no // --vex (get has none). let server_uri = server.uri(); 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 c96762a2..273d7d58 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_composer_build.rs @@ -1,7 +1,7 @@ //! Real-composer HOSTED (redirect) capstone — the composer twin of //! `e2e_redirect_npm_build.rs`, ending in the manifest-less VEX legs. //! -//! `scan --redirect` never lands patched bytes in the repo: it rewrites +//! `scan --mode hosted` never lands patched bytes in the repo: it rewrites //! composer.lock so the patched package's `dist` RESOLVES from Socket's //! hosted patch archive (here: a wiremock standing in for patch.socket.dev) //! and pins the archive's sha1 in `dist.shasum`. This proves every link of @@ -15,7 +15,7 @@ //! marker comment appended to `src/LoggerInterface.php`, wrapped in a //! GitHub-zipball-style top-level dir) and serve it from wiremock with //! the discovery / reference / view API mocks. -//! 3. `scan --redirect --json --vex …` (or its `get --mode hosted` +//! 3. `scan --mode hosted --json --vex …` (or its `get --mode hosted` //! twin): composer.lock's psr/log `dist` now points at the wiremock //! 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 @@ -89,7 +89,7 @@ const FILE_KEY: &str = "src/LoggerInterface.php"; /// Which CLI front door performs the redirect (step 3). #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RedirectCli { - /// `scan --redirect --json --yes --vex …` (embedded VEX asserted). + /// `scan --mode hosted --json --yes --vex …` (embedded VEX asserted). ScanRedirectVex, /// `get --mode hosted --json --yes` (get has no `--vex`). GetUuidHosted, @@ -331,7 +331,7 @@ async fn redirected_project( let mut argv: Vec<&str> = match cli { RedirectCli::ScanRedirectVex => vec![ "scan", - "--redirect", + "--mode=hosted", "--vex", "out.vex.json", "--vex-product", 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 dce12a6c..fb7a7332 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs @@ -1,6 +1,6 @@ //! Real-install redirect→VEX capstone e2e for npm — the full-chain proof. //! -//! `scan --redirect` never lands patched bytes in the repo: it rewrites the +//! `scan --mode hosted` 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). v5 keeps no //! redirect ledger: the lockfile pin IS the hosted state. This test proves @@ -11,7 +11,7 @@ //! 2. Build a PATCHED tarball from the actually-installed bytes (marker //! 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 +//! 3. `scan --mode hosted --json --vex …` (the real binary): the lockfile now //! 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`). @@ -158,7 +158,7 @@ struct RedirectFixture { /// selection must produce the identical on-disk redirect. #[derive(Clone, Copy, PartialEq, Debug)] enum RedirectCli { - /// `scan --redirect --json --yes --vex …` (embedded VEX asserted). + /// `scan --mode hosted --json --yes --vex …` (embedded VEX asserted). ScanRedirectVex, /// `get --mode hosted --json --yes` — get has no `--vex`. GetUuidHosted, @@ -365,7 +365,7 @@ async fn redirect_scanned_project( .await; } - // The redirect invocation itself: `scan --redirect --vex` (the original + // The redirect invocation itself: `scan --mode hosted --vex` (the original // capstone, in-run unverified attestation included) or one of the // `get … --mode hosted` twins (get has no --vex). let uri = server.uri(); @@ -373,7 +373,7 @@ async fn redirect_scanned_project( let argv: Vec<&str> = match cli { RedirectCli::ScanRedirectVex => vec![ "scan", - "--redirect", + "--mode=hosted", "--json", "--yes", "--cwd", diff --git a/crates/socket-patch-cli/tests/e2e_redirect_uv_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_uv_build.rs index 3979b4d4..0d691203 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_uv_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_uv_build.rs @@ -1,5 +1,5 @@ #![cfg(unix)] -//! Real-uv capstones for HOSTED mode (`scan --redirect`), ending in +//! Real-uv capstones for HOSTED mode (`scan --mode hosted`), ending in //! manifest-less VEX — the hermetic twin of the production //! `e2e_hosted_production::pypi_uv_lock_hosted_install_proof` leg, for every //! uv lock shape: @@ -19,12 +19,12 @@ //! pylock.toml` and `pip lock`, installed by `uv pip sync`. //! //! Each lane: real uv builds and installs the pristine project from PyPI → -//! `scan --redirect --vex` against a wiremock patch API (which also serves +//! `scan --mode hosted --vex` against a wiremock patch API (which also serves //! the patched wheel at its hosted url) → a fresh checkout of ONLY the //! committable files installs with uv from an EMPTY cache, fetching the //! wheel from the mock and checking its pin, and imports the patched bytes → //! manifest-less VEX: attested with/without ledgers, `record_unavailable` -//! offline with zero requests, embedded `apply --vex` / `scan --redirect +//! offline with zero requests, embedded `apply --vex` / `scan --mode hosted //! --vex`, NOT attested once the wiring is reverted (ledgers left behind, //! `--no-verify` too) → `rollback` restores the files byte for byte. The //! driver is `vex_e2e_common/uv.rs`. diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index 6119c3c5..2904084c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -38,7 +38,7 @@ //! 7. **Revert proof**: `vendor --revert` restores composer.lock //! byte-for-byte and removes `.socket/vendor/` entirely. //! -//! A third twin drives `scan --vendor --detached --vex` (the depscan-style +//! A third twin drives `scan --vendor --vex` (the depscan-style //! front door: batch discovery → vendored copy + lock wiring, NO manifest, //! embedded VEX in the same run) against the same mocked API, then the same //! fresh-checkout install and manifest-less VEX legs. @@ -935,7 +935,7 @@ async fn composer_get_uuid_vendored_fresh_checkout_install() { }); } -/// `scan --vendor --detached --vex` twin: batch discovery over the REAL +/// `scan --vendor --vex` twin: batch discovery over the REAL /// install → the vendored copy + composer.lock wiring with NO manifest /// (detached), the in-run embedded VEX attesting `(vendored)`, then the same /// fresh-checkout install and manifest-less VEX legs. @@ -972,7 +972,6 @@ async fn composer_scan_vendor_detached_vex_fresh_checkout_install() { &[ "scan", "--vendor", - "--detached", "--vendor-source", "build", "--vex", @@ -993,7 +992,7 @@ async fn composer_scan_vendor_detached_vex_fresh_checkout_install() { ); assert_eq!( code, 0, - "scan --vendor --detached --vex failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + "scan --vendor --vex failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env = parse_envelope(&stdout); assert_eq!(env["vex"]["statements"], 1, "in-run vex block: {env}"); @@ -1002,7 +1001,7 @@ async fn composer_scan_vendor_detached_vex_fresh_checkout_install() { assert_attested(&doc, &purl, UUID, Marker::Vendored, &[(GHSA, &[VEX_CVE])]); assert!( !proj.join(".socket/manifest.json").exists(), - "--detached must not write a manifest: {env}" + "scan --vendor must not write a manifest: {env}" ); let copy_rel = format!(".socket/vendor/composer/{UUID}/{DEP}@{version}"); let entry = lock_entry(&lock_path, DEP); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index 74b86664..0a2e550d 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -252,8 +252,7 @@ struct Vendored<'a> { pristine_lock: &'a [u8], } -/// Manifest-less VEX over a vendored checkout, the depscan / `vendor -/// --detached` shape: +/// Manifest-less VEX over a vendored checkout, the depscan shape: /// /// 1. `.socket/manifest.json` deleted: `vex --offline` attests /// `(vendored)` from the lock's `PATH` wiring + the vendor ledger's diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index 157a8df2..753bbcb2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -31,8 +31,8 @@ //! `vendor --vex`. //! //! The detached twin (`yarn_classic_detached_scan_vendored_…`) produces the -//! state with `scan --mode vendored` (plus the kept-for-compat no-op -//! `--detached`) against a wiremock Socket API instead — the manifest-free +//! state with `scan --mode vendored` against a wiremock Socket API +//! instead — the manifest-free //! shape every vendored run has — and runs the //! same fresh-checkout install + manifest-less VEX matrix (plus the embedded //! re-scan). @@ -622,7 +622,7 @@ fn sha512_sri_b64(bytes: &[u8]) -> String { // ── detached vendoring from the patch API (the manifest-less shape) ──── -/// `scan --mode vendored` (with the legacy no-op `--detached`) against a +/// `scan --mode vendored` against a /// wiremock Socket API: vendored mode NEVER writes `.socket/manifest.json` (the vendor /// ledger embeds the record) — the shape a depscan-opened PR commits. The /// scan discovers the dep (batch search), the record (with `blobContent`) @@ -732,7 +732,6 @@ fn yarn_classic_detached_scan_vendored_fresh_checkout_manifestless_vex() { "scan", "--mode", "vendored", - "--detached", "--json", "--yes", "--cwd", @@ -749,7 +748,7 @@ fn yarn_classic_detached_scan_vendored_fresh_checkout_manifestless_vex() { ); assert_eq!( code, 0, - "scan --mode vendored --detached failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + "scan --mode vendored failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); let env = parse_envelope(&stdout); assert_eq!(env["status"], "success", "envelope: {env}"); @@ -759,7 +758,7 @@ fn yarn_classic_detached_scan_vendored_fresh_checkout_manifestless_vex() { ); assert!( !proj.join(".socket/manifest.json").exists(), - "--detached must never write the manifest" + "vendored mode must never write the manifest" ); let tgz_rel = format!(".socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); assert!(proj.join(&tgz_rel).is_file(), "vendored tarball missing"); @@ -833,13 +832,12 @@ fn yarn_classic_detached_scan_vendored_fresh_checkout_manifestless_vex() { ("vendor --vex", via_vendor()), // The command that produced the state, re-run manifest-less. ( - "scan --mode vendored --detached --vex", + "scan --mode vendored --vex", Box::new(|run: vex_e2e_common::VexRun| { let mut run = run .via(vex_e2e_common::VexVia::Scan) .arg("--mode") .arg("vendored") - .arg("--detached") .arg("--vendor-source") .arg("build") .arg("--yes"); diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 2afa3bd1..46cdb71c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -1259,14 +1259,13 @@ fn yarn_classic_vendored_install_proof() { ("apply --vex", yarn_classic_vex::via_apply()), ("vendor --vex", yarn_classic_vex::via_vendor()), // The command the leg itself ran, re-run manifest-less - // (`--detached`: no manifest writes — the shape under test). + // (vendored mode writes no manifest — the shape under test). ( - "scan --mode vendored --detached --vex", + "scan --mode vendored --vex", Box::new(|run: vex_e2e_common::VexRun| { run.via(vex_e2e_common::VexVia::Scan) .arg("--mode") .arg("vendored") - .arg("--detached") .arg("--yes") }), ), 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 278cae19..3e6e3eff 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs @@ -14,7 +14,7 @@ //! hosted rewriter reads the exact pin from the declaration); //! 2. the synthetic patch appends a marker to `six.py`; a wiremock Socket //! API serves discovery, the grant, the view and the patched wheel; -//! 3. hosted: `scan --redirect --vex`; vendored: `scan --vendor +//! 3. hosted: `scan --mode hosted --vex`; vendored: `scan --vendor //! --vendor-source build --vex` — the same-run VEX attests, and the //! declaration becomes `six @ #sha256=…` / //! `six @ {root:uri}/.socket/vendor/pypi//#sha256=…`; @@ -200,7 +200,7 @@ fn hatch() -> Option { fn scan_mode_args(mode: Mode) -> Vec<&'static str> { match mode { - Mode::Hosted => vec!["--redirect"], + Mode::Hosted => vec!["--mode=hosted"], Mode::Vendored => vec!["--vendor", "--vendor-source", "build"], } } 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 4e79d00c..cea8568a 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pdm.rs @@ -11,7 +11,7 @@ //! 2. the synthetic patch appends a marker to the INSTALLED `six.py`; a //! wiremock Socket API serves discovery, the grant, the view and the //! patched wheel itself; -//! 3. hosted: `scan --redirect --vex` (same-run VEX attests); vendored: +//! 3. hosted: `scan --mode hosted --vex` (same-run VEX attests); vendored: //! `scan --vendor --vendor-source build --vex`; //! 4. a FRESH checkout of only the committable files (pyproject, pdm.lock, //! `.socket/` minus the manifest) is installed by the real `pdm sync` — @@ -25,7 +25,7 @@ //! `record_unavailable` with zero requests, and the lock reverted to the //! 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. +//! manifest-less `scan --mode hosted|--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 @@ -310,7 +310,7 @@ fn patched_of(pristine: &[u8]) -> Vec { fn scan_mode_args(mode: Mode) -> Vec<&'static str> { match mode { - Mode::Hosted => vec!["--redirect"], + Mode::Hosted => vec!["--mode=hosted"], Mode::Vendored => vec!["--vendor", "--vendor-source", "build"], } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pip.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pip.rs index 0b60020c..5a6d336c 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pip.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pip.rs @@ -12,7 +12,7 @@ //! //! 1. `python -m pip install -r requirements.txt` with the real pip major //! from PyPI (the pristine install); -//! 2. `socket-patch scan --redirect --vex` (hosted, on the lock-only +//! 2. `socket-patch scan --mode hosted --vex` (hosted, on the lock-only //! checkout) / `scan --vendor --vendor-source build --vex` (vendored, //! from the pristine install) against a wiremock Socket API that also //! serves the patched wheel — the same-run document attests; @@ -24,7 +24,7 @@ //! 4. the manifest-less VEX matrix (`vex_pipenv_pip_real`): manifest //! deleted, ledgers deleted, `--offline` (zero requests), requirements //! reverted to the registry pin (also `--no-verify`), `apply --vex`; -//! plus the embedded `scan --redirect --vex` / `scan --vendor --vex` +//! plus the embedded `scan --mode hosted --vex` / `scan --vendor --vex` //! re-run on the manifest-less checkout. //! //! `#[ignore]`d (network: PyPI) — run with `--ignored`; CI sets diff --git a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs index f4e4d57d..d32bb582 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/pipenv.rs @@ -5,7 +5,7 @@ //! //! 1. `pipenv install six==1.16.0` from PyPI (in-project venv) — the native //! Pipfile.lock that release writes; -//! 2. `socket-patch scan --redirect --vex` (hosted, on the lock-only +//! 2. `socket-patch scan --mode hosted --vex` (hosted, on the lock-only //! checkout: the CI shape) / `scan --vendor --vendor-source build --vex` //! (vendored, from the pristine install) against a wiremock Socket API //! that also serves the patched wheel — the same-run document attests; @@ -17,7 +17,7 @@ //! 4. the manifest-less VEX matrix (`vex_pipenv_pip_real`): manifest //! deleted, ledgers deleted, `--offline` (zero requests), lock reverted //! to the registry (also `--no-verify`), `apply --vex`; plus the -//! embedded `scan --redirect --vex` / `scan --vendor --vex` re-run on the +//! embedded `scan --mode hosted --vex` / `scan --vendor --vex` re-run on the //! manifest-less checkout. //! //! Versions: `SOCKET_PATCH_PIPENV_E2E_VERSIONS` (space / comma separated), 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 bd6d2b4a..e3834829 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_build/poetry.rs @@ -4,7 +4,7 @@ //! 1. a real project on `six==1.16.0`, locked by the real `poetry lock` //! (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 +//! hosted = `scan --mode hosted --vex` on the lock-only checkout (the lock is //! 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 @@ -765,14 +765,14 @@ fn poetry_hosted_fresh_install_then_manifestless_vex() { &service, &[ "scan", - "--redirect", + "--mode=hosted", "--vex", embedded.to_str().unwrap(), "--vex-product", PRODUCT, ], ); - assert_eq!(code, Some(0), "scan --redirect: {env}"); + assert_eq!(code, Some(0), "scan --mode hosted: {env}"); assert_eq!(env["redirect"]["redirected"], 1, "{env}"); let lock = std::fs::read_to_string(project.join("poetry.lock")).unwrap(); let sha = hex::encode(Sha256::digest(&wheel)); 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 67ffce61..8b7d6d6b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs @@ -221,7 +221,6 @@ fn binary_lock(release: &str, wiring: &Wiring) -> Vec { token: TOKEN.into(), patch_uuid: uuid, artifact_url, - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(sri), @@ -779,14 +778,14 @@ fn scan_hosted(cwd: &Path, flavor: Flavor, api: &Api) -> Vec { /// `scan --mode vendored --vendor-source build --vex` against an installed /// pristine minimist: the real engine builds + commits the tarball, rewires /// the lock, and the in-run VEX attests `(vendored)`. Vendored mode is -/// manifest-free, so neither spelling writes a manifest (`--detached` is a -/// hidden compatibility no-op). Returns the pre-scan lock bytes. -fn scan_vendored(cwd: &Path, flavor: Flavor, api: &Api, detached: bool) -> Vec { +/// manifest-free, so no manifest is written. Returns the pre-scan lock +/// bytes. +fn scan_vendored(cwd: &Path, flavor: Flavor, api: &Api) -> Vec { write_lock(cwd, flavor, &Wiring::Registry); install(cwd, PRISTINE); let registry_lock = std::fs::read(cwd.join(flavor.lock_file())).unwrap(); api.serve_scan(tgz(PATCHED)); - let mut args = vec![ + let args = vec![ "scan", "--mode", "vendored", @@ -798,11 +797,8 @@ fn scan_vendored(cwd: &Path, flavor: Flavor, api: &Api, detached: bool) -> Vec PatchApi { PatchApi::start(vec![(UUID.into(), view(UUID, PURL))]) } -/// `.socket/vendor/redirect-state.json` as `scan --redirect` writes it for a +/// `.socket/vendor/redirect-state.json` as `scan --mode hosted` writes it for a /// composer redirect: the record plus the lock edit. fn write_redirect_ledger(cwd: &Path, key: &str, rec: PatchRecord) { let mut state = RedirectState::new(); 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 f2678626..7de78ae5 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pdm.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pdm.rs @@ -27,8 +27,8 @@ //! root-escaping path and mismatched records never attest; g) hosted //! installed-tree states (not installed → pin, patched → hashed, pristine → //! `not_applied`), pinless hosted needs an install, vendored over a pristine -//! venv warns; plus the embedded `scan --redirect|--vendor --vex`, -//! `scan --vendor --detached --vex`, `apply --vex` and `vendor --vex`. +//! venv warns; plus the embedded `scan --mode hosted|--vendor --vex`, +//! `apply --vex` and `vendor --vex`. //! //! The real-PDM counterpart (real `pdm lock` / `pdm sync`, per PDM release) //! is `e2e_vex_build/pdm.rs`. 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 f076a160..dc943805 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/pipenv.rs @@ -21,8 +21,8 @@ //! record-mismatched references never attest; hosted not-installed attests //! from the pin, a pristine install is `not_applied`, pinless needs an //! install; a vendored wheel over a pristine venv warns; and the embedded -//! forms (`scan --redirect --vex` / `scan --vendor --vex` re-runs, -//! `scan --vendor --detached --vex`, `apply --vex`, `vendor --vex`). +//! forms (`scan --mode hosted --vex` / `scan --vendor --vex` re-runs, +//! `apply --vex`, `vendor --vex`). //! //! Pipenv-specific cells below: a relock that re-serializes AROUND our //! reference (Pipenv 2023+ restores `version` / `index` / registry 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 8601113e..7142c84f 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs @@ -11,7 +11,7 @@ //! fixture cells run the core `rewrite_poetry_lock` over the committed native //! locks of every Poetry release (`socket-patch-core/tests/fixtures/poetry/ //! <0.12.17..2.4.3>/`: lock formats "0" / "1.0" / "1.1" / "2.0" / "2.1"), and -//! the writer-driven cells run the real `scan --redirect --vex` / `scan +//! the writer-driven cells run the real `scan --mode hosted --vex` / `scan //! --vendor --vex` / `apply --vex` / `vendor --vex` binaries against a //! wiremock patch API. The package is renamed to a made-up `vexfixture`, so //! no interpreter's global site-packages on the test host can hold a copy. @@ -1240,7 +1240,7 @@ fn strip_pin(lock: &str, pin: &str) -> String { } // ════════════════════════════════════════════════════════════════════════ -// Writer-driven: the REAL `scan --redirect --vex` / `scan --vendor --vex` +// Writer-driven: the REAL `scan --mode hosted --vex` / `scan --vendor --vex` // write the wiring and the ledgers; then the manifest (and the ledgers) are // deleted and standalone + embedded VEX must still attest — and stop // attesting once the real revert unwinds the wiring with the ledger left @@ -1428,7 +1428,7 @@ fn embedded(p: &Proj, api: &PatchApi, via: VexVia, offline: bool, extra: &[&str] run_vex(&binary(), &p.root, &run) } -/// `scan --redirect --vex` on a lock-only checkout (nothing installed) +/// `scan --mode hosted --vex` on a lock-only checkout (nothing installed) /// 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 @@ -1444,7 +1444,7 @@ fn embedded(p: &Proj, api: &PatchApi, via: VexVia, offline: bool, extra: &[&str] fn scan_redirect_wiring_attests_without_manifest_or_ledger() { for release in WRITER_RELEASES { for self_hosted in [false, true] { - let what = format!("poetry {release} scan --redirect self_hosted={self_hosted}"); + let what = format!("poetry {release} scan --mode hosted self_hosted={self_hosted}"); let p = Proj::new(); p.write_files(&native_files(release)); p.empty_venv(); @@ -1466,7 +1466,7 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { &scan, &[ "scan", - "--redirect", + "--mode=hosted", "--vex", embedded_doc.to_str().unwrap(), "--vex-product", @@ -1561,104 +1561,69 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { /// a. without the ledger, online attests (the committed wheel is hashed); /// d. `vendor --revert` unwinds the wiring; with the ledger put back (and /// the artifact re-committed) it is `vendor_unwired`, `--no-verify` too. -/// The `--detached` twin (a hidden compatibility no-op) behaves the same. #[test] fn scan_vendor_wiring_attests_without_manifest_or_ledger() { - for detached in [false, true] { - for release in WRITER_RELEASES { - let what = format!("poetry {release} scan --vendor detached={detached}"); - let p = Proj::new(); - p.write_files(&native_files(release)); - p.install(PRISTINE); - let scan = ScanApi::start( - VENDORED_UUID, - Some(&hosted_url(VENDORED_UUID)), - &build_wheel(PATCHED), - ); - let embedded_doc = p.root.join("embedded.vex.json"); - let mut args = vec![ - "scan", - "--vendor", - "--vendor-source", - "build", - "--vex", - embedded_doc.to_str().unwrap(), - "--vex-product", - PRODUCT, - ]; - if detached { - args.push("--detached"); - } - let (code, env) = run_authed(&p, &scan, &args); - assert_eq!(code, Some(0), "{what}: {env}"); - assert_embedded_doc(&what, &embedded_doc, Mode::Vendored); - let lock = p.read("poetry.lock"); - assert!( - lock.contains(&format!("url = \"{}\"", vendored_rel(VENDORED_UUID))), - "{what}: lock not wired:\n{lock}" - ); - assert!(lock.contains("type = \"file\""), "{what}:\n{lock}"); - assert!( - !p.exists(".socket/manifest.json"), - "{what}: vendored mode is manifest-free (--detached is a no-op)" - ); - // Vendoring never patches the installed tree. - assert_eq!(std::fs::read(p.site().join(MODULE)).unwrap(), PRISTINE); - - let api = api_for(Mode::Vendored); - // A legacy (pre-5.0) checkout also carries the manifest record - // beside the ledger: same uuid, so it attests the same way. - assert!(vex_e2e_common::seed_legacy_manifest(&p.root) > 0, "{what}"); - let out = vex(&p, &api, true, &[]); - assert_attested( - &format!("{what} legacy manifest"), - &out, - Mode::Vendored, - VENDORED_UUID, - ); - vex_e2e_common::strip_manifest(&p.root); - // c. the ledger alone — standalone and both embedded commands. - let out = vex(&p, &api, true, &[]); - assert_attested( - &format!("{what} ledger"), - &out, - Mode::Vendored, - VENDORED_UUID, - ); - for via in [VexVia::Vendor, VexVia::Apply] { - let out = embedded(&p, &api, via, true, &[]); - assert_eq!(out.code, Some(0), "{what} {via:?} --vex ledger: {out}"); - assert_eq!(out.envelope["status"], "noManifest", "{what}: {out}"); - vex_e2e_common::assert_attested( - out.doc(), - &purl(), - VENDORED_UUID, - Marker::Vendored, - &[(GHSA, &[CVE])], - ); - } - api.assert_no_requests(); + for release in WRITER_RELEASES { + let what = format!("poetry {release} scan --vendor"); + let p = Proj::new(); + p.write_files(&native_files(release)); + p.install(PRISTINE); + let scan = ScanApi::start( + VENDORED_UUID, + Some(&hosted_url(VENDORED_UUID)), + &build_wheel(PATCHED), + ); + let embedded_doc = p.root.join("embedded.vex.json"); + let args = vec![ + "scan", + "--vendor", + "--vendor-source", + "build", + "--vex", + embedded_doc.to_str().unwrap(), + "--vex-product", + PRODUCT, + ]; + let (code, env) = run_authed(&p, &scan, &args); + assert_eq!(code, Some(0), "{what}: {env}"); + assert_embedded_doc(&what, &embedded_doc, Mode::Vendored); + let lock = p.read("poetry.lock"); + assert!( + lock.contains(&format!("url = \"{}\"", vendored_rel(VENDORED_UUID))), + "{what}: lock not wired:\n{lock}" + ); + assert!(lock.contains("type = \"file\""), "{what}:\n{lock}"); + assert!( + !p.exists(".socket/manifest.json"), + "{what}: vendored mode is manifest-free" + ); + // Vendoring never patches the installed tree. + assert_eq!(std::fs::read(p.site().join(MODULE)).unwrap(), PRISTINE); - // b / a. no ledger. - let ledger_path = p.root.join(".socket/vendor/state.json"); - let ledger = std::fs::read(&ledger_path).unwrap(); - strip_ledgers(&p); - let out = vex(&p, &api, true, &[]); - assert_omitted( - &format!("{what} no ledger offline"), - &out, - "record_unavailable", - ); - api.assert_no_requests(); - let out = vex(&p, &api, false, &[]); - assert_attested( - &format!("{what} no ledger online"), - &out, - Mode::Vendored, - VENDORED_UUID, - ); - let out = embedded(&p, &api, VexVia::Vendor, false, &[]); - assert_eq!(out.code, Some(0), "{what} vendor --vex online: {out}"); + let api = api_for(Mode::Vendored); + // A legacy (pre-5.0) checkout also carries the manifest record + // beside the ledger: same uuid, so it attests the same way. + assert!(vex_e2e_common::seed_legacy_manifest(&p.root) > 0, "{what}"); + let out = vex(&p, &api, true, &[]); + assert_attested( + &format!("{what} legacy manifest"), + &out, + Mode::Vendored, + VENDORED_UUID, + ); + vex_e2e_common::strip_manifest(&p.root); + // c. the ledger alone — standalone and both embedded commands. + let out = vex(&p, &api, true, &[]); + assert_attested( + &format!("{what} ledger"), + &out, + Mode::Vendored, + VENDORED_UUID, + ); + for via in [VexVia::Vendor, VexVia::Apply] { + let out = embedded(&p, &api, via, true, &[]); + assert_eq!(out.code, Some(0), "{what} {via:?} --vex ledger: {out}"); + assert_eq!(out.envelope["status"], "noManifest", "{what}: {out}"); vex_e2e_common::assert_attested( out.doc(), &purl(), @@ -1666,41 +1631,70 @@ fn scan_vendor_wiring_attests_without_manifest_or_ledger() { Marker::Vendored, &[(GHSA, &[CVE])], ); - assert_no_manifest_written(&p, &what); + } + api.assert_no_requests(); - // d. the real revert, then the ledger + artifact put back. - std::fs::write(&ledger_path, &ledger).unwrap(); - let artifact_dir = p.root.join(format!(".socket/vendor/pypi/{VENDORED_UUID}")); - let artifacts: Vec<(PathBuf, Vec)> = std::fs::read_dir(&artifact_dir) - .unwrap() - .flatten() - .map(|e| (e.path(), std::fs::read(e.path()).unwrap())) - .collect(); - let (code, env) = run_authed(&p, &scan, &["vendor", "--revert"]); - assert_eq!(code, Some(0), "{what} revert: {env}"); - for (name, text) in native_files(release) { - assert_eq!(p.read(name), text, "{what}: revert restores {name}"); - } - std::fs::create_dir_all(&artifact_dir).unwrap(); - for (path, bytes) in &artifacts { - std::fs::write(path, bytes).unwrap(); - } - std::fs::write(&ledger_path, &ledger).unwrap(); - for extra in [&[][..], &["--no-verify"][..]] { - let out = vex(&p, &api, true, extra); - assert_omitted( - &format!("{what} reverted {extra:?}"), - &out, - "vendor_unwired", - ); - let out = vex(&p, &api, false, extra); - assert_omitted( - &format!("{what} reverted online {extra:?}"), - &out, - "vendor_unwired", - ); - } - let _ = scan.requests(); + // b / a. no ledger. + let ledger_path = p.root.join(".socket/vendor/state.json"); + let ledger = std::fs::read(&ledger_path).unwrap(); + strip_ledgers(&p); + let out = vex(&p, &api, true, &[]); + assert_omitted( + &format!("{what} no ledger offline"), + &out, + "record_unavailable", + ); + api.assert_no_requests(); + let out = vex(&p, &api, false, &[]); + assert_attested( + &format!("{what} no ledger online"), + &out, + Mode::Vendored, + VENDORED_UUID, + ); + let out = embedded(&p, &api, VexVia::Vendor, false, &[]); + assert_eq!(out.code, Some(0), "{what} vendor --vex online: {out}"); + vex_e2e_common::assert_attested( + out.doc(), + &purl(), + VENDORED_UUID, + Marker::Vendored, + &[(GHSA, &[CVE])], + ); + assert_no_manifest_written(&p, &what); + + // d. the real revert, then the ledger + artifact put back. + std::fs::write(&ledger_path, &ledger).unwrap(); + let artifact_dir = p.root.join(format!(".socket/vendor/pypi/{VENDORED_UUID}")); + let artifacts: Vec<(PathBuf, Vec)> = std::fs::read_dir(&artifact_dir) + .unwrap() + .flatten() + .map(|e| (e.path(), std::fs::read(e.path()).unwrap())) + .collect(); + let (code, env) = run_authed(&p, &scan, &["vendor", "--revert"]); + assert_eq!(code, Some(0), "{what} revert: {env}"); + for (name, text) in native_files(release) { + assert_eq!(p.read(name), text, "{what}: revert restores {name}"); + } + std::fs::create_dir_all(&artifact_dir).unwrap(); + for (path, bytes) in &artifacts { + std::fs::write(path, bytes).unwrap(); + } + std::fs::write(&ledger_path, &ledger).unwrap(); + for extra in [&[][..], &["--no-verify"][..]] { + let out = vex(&p, &api, true, extra); + assert_omitted( + &format!("{what} reverted {extra:?}"), + &out, + "vendor_unwired", + ); + let out = vex(&p, &api, false, extra); + assert_omitted( + &format!("{what} reverted online {extra:?}"), + &out, + "vendor_unwired", + ); } + 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 5a9f7960..af798ff7 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs @@ -10,7 +10,7 @@ //! rewriters (`rewrite_python_lock`, `rewrite_project_metadata`, //! `rewrite_script_metadata`) over real uv output (uv 0.11 `uv lock` / //! `uv lock --script` / `uv export --format pylock.toml` grammar), and the -//! writer-driven cells run the real `scan --redirect --vex` / `scan --vendor +//! writer-driven cells run the real `scan --mode hosted --vex` / `scan --vendor //! --vex` binaries against a wiremock patch API. The package is a made-up //! `vexfixture`, so no interpreter's global site-packages on the test host //! can hold a copy (a no-venv python project falls back to the global @@ -36,7 +36,7 @@ //! installed + patched → attests after hashing; installed pristine → //! `not_applied`; a pin-less hosted entry needs an installed tree. //! -//! Plus the embedded entry points (`scan --redirect --vex`, `scan --vendor +//! Plus the embedded entry points (`scan --mode hosted --vex`, `scan --vendor //! --vex`, `apply --vex`). use crate::vex_e2e_common; @@ -1367,7 +1367,7 @@ fn pinless_hosted_entry_needs_an_installed_tree() { } // ════════════════════════════════════════════════════════════════════════ -// Writer-driven: the REAL `scan --redirect --vex` / `scan --vendor --vex` +// Writer-driven: the REAL `scan --mode hosted --vex` / `scan --vendor --vex` // write the wiring and the ledgers; then the manifest (and the ledgers) are // deleted and standalone `vex` must still attest — and stop attesting once // the real revert unwinds the wiring with the ledger left behind. @@ -1379,7 +1379,7 @@ impl Api { /// per-package search, the hosted grant (`artifact_url` pinned to /// `sha256`), the authenticated view with inline blob content, plus the /// public-proxy view `vex` falls back to and the hosted wheel itself - /// (`scan --redirect` reads a uv lock's wheel METADATA from it). + /// (`scan --mode hosted` reads a uv lock's wheel METADATA from it). fn serve_scan_routes( &self, flavor: Flavor, @@ -1515,7 +1515,7 @@ fn writer_flavors() -> Vec { flavors(Mode::Hosted) } -/// `scan --redirect --vex` on a lock-only checkout (nothing installed; an +/// `scan --mode hosted --vex` on a lock-only checkout (nothing installed; an /// empty in-project venv keeps the crawl off the host interpreters) writes /// the hosted wiring — and NO ledger (v5) — and attests in-run from this /// run's records; afterwards, with no manifest: @@ -1531,7 +1531,7 @@ fn writer_flavors() -> Vec { #[test] fn scan_redirect_wiring_attests_without_manifest_or_ledger() { for flavor in writer_flavors() { - let what = format!("{} scan --redirect", flavor.label()); + let what = format!("{} scan --mode hosted", flavor.label()); let p = Proj::new(); p.write_files(&flavor.native_files()); std::fs::create_dir_all(p.site()).unwrap(); @@ -1555,7 +1555,7 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { &api, &[ "scan", - "--redirect", + "--mode=hosted", "--vex", embedded.to_str().unwrap(), "--vex-product", @@ -1634,142 +1634,132 @@ fn scan_redirect_wiring_attests_without_manifest_or_ledger() { /// a. without the ledger, online attests (the committed wheel is hashed); /// d. `vendor --revert` unwinds the wiring; with the ledger put back (and /// the artifact re-committed) it is `vendor_unwired`. -/// The `--detached` twin (a hidden compatibility no-op) behaves the same. #[test] fn scan_vendor_wiring_attests_without_manifest_or_ledger() { - for detached in [false, true] { - for flavor in writer_flavors() { - if matches!(flavor, Flavor::UvLockOnly) { - // uv vendoring always edits the pyproject/lock pair. - continue; - } - let what = format!( - "{} scan --vendor{}", - flavor.label(), - if detached { " --detached" } else { "" } - ); - let p = Proj::new(); - p.write_files(&flavor.native_files()); - p.install(flavor.version(), PRISTINE); - let api = Api::start(); - api.serve_scan_routes( - flavor, - VENDORED_UUID, - &flavor.hosted_url(VENDORED_UUID), - build_wheel(flavor.version(), PATCHED), - ); - let embedded = p.root.join("embedded.vex.json"); - let mut args = vec![ - "scan", - "--vendor", - "--vendor-source", - "build", - "--vex", - embedded.to_str().unwrap(), - "--vex-product", - PRODUCT, - ]; - if detached { - args.push("--detached"); - } - let (code, env) = run_authed(&p, &api, &args); - assert_eq!(code, Some(0), "{what}: {env}"); - assert_embedded_doc(&what, &embedded, VENDORED_UUID, Mode::Vendored); - let lock = p.read(flavor.lock_file()); - assert!( - lock.contains(&format!(".socket/vendor/pypi/{VENDORED_UUID}/")), - "{what}: lock not wired:\n{lock}" - ); - assert!( - !p.exists(".socket/manifest.json"), - "{what}: vendored mode is manifest-free (--detached is a no-op)" - ); - // The installed tree stays pristine: vendoring never patches it. - assert_eq!(std::fs::read(p.site().join(MODULE)).unwrap(), PRISTINE); - // A legacy (pre-5.0) checkout also carries the manifest record - // beside the ledger: same uuid, so it attests the same way. - assert!(vex_e2e_common::seed_legacy_manifest(&p.root) > 0, "{what}"); - let (code, env, doc) = vex_offline(&p, &api, &[]); - assert_attested( - &format!("{what} legacy manifest"), - code, - &env, - &doc, - &flavor.api_purl(), - VENDORED_UUID, - Mode::Vendored, - ); - vex_e2e_common::strip_manifest(&p.root); + for flavor in writer_flavors() { + if matches!(flavor, Flavor::UvLockOnly) { + // uv vendoring always edits the pyproject/lock pair. + continue; + } + let what = format!("{} scan --vendor", flavor.label()); + let p = Proj::new(); + p.write_files(&flavor.native_files()); + p.install(flavor.version(), PRISTINE); + let api = Api::start(); + api.serve_scan_routes( + flavor, + VENDORED_UUID, + &flavor.hosted_url(VENDORED_UUID), + build_wheel(flavor.version(), PATCHED), + ); + let embedded = p.root.join("embedded.vex.json"); + let args = vec![ + "scan", + "--vendor", + "--vendor-source", + "build", + "--vex", + embedded.to_str().unwrap(), + "--vex-product", + PRODUCT, + ]; + let (code, env) = run_authed(&p, &api, &args); + assert_eq!(code, Some(0), "{what}: {env}"); + assert_embedded_doc(&what, &embedded, VENDORED_UUID, Mode::Vendored); + let lock = p.read(flavor.lock_file()); + assert!( + lock.contains(&format!(".socket/vendor/pypi/{VENDORED_UUID}/")), + "{what}: lock not wired:\n{lock}" + ); + assert!( + !p.exists(".socket/manifest.json"), + "{what}: vendored mode is manifest-free" + ); + // The installed tree stays pristine: vendoring never patches it. + assert_eq!(std::fs::read(p.site().join(MODULE)).unwrap(), PRISTINE); + // A legacy (pre-5.0) checkout also carries the manifest record + // beside the ledger: same uuid, so it attests the same way. + assert!(vex_e2e_common::seed_legacy_manifest(&p.root) > 0, "{what}"); + let (code, env, doc) = vex_offline(&p, &api, &[]); + assert_attested( + &format!("{what} legacy manifest"), + code, + &env, + &doc, + &flavor.api_purl(), + VENDORED_UUID, + Mode::Vendored, + ); + vex_e2e_common::strip_manifest(&p.root); - // c. the ledger alone. - let before = api.requests(); - let (code, env, doc) = vex_offline(&p, &api, &[]); - assert_attested( - &format!("{what} ledger"), - code, - &env, - &doc, - &flavor.api_purl(), - VENDORED_UUID, - Mode::Vendored, - ); - assert_eq!(api.requests(), before, "{what}: offline made a request"); + // c. the ledger alone. + let before = api.requests(); + let (code, env, doc) = vex_offline(&p, &api, &[]); + assert_attested( + &format!("{what} ledger"), + code, + &env, + &doc, + &flavor.api_purl(), + VENDORED_UUID, + Mode::Vendored, + ); + assert_eq!(api.requests(), before, "{what}: offline made a request"); - // b / a. no ledger. - let ledger_path = p.root.join(".socket/vendor/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, &[]); + // b / a. no ledger. + let ledger_path = p.root.join(".socket/vendor/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, &[]); + assert_omitted( + &format!("{what} no ledger offline"), + code, + &env, + &doc, + &flavor.purl(), + "record_unavailable", + ); + assert_eq!(api.requests(), before, "{what}: offline made a request"); + let (code, env, doc) = vex_online(&p, &api, &[]); + assert_attested( + &format!("{what} no ledger online"), + code, + &env, + &doc, + &flavor.api_purl(), + VENDORED_UUID, + Mode::Vendored, + ); + assert_no_manifest_written(&p, &what); + + // d. the real revert, then the ledger + artifact put back. + std::fs::write(&ledger_path, &ledger).unwrap(); + let artifact_dir = p.root.join(format!(".socket/vendor/pypi/{VENDORED_UUID}")); + let artifacts: Vec<(PathBuf, Vec)> = std::fs::read_dir(&artifact_dir) + .unwrap() + .flatten() + .map(|e| (e.path(), std::fs::read(e.path()).unwrap())) + .collect(); + let (code, env) = run_authed(&p, &api, &["vendor", "--revert"]); + assert_eq!(code, Some(0), "{what} revert: {env}"); + for (name, text) in flavor.native_files() { + assert_eq!(p.read(name), text, "{what}: revert restores {name}"); + } + std::fs::create_dir_all(&artifact_dir).unwrap(); + for (path, bytes) in &artifacts { + std::fs::write(path, bytes).unwrap(); + } + std::fs::write(&ledger_path, &ledger).unwrap(); + for extra in [&[][..], &["--no-verify"][..]] { + let (code, env, doc) = vex_offline(&p, &api, extra); assert_omitted( - &format!("{what} no ledger offline"), + &format!("{what} reverted {extra:?}"), code, &env, &doc, &flavor.purl(), - "record_unavailable", - ); - assert_eq!(api.requests(), before, "{what}: offline made a request"); - let (code, env, doc) = vex_online(&p, &api, &[]); - assert_attested( - &format!("{what} no ledger online"), - code, - &env, - &doc, - &flavor.api_purl(), - VENDORED_UUID, - Mode::Vendored, + "vendor_unwired", ); - assert_no_manifest_written(&p, &what); - - // d. the real revert, then the ledger + artifact put back. - std::fs::write(&ledger_path, &ledger).unwrap(); - let artifact_dir = p.root.join(format!(".socket/vendor/pypi/{VENDORED_UUID}")); - let artifacts: Vec<(PathBuf, Vec)> = std::fs::read_dir(&artifact_dir) - .unwrap() - .flatten() - .map(|e| (e.path(), std::fs::read(e.path()).unwrap())) - .collect(); - let (code, env) = run_authed(&p, &api, &["vendor", "--revert"]); - assert_eq!(code, Some(0), "{what} revert: {env}"); - for (name, text) in flavor.native_files() { - assert_eq!(p.read(name), text, "{what}: revert restores {name}"); - } - std::fs::create_dir_all(&artifact_dir).unwrap(); - for (path, bytes) in &artifacts { - std::fs::write(path, bytes).unwrap(); - } - std::fs::write(&ledger_path, &ledger).unwrap(); - for extra in [&[][..], &["--no-verify"][..]] { - let (code, env, doc) = vex_offline(&p, &api, extra); - assert_omitted( - &format!("{what} reverted {extra:?}"), - code, - &env, - &doc, - &flavor.purl(), - "vendor_unwired", - ); - } } } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index f504682f..2951081d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -1204,7 +1204,7 @@ fn pnp_layout_contract() { } // ────────────────────────────────────────────────────────────────────── -// EMBEDDED — scan --vex / scan --redirect --vex / scan --vendor --vex / +// EMBEDDED — scan --vex / scan --mode hosted --vex / scan --vendor --vex / // apply --vex, manifest-less // ────────────────────────────────────────────────────────────────────── @@ -1250,7 +1250,7 @@ fn assert_embedded_attested(doc: Option, env: &Value, marker: &str, cell: } /// The in-run VEX of `scan` (a bare scan, which runs hosted mode; the legacy -/// `--redirect`; `--vendor`) on an +/// `--mode hosted`; `--vendor`) on an /// already-wired, manifest-less checkout attests the lock's patch like the /// standalone command, never rewrites the wiring, never writes a manifest, /// and still refuses a tampered installed tree. @@ -1258,7 +1258,7 @@ fn assert_embedded_attested(doc: Option, env: &Value, marker: &str, cell: fn embedded_scan_vex_attests_manifest_less_wiring() { for flavor in [Flavor::Classic, Flavor::Berry4] { for mode in ["hosted", "vendored"] { - for scan_mode in [None, Some("--redirect"), Some("--vendor")] { + for scan_mode in [None, Some("--mode=hosted"), Some("--vendor")] { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); if mode == "hosted" { diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index e9b1caf0..45b2ae4b 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -1,6 +1,6 @@ //! End-to-end tests for redirect-patch awareness in `socket-patch vex`. //! -//! `socket-patch scan --redirect` rewrites lockfiles so a patched dependency +//! `socket-patch scan --mode hosted` rewrites lockfiles so a patched dependency //! resolves from Socket's HOSTED vendored patch, and records the patch (file //! hashes + vulnerabilities) in `.socket/vendor/redirect-state.json`. After the //! package manager installs, the patched bytes land in the installed tree, so @@ -15,7 +15,7 @@ //! 3. tampered installed file → omitted with skip reason `hash_mismatch` //! (fail-closed) //! 4. `--no-verify` attests from the ledger records with NO installed tree -//! (the same shape as the in-run `scan --redirect --vex` attestation) — +//! (the same shape as the in-run `scan --mode hosted --vex` attestation) — //! but only while the lockfile still wires the hosted patch: a stale //! ledger is `redirect_unwired` even under `--no-verify` //! 5. every ecosystem attests through the ledger with its real hosted @@ -25,7 +25,7 @@ //! evidence until install, an installed tree that does not verify wins, //! foreign hosts and pin-less entries are refused //! -//! Every ledger fixture carries the lockfile wiring `scan --redirect` wrote +//! Every ledger fixture carries the lockfile wiring `scan --mode hosted` wrote //! next to it: `vex` only attests a redirect-ledger record while some //! lockfile still resolves the dependency from its hosted patch (a reverted //! lockfile with the ledger left behind must not keep attesting). @@ -141,7 +141,7 @@ fn make_record(uuid: &str, after_hash: &str, vuln_id: &str, cves: &[&str]) -> Pa } /// Write a `.socket/vendor/redirect-state.json` ledger embedding `record` for -/// `purl` (the shape `scan --redirect` persists for VEX). +/// `purl` (the shape `scan --mode hosted` persists for VEX). fn write_redirect_state(cwd: &Path, purl: &str, record: PatchRecord) { let mut state = RedirectState::new(); state.records.insert(purl.to_string(), record); @@ -377,7 +377,7 @@ fn tampered_installed_file_omits_redirected_patch() { // ────────────────────────────────────────────────────────────────────── // 4. --no-verify attests from the ledger with NO installed tree — the same -// shape as the in-run `scan --redirect --vex` attestation (bytes are remote, +// shape as the in-run `scan --mode hosted --vex` attestation (bytes are remote, // fetched at install time, so there is nothing to hash yet). // ────────────────────────────────────────────────────────────────────── @@ -389,7 +389,7 @@ fn redirected_no_verify_attests_without_installed_tree() { // No node_modules, no manifest — the redirect ledger is the only record // source. The ledger alone does not attest; the lockfile must still wire the hosted patch (see the gated twin below), - // so the fixture carries the rewrite `scan --redirect` recorded. + // so the fixture carries the rewrite `scan --mode hosted` recorded. write_redirect_state( cwd, purl, @@ -637,7 +637,7 @@ fn no_verify_attests_redirected_patches_across_ecosystems() { ), ]; - // The ledger records both halves `scan --redirect` persists: the + // The ledger records both halves `scan --mode hosted` persists: the // records AND the file edits (whose files still carry each patch's // hosted wiring — the liveness proof the ledger record needs). let mut state = RedirectState::new(); @@ -852,7 +852,7 @@ fn lockfile_hosted_ref_attests_without_manifest_or_ledger() { assert_eq!(skipped_reason(&env, purl), "record_unavailable"); // Online: the record is fetched, and the PINNED hosted wiring attests - // until install (the in-run `scan --redirect --vex` evidence). + // until install (the in-run `scan --mode hosted --vex` evidence). let (_rt, server) = serve_patch_views(vec![(UUID.to_string(), left_pad_view(&"b".repeat(64)))]); let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); assert_eq!(code, Some(0), "{env}"); diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index b1e54018..71e7433d 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -1,5 +1,5 @@ //! Additional e2e tests for `get` edge cases — exercises the -//! validation branches (--one-off + --save-only conflict, --id flag, +//! validation branches (--id flag, //! multi-patch selection via --id, auto-select for single free patch //! match) and a few error paths the main get_invariants suite doesn't //! reach. @@ -9,9 +9,8 @@ use wiremock::{Mock, MockServer, ResponseTemplate}; // Every invocation must go through `common::run`/`run_with_env`: the binary // binds a wide `SOCKET_*` env surface, and a raw `Command::new(binary())` -// inherits the developer's shell — an exported `SOCKET_ONE_OFF=true` aborts -// every `get` here, `SOCKET_PROXY_URL` outranks the proxy these tests pin, -// and `SOCKET_MANIFEST_PATH` makes a *passing* test write its manifest and +// inherits the developer's shell — `SOCKET_PROXY_URL` outranks the proxy +// these tests pin, and `SOCKET_MANIFEST_PATH` makes a *passing* test write its manifest and // blobs into whatever real project the variable points at. #[path = "common/mod.rs"] mod common; @@ -56,37 +55,6 @@ fn single_file_view() -> serde_json::Value { }) } -#[test] -fn get_one_off_and_save_only_together_errors() { - // The two flags are mutually exclusive — using both must fail. - let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, _stderr) = common::run( - tmp.path(), - &[ - "get", - UUID_A, - "--one-off", - "--save-only", - "--yes", - "--json", - "--api-url", - "http://127.0.0.1:1", - "--api-token", - "fake", - "--org", - ORG_SLUG, - ], - ); - assert_eq!(code, 2, "a usage error (v5.0)"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "error"); - let err = v["error"].as_str().expect("error message"); - assert!( - err.contains("one-off") && err.contains("save-only"), - "error must mention both flags: {err}" - ); -} - #[tokio::test] async fn get_with_id_flag_selects_specific_patch() { // Multiple patches available for a PURL; `get --id` fetches exactly that one. @@ -293,10 +261,8 @@ async fn get_by_package_with_single_paid_patch_emits_paid_required() { let tmp = tempfile::tempdir().unwrap(); let uri = mock.uri(); - // Seed the proxy under its MODERN name: `SOCKET_PROXY_URL` outranks the - // legacy `SOCKET_PATCH_PROXY_URL` in `proxy_url_from_env`, so pinning the - // legacy name alone loses to an ambient modern one. The scrub in - // `run_with_env` also strips any ambient `SOCKET_API_TOKEN`, forcing the + // Seed the proxy via `SOCKET_PROXY_URL`. The scrub in `run_with_env` + // also strips any ambient `SOCKET_API_TOKEN`, forcing the // public-proxy (free-tier) client this test is about. let (code, stdout, _stderr) = common::run_with_env( tmp.path(), @@ -503,9 +469,6 @@ fn get_help_lists_all_identifier_flags() { "get --help missing flag {flag}; got: {stdout}" ); } - // `--one-off` always fails with "not yet implemented": it stays - // parseable (scripts get that explicit error) but is not advertised. - assert!(!stdout.contains("--one-off"), "{stdout}"); // Help text is for users: no implementation notes from the source. for leak in ["value_parser", "parse_bool_flag", "No env binding", "locally- installed"] { assert!(!stdout.contains(leak), "get --help leaks {leak:?}: {stdout}"); diff --git a/crates/socket-patch-cli/tests/get_invariants.rs b/crates/socket-patch-cli/tests/get_invariants.rs index a96531b0..2e5b9b7e 100644 --- a/crates/socket-patch-cli/tests/get_invariants.rs +++ b/crates/socket-patch-cli/tests/get_invariants.rs @@ -20,10 +20,9 @@ const BLOB_BYTES: &[u8] = b"patched\n"; /// Run `get` via `common::run_with_env`, which scrubs the ambient /// `SOCKET_*` environment before spawning. The binary binds a wide env -/// surface (`SOCKET_ONE_OFF`, `SOCKET_MANIFEST_PATH`, `SOCKET_CWD`, -/// `SOCKET_OFFLINE`, ...); an ambient value silently changes what these -/// tests exercise — `SOCKET_ONE_OFF=true` alone fails every invocation -/// here ("--one-off and --save-only cannot be used together"), and +/// surface (`SOCKET_MANIFEST_PATH`, `SOCKET_CWD`, `SOCKET_OFFLINE`, ...); +/// an ambient value silently changes what these tests exercise — +/// `SOCKET_OFFLINE=1` alone fails every invocation here, and /// `SOCKET_MANIFEST_PATH` aims the manifest write OUTSIDE the tempdir. fn run_get(cwd: &Path, api_url: &str, identifier: &str, extra: &[&str]) -> (i32, String, String) { let mut args = vec![ @@ -474,7 +473,7 @@ async fn get_uuid_paid_patch_via_public_proxy_emits_paid_required_envelope() { "--api-url", &uri, ], - &[("SOCKET_PATCH_PROXY_URL", uri.as_str())], + &[("SOCKET_PROXY_URL", uri.as_str())], ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { @@ -510,9 +509,8 @@ async fn get_paid_patch_via_public_proxy_returns_paid_required() { // returns a `paid_required` status. To simulate this we DON'T pass // --api-token / --org so the binary falls back to the public proxy // (the scrubbed env guarantees no ambient SOCKET_API_TOKEN / - // SOCKET_PROXY_URL interferes). We also have to point - // SOCKET_PATCH_PROXY_URL (the legacy alias, injected post-scrub) at - // the mock. + // SOCKET_PROXY_URL interferes). We then point SOCKET_PROXY_URL + // (injected post-scrub) at the mock. let mock = MockServer::start().await; let purl = "pkg:npm/paidpkg@1.0.0"; let encoded = "pkg%3Anpm%2Fpaidpkg%401.0.0"; @@ -548,7 +546,7 @@ async fn get_paid_patch_via_public_proxy_returns_paid_required() { "--api-url", &uri, ], - &[("SOCKET_PATCH_PROXY_URL", uri.as_str())], + &[("SOCKET_PROXY_URL", uri.as_str())], ); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index c0e60e47..9ff12449 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -74,18 +74,11 @@ pub fn patches_from_overrides(overrides: &Path, rewrite_host: Option<&str>) -> V } }; let url = fix(&o["artifactUrl"]); - let mut artifacts = vec![serde_json::json!({ + let artifacts = vec![serde_json::json!({ "kind": "tarball", "url": url, "integrity": o["integrity"].clone(), })]; - if let Some(zip) = o["berryZipUrl"].as_str() { - artifacts.push(serde_json::json!({ - "kind": "yarn-berry-zip", - "url": fix(&Value::String(zip.to_string())), - "integrity": {"yarnBerry10c0": o["integrity"]["yarnBerry10c0"].clone()}, - })); - } let mut registry_override = o.get("registryOverride").cloned().unwrap_or(Value::Null); if let Some(index) = registry_override.get("indexUrl").cloned() { registry_override["indexUrl"] = fix(&index); diff --git a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs index fce505b3..9502d312 100644 --- a/crates/socket-patch-cli/tests/in_process_cargo_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_cargo_apply.rs @@ -242,8 +242,6 @@ async fn cargo_fetch_scan_sync_patches_real_file() { prune: false, sync: true, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), @@ -362,8 +360,6 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() { prune: false, sync: true, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), @@ -461,8 +457,6 @@ async fn cargo_crawler_finds_real_fetched_crate() { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_edge_cases.rs b/crates/socket-patch-cli/tests/in_process_edge_cases.rs index 238d747d..d5f92ac8 100644 --- a/crates/socket-patch-cli/tests/in_process_edge_cases.rs +++ b/crates/socket-patch-cli/tests/in_process_edge_cases.rs @@ -557,7 +557,6 @@ async fn rollback_already_original_short_circuits() { }, targets: Vec::new(), preserve_state: false, - one_off: false, }; let target = tmp.path().join("node_modules/already-orig/index.js"); #[cfg(unix)] diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index ae9f45c4..af56d13a 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -220,8 +220,6 @@ async fn gem_install_scan_sync_patches_real_file() { prune: false, sync: true, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), @@ -333,8 +331,6 @@ async fn gem_crawler_finds_real_installed_gem() { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs index ecf70b37..fba7f6c2 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_multi_platform.rs @@ -240,8 +240,6 @@ fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases, vex: Default::default(), @@ -537,7 +535,6 @@ fn rollback_args(cwd: &Path, api_url: String, offline: bool) -> RollbackArgs { ecosystems: Some(vec!["gem".to_string()]), ..socket_patch_cli::args::GlobalArgs::default() }, - one_off: false, } } @@ -742,7 +739,6 @@ async fn rollback_all_over_broad_manifest_succeeds() { ecosystems: Some(vec!["gem".to_string()]), ..socket_patch_cli::args::GlobalArgs::default() }, - one_off: false, }; let code = rollback_run(rollback_args).await; assert_eq!(code, 0, "rollback-all over broad manifest should exit 0"); diff --git a/crates/socket-patch-cli/tests/in_process_get.rs b/crates/socket-patch-cli/tests/in_process_get.rs index 9035e57f..96a6d3fb 100644 --- a/crates/socket-patch-cli/tests/in_process_get.rs +++ b/crates/socket-patch-cli/tests/in_process_get.rs @@ -39,7 +39,6 @@ fn default_args(identifier: &str, cwd: &Path) -> GetArgs { ghsa: false, package: false, save_only: true, - one_off: false, all_releases: false, mode: None, } @@ -535,76 +534,6 @@ async fn get_with_explicit_package_flag_resolves_installed_and_saves() { ); } -// --------------------------------------------------------------------------- -// Conflict flags (--one-off + --save-only) -// --------------------------------------------------------------------------- - -/// Assert the mounted mock saw zero requests — the up-front-rejection -/// oracle for the flag-validation tests below. A dead (unreachable) API -/// cannot prove "rejected before any fetch": a run that ignored the flag, -/// fetched, and failed on the dead socket produces the same exit 1 and -/// the same absent manifest. Against a LIVE mock the regressed flow -/// instead fetches successfully and saves, so all three oracles trip. -async fn assert_no_api_requests(server: &MockServer) { - let requests = server.received_requests().await.unwrap(); - assert!( - requests.is_empty(), - "flag must be rejected before any API call, saw: {:?}", - requests - .iter() - .map(|r| r.url.path().to_string()) - .collect::>() - ); -} - -#[tokio::test] -#[serial] -async fn get_one_off_with_save_only_errors() { - // Live mock (not a dead socket) so the zero-request oracle below can - // distinguish up-front rejection from fetch-and-fail. - let (server, url) = start_wiremock().await; - make_view_mock(&server, UUID, PURL, "free").await; - - let tmp = tempfile::tempdir().unwrap(); - let mut args = default_args(UUID, tmp.path()); - args.common.api_url = Some(url); - args.one_off = true; - args.save_only = true; - - let code = run(args).await; - assert_eq!(code, 2, "conflicting flags are a usage error (exit 2)"); - // The conflict is rejected up front, before any fetch — nothing saved. - assert_no_manifest(tmp.path()); - assert_no_api_requests(&server).await; -} - -#[tokio::test] -#[serial] -async fn get_one_off_is_an_honest_not_implemented_error() { - // `--one-off` was a silent no-op for three majors: the flag parsed but - // was never read past the `--save-only` conflict check, so the patch - // was saved to the manifest anyway — lying about persistence. It now - // fails honestly, BEFORE any network or disk activity. The previous - // version of this test used an unreachable API, which proved nothing: - // the regressed flow's fetch failed on the dead socket with the same - // exit 1 and no manifest, so the exact historical regression passed. - // With a live view mock the regressed flow fetches and saves, so it - // now trips all three oracles (exit 0, manifest written, request seen). - let (server, url) = start_wiremock().await; - make_view_mock(&server, UUID, PURL, "free").await; - - let tmp = tempfile::tempdir().unwrap(); - let mut args = default_args(UUID, tmp.path()); - args.common.api_url = Some(url); - args.one_off = true; - args.save_only = false; - - let code = run(args).await; - assert_eq!(code, 2, "--one-off must fail as not-yet-implemented (usage, exit 2)"); - assert_no_manifest(tmp.path()); - assert_no_api_requests(&server).await; -} - // --------------------------------------------------------------------------- // Network failure // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs b/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs index 8f18e6e7..63a4af6a 100644 --- a/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs +++ b/crates/socket-patch-cli/tests/in_process_get_corrupt_manifest.rs @@ -67,7 +67,6 @@ async fn uuid_get_with_corrupt_manifest_fails_without_clobbering() { package: false, // save_only isolates the save path from the apply step. save_only: true, - one_off: false, all_releases: false, mode: None, }; 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 f6ab3f7a..9958b2be 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 @@ -66,7 +66,6 @@ fn get_hosted_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { ghsa: false, package: false, save_only: false, - one_off: false, all_releases: false, mode: Some(ScanMode::Hosted), } diff --git a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs index 152819af..40e5b5c2 100644 --- a/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs +++ b/crates/socket-patch-cli/tests/in_process_get_manifest_path.rs @@ -103,7 +103,6 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { ghsa: false, package: false, save_only: true, - one_off: false, all_releases: false, mode: Some(socket_patch_cli::commands::scan::ScanMode::Agent), } 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 83cd74c7..949921f3 100644 --- a/crates/socket-patch-cli/tests/in_process_get_modes.rs +++ b/crates/socket-patch-cli/tests/in_process_get_modes.rs @@ -68,7 +68,6 @@ fn get_args(identifier: &str, cwd: &Path, api_url: String) -> GetArgs { ghsa: false, package: false, save_only: false, - one_off: false, all_releases: false, mode: Some(socket_patch_cli::commands::scan::ScanMode::Agent), } diff --git a/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs b/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs index 07d61017..dd131656 100644 --- a/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs +++ b/crates/socket-patch-cli/tests/in_process_get_uuid_fallback.rs @@ -71,7 +71,6 @@ async fn stale_token_uuid_get_falls_back_to_proxy_end_to_end() { package: false, // save_only isolates the fallback/save path from the apply step. save_only: true, - one_off: false, all_releases: false, mode: None, }; diff --git a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs index c5bafe10..fa520987 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_apply.rs @@ -269,8 +269,6 @@ async fn pypi_install_scan_sync_patches_real_file() { prune: false, sync: true, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), @@ -346,8 +344,6 @@ async fn pypi_scan_then_apply_force_patches_real_file() { prune: false, sync: true, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), @@ -456,8 +452,6 @@ async fn pypi_apply_dry_run_does_not_modify_file() { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), @@ -586,8 +580,6 @@ async fn pypi_crawler_finds_real_installed_six() { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs index 18b866a9..3e4b098e 100644 --- a/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs +++ b/crates/socket-patch-cli/tests/in_process_pypi_multi_release.rs @@ -318,8 +318,6 @@ fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases, vex: Default::default(), @@ -578,7 +576,6 @@ async fn rollback_all_over_broad_manifest_succeeds() { ecosystems: Some(vec!["pypi".to_string()]), ..socket_patch_cli::args::GlobalArgs::default() }, - one_off: false, }; let code = rollback_run(rollback_args).await; assert_eq!(code, 0, "rollback-all over broad manifest should exit 0"); diff --git a/crates/socket-patch-cli/tests/in_process_python_envs.rs b/crates/socket-patch-cli/tests/in_process_python_envs.rs index 95325a01..5d759c5c 100644 --- a/crates/socket-patch-cli/tests/in_process_python_envs.rs +++ b/crates/socket-patch-cli/tests/in_process_python_envs.rs @@ -135,8 +135,6 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 8b830088..5eb700f1 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1,6 +1,6 @@ -//! In-process test for `socket-patch scan --redirect`: mocks the API +//! In-process test for `socket-patch scan --mode hosted`: mocks the API //! (discovery + the `patches/package` reference endpoint) via wiremock, lays -//! down an npm project with a lockfile, runs `scan --redirect`, and asserts the +//! down an npm project with a lockfile, runs `scan --mode hosted`, and asserts the //! lockfile's patched-dependency entry was repointed at the hosted vendored //! patch (resolved URL + sha512 integrity) — and (v5) that NO redirect //! ledger was written: the lockfile pin is the whole hosted state, and @@ -61,9 +61,7 @@ fn redirect_args(cwd: &Path, api_url: String) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: true, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Hosted), all_releases: false, vex: Default::default(), } @@ -206,7 +204,7 @@ async fn scan_redirect_rewrites_lockfile_to_hosted_patch() { write_project(tmp.path()); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect should succeed"); + assert_eq!(code, 0, "scan --mode hosted should succeed"); let lock = std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(); assert!( @@ -225,7 +223,7 @@ async fn scan_redirect_rewrites_lockfile_to_hosted_patch() { vlt_hosted_common::assert_no_ledger(tmp.path()); } -/// `scan --redirect --vex` must emit a valid OpenVEX doc for the redirected +/// `scan --mode hosted --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 this run fetched (held in memory /// — v5 writes no ledger); the statement carries the `(redirected)` @@ -250,7 +248,7 @@ async fn scan_redirect_vex_emits_redirected_attestation() { }; let code = run(args).await; - assert_eq!(code, 0, "scan --redirect --vex should succeed"); + assert_eq!(code, 0, "scan --mode hosted --vex should succeed"); // The record reached the attestation in memory: nothing persisted. vlt_hosted_common::assert_no_ledger(tmp.path()); @@ -318,7 +316,7 @@ fn write_installed(root: &Path, name: &str, version: &str, bytes: &[u8]) { std::fs::write(pkg.join("index.js"), bytes).unwrap(); } -/// Idempotency: a second `scan --redirect` run over the already-redirected +/// Idempotency: a second `scan --mode hosted` 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] @@ -333,12 +331,12 @@ async fn second_redirect_run_is_idempotent() { write_project(tmp.path()); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "first scan --redirect should succeed"); + assert_eq!(code, 0, "first scan --mode hosted should succeed"); 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"); + assert_eq!(code, 0, "second scan --mode hosted should succeed"); assert_eq!( std::fs::read_to_string(tmp.path().join("package-lock.json")).unwrap(), first, @@ -405,7 +403,7 @@ async fn no_lockfile_redirect_is_not_attested() { /// In-run `--vex` semantics: redirected PURLs are exempt from verification /// (their bytes are remote until install), but OTHER manifest patches still /// verify normally — an applied one attests plain, a not-applied one is -/// omitted. This pins that `scan --redirect --vex` does NOT silently attest +/// omitted. This pins that `scan --mode hosted --vex` does NOT silently attest /// the whole manifest unverified. #[tokio::test] #[serial] @@ -463,7 +461,7 @@ async fn redirect_vex_verifies_manifest_patches_normally() { ..Default::default() }; let code = run(args).await; - assert_eq!(code, 0, "scan --redirect --vex should succeed"); + assert_eq!(code, 0, "scan --mode hosted --vex should succeed"); let doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); @@ -570,7 +568,7 @@ async fn redirect_vex_doc_id_and_compact_flags() { ..Default::default() }; let code = run(args).await; - assert_eq!(code, 0, "scan --redirect --vex should succeed"); + assert_eq!(code, 0, "scan --mode hosted --vex should succeed"); let raw = std::fs::read_to_string(&vex_path).unwrap(); assert_eq!( @@ -708,7 +706,7 @@ async fn scan_redirect_rewrites_yarn_berry_lock() { write_berry_project(tmp.path()); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect (berry) should succeed"); + assert_eq!(code, 0, "scan --mode hosted (berry) should succeed"); let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); // yarn writes `__archiveUrl=`; assert both the @@ -898,7 +896,7 @@ async fn scan_redirect_rewrites_correct_entry_in_crlf_classic_lock() { std::fs::write(tmp.path().join("yarn.lock"), lock_lf.replace('\n', "\r\n")).unwrap(); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect (classic CRLF) should succeed"); + assert_eq!(code, 0, "scan --mode hosted (classic CRLF) should succeed"); let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); assert!( @@ -988,7 +986,7 @@ async fn scan_redirect_rewrites_bun_lock() { let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect (bun) should succeed"); + assert_eq!(code, 0, "scan --mode hosted (bun) should succeed"); let lock = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); assert!( @@ -1024,7 +1022,7 @@ async fn scan_redirect_rewrites_bun_lock_v2() { let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect (bun, lock v2) should succeed"); + assert_eq!(code, 0, "scan --mode hosted (bun, lock v2) should succeed"); let lock = std::fs::read_to_string(tmp.path().join("bun.lock")).unwrap(); assert!( @@ -1293,7 +1291,7 @@ fn path_with_first(bin_dir: &Path) -> std::ffi::OsString { std::env::join_paths(entries).expect("PATH entries join") } -/// `scan --redirect --json --yes` as a subprocess with the given child PATH; +/// `scan --mode hosted --json --yes` as a subprocess with the given child PATH; /// returns (exit code, parsed envelope, stderr). Asserts stdout IS JSON so a /// leaking shim (bun chatter on stdout) fails loudly. fn scan_redirect_json_with_path( @@ -1304,7 +1302,7 @@ fn scan_redirect_json_with_path( let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--json", "--yes", "--cwd", @@ -1785,7 +1783,7 @@ fn write_rush_project(root: &Path, with_repo_state: bool) { } } -/// `scan --redirect` in a Rush monorepo rewrites BOTH the common +/// `scan --mode hosted` in a Rush monorepo rewrites BOTH the common /// source-of-truth lock and every subspace lock in place (nested FileEdit /// paths), even though there is no root package.json/lock pair — the package /// is discovered from the Rush locks (lockfile supplement) and the pnpm @@ -1803,7 +1801,7 @@ async fn scan_redirect_rewrites_rush_common_and_subspace_locks() { write_rush_project(tmp.path(), true); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect should succeed in a Rush repo"); + assert_eq!(code, 0, "scan --mode hosted should succeed in a Rush repo"); // Both nested locks are rewritten in place (not a new root lock). for rel in [ @@ -1853,7 +1851,7 @@ fn run_redirect_subprocess_with(cwd: &Path, api_url: &str, extra: &[&str]) -> se let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--json", "--yes", "--cwd", @@ -1871,13 +1869,13 @@ fn run_redirect_subprocess_with(cwd: &Path, api_url: &str, extra: &[&str]) -> se assert_eq!( out.status.code(), Some(0), - "scan --redirect must succeed; stdout=\n{}\nstderr=\n{}", + "scan --mode hosted must succeed; stdout=\n{}\nstderr=\n{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr), ); serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { panic!( - "scan --redirect --json output is not JSON: {e}\nstdout:\n{}", + "scan --mode hosted --json output is not JSON: {e}\nstdout:\n{}", String::from_utf8_lossy(&out.stdout) ) }) @@ -2055,7 +2053,7 @@ packages: /// `redirect_gradle_manual_snippet`, the missing-integrity family). /// Regression guard: the human branch printed skipped/record/rush warnings /// but dropped `rewrite.warnings` entirely, so a default-mode -/// `scan --redirect` in a lockfile-less project reported "Redirected 0 +/// `scan --mode hosted` in a lockfile-less project reported "Redirected 0 /// package(s)" with no explanation at all. Subprocess (not in-process) so /// stderr can be read back. #[tokio::test] @@ -2086,7 +2084,7 @@ async fn redirect_human_mode_prints_rewriter_warnings() { let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--cwd", tmp.path().to_str().unwrap(), @@ -2143,7 +2141,7 @@ async fn redirect_human_mode_warnings_are_not_json_quoted() { let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--cwd", tmp.path().to_str().unwrap(), @@ -2176,7 +2174,7 @@ async fn redirect_human_mode_warnings_are_not_json_quoted() { let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--cwd", tmp.path().to_str().unwrap(), @@ -2960,7 +2958,7 @@ async fn cargo_redirect_writes_the_legacy_dot_cargo_config() { std::fs::write(tmp.path().join(".cargo/config"), "[net]\nretry = 3\n").unwrap(); let code = run(redirect_args(tmp.path(), server.uri())).await; - assert_eq!(code, 0, "scan --redirect should succeed"); + assert_eq!(code, 0, "scan --mode hosted should succeed"); let legacy = std::fs::read_to_string(tmp.path().join(".cargo/config")).unwrap(); assert!( @@ -2983,7 +2981,7 @@ async fn cargo_redirect_writes_the_legacy_dot_cargo_config() { ); } -/// `scan --redirect --json` must emit a machine-readable error envelope on +/// `scan --mode hosted --json` must emit a machine-readable error envelope on /// stdout for EVERY failure exit, never empty stdout plus an exit code. /// /// Regression pin for the long-open hosted-mode JSON gap: the early @@ -3048,7 +3046,7 @@ async fn redirect_json_mode_failures_emit_error_envelope() { let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--json", "--cwd", @@ -3077,7 +3075,7 @@ async fn redirect_json_mode_failures_emit_error_envelope() { let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--json", "--cwd", @@ -3122,13 +3120,13 @@ fn assert_write_failure_envelope(out: &std::process::Output, leg: &str) { ); } -/// Shared driver for the write-failure legs: a hosted `scan --redirect --json` +/// Shared driver for the write-failure legs: a hosted `scan --mode hosted --json` /// subprocess against the obstructed project in `tmp`. async fn run_hosted_json_scan(tmp: &std::path::Path, server: &MockServer) -> std::process::Output { scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--json", "--cwd", @@ -3277,7 +3275,7 @@ async fn corrupt_pre_v5_ledger_dry_run_succeeds_without_touching_it() { let out = scrubbed_cli() .args([ "scan", - "--redirect", + "--mode=hosted", "--yes", "--json", "--dry-run", 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 7eb92abf..ea99779f 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -119,7 +119,6 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { patch_server_url: Some(PATCH_SERVER.to_string()), ..global(cwd, server.uri()) }, - one_off: false, preserve_state: false, }) .await; @@ -160,9 +159,7 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: true, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Hosted), all_releases: false, vex: VexEmbedArgs { vex: vex.map(Path::to_path_buf), 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 b176e9a6..2c80118b 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -88,9 +88,7 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: true, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Hosted), all_releases: false, vex: VexEmbedArgs { vex: vex.map(Path::to_path_buf), @@ -315,7 +313,6 @@ async fn roll_back(cwd: &Path, server: &MockServer) { let code = rollback::run(RollbackArgs { targets: Vec::new(), common: global(cwd, server.uri()), - one_off: false, preserve_state: false, }) .await; 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 03da8fcd..ddc76a93 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -67,7 +67,6 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { patch_server_url: Some("http://patch.test".to_string()), ..socket_patch_cli::args::GlobalArgs::default() }, - one_off: false, preserve_state: false, }) .await; @@ -75,8 +74,7 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { code } -/// `--mode hosted` (the documented spelling; the hidden `--redirect` boolean -/// folds into it). +/// `--mode hosted`. fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { ScanArgs { paths: Vec::new(), @@ -95,8 +93,6 @@ fn hosted_args(cwd: &Path, api_url: String) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: Some(ScanMode::Hosted), all_releases: false, vex: Default::default(), 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 ef67e70a..5b1bb834 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -104,7 +104,6 @@ async fn rollback_hosted(cwd: &Path, server: &MockServer) -> i32 { patch_server_url: Some("http://patch.test".to_string()), ..global(cwd, server.uri()) }, - one_off: false, preserve_state: false, }) .await; @@ -129,9 +128,7 @@ fn hosted_args(cwd: &Path, api_url: String, vex: Option<&Path>) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: true, - mode: None, + mode: Some(socket_patch_cli::commands::scan::ScanMode::Hosted), all_releases: false, vex: VexEmbedArgs { vex: vex.map(Path::to_path_buf), diff --git a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs index 094454bc..1ff92dcc 100644 --- a/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs @@ -93,8 +93,6 @@ fn default_scan_args(cwd: &Path, eco: &str, api_url: String) -> ScanArgs { prune: false, sync: true, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs index 575d6339..2f795be6 100644 --- a/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs +++ b/crates/socket-patch-cli/tests/in_process_remove_repair_lifecycle.rs @@ -848,7 +848,6 @@ async fn repair_telemetry_attributed_to_env_credentials() { // below would fail for the wrong reason (`is_telemetry_disabled` // reads these at runtime — `VITEST=true` included). std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); - std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_OFFLINE"); std::env::remove_var("VITEST"); let code = repair_run(make_repair_args(tmp.path(), "file")).await; diff --git a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs index cdaf734d..b3487362 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs @@ -110,7 +110,6 @@ fn default_rollback_args(cwd: &Path, eco: &str) -> RollbackArgs { }, targets: Vec::new(), preserve_state: false, - one_off: false, } } diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 5f6072e0..19ff8dae 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -86,8 +86,6 @@ fn hosted_scan_args(cwd: &Path, api_url: String) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: Some(ScanMode::Hosted), all_releases: false, vex: Default::default(), @@ -109,7 +107,6 @@ async fn rollback_in_process(cwd: &Path, targets: Vec, preserve_state: b patch_server_url: Some("http://patch.test".to_string()), ..socket_patch_cli::args::GlobalArgs::default() }, - one_off: false, preserve_state, }; let code = rollback_run(args).await; @@ -155,7 +152,6 @@ async fn rollback_online(cwd: &Path, server: &MockServer) -> i32 { 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; @@ -669,7 +665,6 @@ async fn npm_hosted_dry_run_previews_cleanly() { 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; @@ -833,7 +828,6 @@ async fn pypi_requirements_hosted_round_trip() { ghsa: false, package: false, save_only: false, - one_off: false, all_releases: false, mode: Some(ScanMode::Hosted), }; diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs index 58774101..34430eaa 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -211,7 +211,6 @@ fn rollback_args(cwd: &Path, preserve_state: bool) -> RollbackArgs { lock_timeout: Some(5), ..GlobalArgs::default() }, - one_off: false, preserve_state, } } diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index bfc0d72c..e9d816d8 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -39,8 +39,6 @@ fn default_args(cwd: &Path) -> ScanArgs { prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: None, all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 15c8aaf8..122d3d03 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -2860,7 +2860,7 @@ async fn scan_vendor_gem_qualified_platform_ruby_purl_vendors() { ); } -/// The QUALIFIED purl through `--detached` + `vendor --revert`: a detached +/// The QUALIFIED purl through `scan --vendor` + `vendor --revert`: a detached /// ledger entry has NO manifest fallback, so the revert must find it via its /// own key/`basePurl` alone. The bare-purl detached shape is covered by /// [`scan_vendor_gem_detached_writes_no_manifest_and_reverts`]; this pins @@ -2871,10 +2871,10 @@ async fn scan_vendor_gem_detached_qualified_purl_reverts() { mount_gem_patch_api(&mock, GEM_PURL_QUALIFIED).await; let fx = gem_fixture(); - let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &["--detached"]); + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); assert_eq!( code, 0, - "scan --vendor --detached must succeed on the qualified purl: {env:#}" + "scan --vendor must succeed on the qualified purl: {env:#}" ); assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); @@ -2911,7 +2911,7 @@ async fn scan_vendor_gem_detached_qualified_purl_reverts() { assert!(!fx.root().join(".socket/vendor").exists()); } -/// `scan --vendor --detached` on the gem project: no manifest is written, +/// `scan --vendor` on the gem project: no manifest is written, /// the ledger entry is detached with the patch record embedded, the pair /// edit still lands — and `vendor --revert` (the detached entry's only exit /// path) byte-restores both files. @@ -2921,8 +2921,8 @@ async fn scan_vendor_gem_detached_writes_no_manifest_and_reverts() { mount_gem_patch_api(&mock, GEM_PURL).await; let fx = gem_fixture(); - let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &["--detached"]); - assert_eq!(code, 0, "scan --vendor --detached must succeed: {env:#}"); + let (code, env) = run_scan_vendor(fx.root(), &mock.uri(), &[]); + assert_eq!(code, 0, "scan --vendor must succeed: {env:#}"); assert_eq!(env["vendor"]["summary"]["applied"], 1, "envelope: {env:#}"); assert!( @@ -3262,8 +3262,6 @@ snapshots: prune: false, sync: false, vendor: false, - detached: false, - redirect: false, mode: Some(ScanMode::Hosted), all_releases: false, vex: Default::default(), diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs index 33cf99de..38f15e78 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -1,6 +1,5 @@ //! Hermetic subprocess tests for the Bun VENDORED-mode refusals and their -//! positive twins: `scan --mode vendored` (with and without the no-op -//! `--detached`), `get --mode vendored`, `get --mode +//! positive twins: `scan --mode vendored`, `get --mode vendored`, `get --mode //! vendored`, their `--dry-run` previews, `--silent`, and the agent //! `--save-only` exemption — driven through the built binary against a //! wiremock patch API, on lockfiles written in the grammar REAL bun @@ -569,22 +568,21 @@ async fn scan_vendored_refusal_preserves_seeded_manifest_record() { } // --------------------------------------------------------------------------- -// scan --mode vendored --detached: the same refusal, BEFORE any fetch +// scan --mode vendored: the same refusal, BEFORE any fetch // --------------------------------------------------------------------------- -/// Every vendored download phase (with or without the no-op `--detached`) -/// refuses pre-fetch with the vendor code — never fetching the view and +/// Every vendored download phase refuses pre-fetch with the vendor code — never fetching the view and /// deferring the refusal to the vendor engine (which degrades to /// `package_not_installed` for alias installs) — and writes no manifest. #[tokio::test] -async fn scan_vendored_detached_refuses_v1_workspace_before_fetch() { +async fn scan_vendored_refuses_v1_workspace_before_fetch() { let mock = MockServer::start().await; mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_bun_project(tmp.path(), LockShape::V1Workspace); let lock_before = lock_bytes(tmp.path()); - let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["status"], "partial_failure", "{v}"); @@ -604,16 +602,16 @@ async fn scan_vendored_detached_refuses_v1_workspace_before_fetch() { assert_refusal_left_tree_alone(tmp.path(), &lock_before); } -/// The lockb twin of the detached refusal: the shape that used to +/// The lockb twin of the pre-fetch refusal: the shape that used to /// misreport `package_not_installed` after a needless fetch. #[tokio::test] -async fn scan_vendored_detached_refuses_bun_lockb_before_fetch() { +async fn scan_vendored_refuses_bun_lockb_before_fetch() { let mock = MockServer::start().await; mount_patch_api(&mock).await; let tmp = tempfile::tempdir().unwrap(); write_bun_project(tmp.path(), LockShape::MalformedLockb); - let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); assert_eq!(v["download"]["downloaded"], 0, "{v}"); @@ -818,17 +816,6 @@ async fn dry_run_previews_report_would_refuse_on_refused_bun_project() { "scan", vec!["scan", "--mode", "vendored", "--dry-run", "--json"], ), - ( - "scan --detached", - vec![ - "scan", - "--mode", - "vendored", - "--detached", - "--dry-run", - "--json", - ], - ), ( "get ", vec!["get", UUID, "--mode", "vendored", "--dry-run", "--json"], @@ -1069,7 +1056,7 @@ async fn preserved_ledger_does_not_bypass_bun_refusal_after_rollback() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); write_bun_project(root, LockShape::V1Direct); - let (exit, stdout, stderr) = scan_vendored(root, &mock.uri(), &["--json", "--detached"]); + let (exit, stdout, stderr) = scan_vendored(root, &mock.uri(), &["--json"]); assert_eq!(exit, 0, "{stdout}\n{stderr}"); assert!(!root.join(".socket/manifest.json").exists()); let (exit, stdout, stderr) = run(root, &["rollback", "--preserve-state", "--yes", "--json"]); @@ -1381,7 +1368,7 @@ async fn corrupt_vendor_ledger_on_refused_bun_lock_reports_vendor_state_unreadab assert_eq!(rec["errorCode"], LEDGER_CODE, "{v}"); // Detached download phase: the same code before any fetch. - let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--detached", "--json"]); + let (exit, stdout, stderr) = scan_vendored(tmp.path(), &mock.uri(), &["--json"]); assert_eq!(exit, 1, "stdout={stdout}\nstderr={stderr}"); let v = parse_single_json_doc(&stdout); let rec = &v["download"]["patches"][0]; diff --git a/crates/socket-patch-cli/tests/remove_duality_invariants.rs b/crates/socket-patch-cli/tests/remove_duality_invariants.rs index 8cb2efb1..5939d0dc 100644 --- a/crates/socket-patch-cli/tests/remove_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/remove_duality_invariants.rs @@ -401,11 +401,12 @@ fn make_two_entry_socket_dir(root: &Path) -> PathBuf { socket } -/// The default cleanup now covers `.socket/diffs` and `.socket/packages` -/// (`.tar.gz`, kept iff the uuid is still referenced by the -/// post-removal manifest) in addition to blobs. Removing A must sweep A's -/// archives from BOTH dirs while B's — still referenced by the second -/// manifest entry — survive; the artifact carrier reports the count. +/// The default cleanup now covers `.socket/diffs` (`.tar.gz`, kept iff +/// the uuid is still referenced by the post-removal manifest) and the legacy +/// `.socket/packages` (swept whole: v5.0 reads no package archives) in +/// addition to blobs. Removing A must sweep A's diff archive while B's — +/// still referenced by the second manifest entry — survives, and both +/// package archives go; the artifact carrier reports the count. #[test] fn default_remove_sweeps_archives_too() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -437,7 +438,8 @@ fn default_remove_sweeps_archives_too() { "exactly A's manifest entry is removed" ); - // A's archives are gone from BOTH archive dirs; B's survive in both. + // A's archives are gone from BOTH archive dirs; B's diff archive + // survives, its legacy package archive does not. for dir in ["diffs", "packages"] { assert!( !socket @@ -446,24 +448,31 @@ fn default_remove_sweeps_archives_too() { .exists(), "the removed entry's {dir} archive must be swept" ); - assert!( - socket - .join(dir) - .join(format!("{ARCH_UUID_B}.tar.gz")) - .exists(), - "the kept entry's {dir} archive must survive" - ); } + assert!( + socket + .join("diffs") + .join(format!("{ARCH_UUID_B}.tar.gz")) + .exists(), + "the kept entry's diff archive must survive" + ); + assert!( + !socket + .join("packages") + .join(format!("{ARCH_UUID_B}.tar.gz")) + .exists(), + "a legacy package archive is swept even for a kept entry" + ); - // The purl-less artifact carrier reports the two swept archives. + // The purl-less artifact carrier reports the three swept archives. let events = v["events"].as_array().expect("events array"); let carrier = events .iter() .find(|e| e["action"] == "removed" && e["purl"].is_null()) .unwrap_or_else(|| panic!("expected the artifact carrier event: {events:?}")); assert_eq!( - carrier["details"]["archivesRemoved"], 2, - "one diff + one package archive swept; carrier={carrier}" + carrier["details"]["archivesRemoved"], 3, + "one diff + two package archives swept; carrier={carrier}" ); // The keep-rule really is manifest-anchored: B's entry survives. diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs index bee81cc8..168a59cf 100644 --- a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -55,7 +55,6 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", - "SOCKET_ONE_OFF", "SOCKET_SKIP_ROLLBACK", "SOCKET_NO_TRUST_LOCKFILE_CONFIG", "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index a447e068..4ae177ca 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -119,7 +119,7 @@ fn rollback_with_no_manifest_emits_error() { let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); assert_eq!(v["status"], "error"); // Pin the *specific* error so a regression that exits 1 for some other - // reason (e.g. ambient env steering it into one-off mode) can't pass. + // reason (e.g. ambient env steering it elsewhere) can't pass. let err = v["error"].as_str().expect("error message string"); assert!( err.contains("Manifest not found"), @@ -127,136 +127,6 @@ fn rollback_with_no_manifest_emits_error() { ); } -#[test] -fn rollback_one_off_without_identifier_errors() { - // `--one-off` is documented as requiring a UUID/PURL positional. - // Without one, rollback bails with an error envelope. - let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout) = run(tmp.path(), &["--json", "--one-off"]); - assert_eq!( - code, 2, - "--one-off w/o identifier is a usage error (exit 2); stdout=\n{stdout}" - ); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "error"); - let err = v["error"].as_str().expect("error message string"); - assert!( - err.contains("--one-off requires an identifier"), - "unexpected error message: {err}" - ); -} - -#[test] -fn rollback_one_off_with_identifier_reports_not_implemented() { - // The one-off mode is a stub that always returns "not yet - // implemented". We pin it here so a real implementation can't land - // silently without updating the contract. - let tmp = tempfile::tempdir().expect("tempdir"); - let (code, stdout) = run( - tmp.path(), - &[ - "--json", - "--one-off", - "33333333-3333-4333-8333-333333333333", - ], - ); - assert_eq!(code, 2, "one-off mode is a usage error (exit 2); stdout=\n{stdout}"); - let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); - assert_eq!(v["status"], "error"); - let err = v["error"].as_str().expect("error message string"); - assert!( - err.contains("not yet implemented"), - "unexpected error message: {err}" - ); -} - -/// Regression: `SOCKET_ONE_OFF=1` must set `--one-off` exactly like the flag. -/// clap's default bool parser accepts only the literal strings `true`/`false` -/// from an env binding, so any other truthy spelling aborted every `rollback` -/// invocation with a clap usage error (exit 2) before it could do any work. -/// `value_parser = parse_bool_flag` gives the flag the same env vocabulary as -/// the `GlobalArgs` bools. Reaching the one-off stub's "not yet implemented" -/// envelope proves the env var landed as `true`. -#[test] -fn truthy_one_off_env_var_sets_flag() { - let tmp = tempfile::tempdir().expect("tempdir"); - let out = rollback_cmd(tmp.path()) - .env("SOCKET_ONE_OFF", "1") - .args(["--json", "33333333-3333-4333-8333-333333333333"]) - .output() - .expect("run socket-patch"); - assert_eq!( - out.status.code(), - Some(2), - "SOCKET_ONE_OFF=1 must parse and reach the one-off stub (its JSON envelope \ - below proves it was not a clap error); stderr=\n{}", - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8_lossy(&out.stdout); - let v: serde_json::Value = serde_json::from_str(&stdout) - .expect("JSON envelope (a clap usage error means the env var aborted the parse)"); - assert_eq!(v["status"], "error"); - let err = v["error"].as_str().expect("error message string"); - assert!( - err.contains("not yet implemented"), - "expected the one-off stub (proving one_off=true), got: {err}" - ); -} - -/// An exported-but-empty `SOCKET_ONE_OFF=` — the shell/CI idiom for -/// blanking a variable without unsetting it — must mean "unset, fall back to -/// false", not abort the run. (`SOCKET_ONE_OFF` is in `LOCAL_ARG_ENV_VARS`, -/// so `main`'s empty-var scrub removes it before clap parses; -/// `parse_bool_flag` also treats an empty string as false.) With one-off correctly off, a manifest-less -/// rollback reaches the normal "Manifest not found" error. -#[test] -fn empty_one_off_env_var_parses_as_false_not_crash() { - let tmp = tempfile::tempdir().expect("tempdir"); - let out = rollback_cmd(tmp.path()) - .env("SOCKET_ONE_OFF", "") - .args(["--json"]) - .output() - .expect("run socket-patch"); - assert_eq!( - out.status.code(), - Some(1), - "empty SOCKET_ONE_OFF must parse, not abort with a usage error; stderr=\n{}", - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8_lossy(&out.stdout); - let v: serde_json::Value = serde_json::from_str(&stdout) - .expect("JSON envelope (a clap usage error means the env var aborted the parse)"); - assert_eq!(v["status"], "error"); - let err = v["error"].as_str().expect("error message string"); - assert!( - err.contains("Manifest not found"), - "empty SOCKET_ONE_OFF must resolve to false (normal rollback path), got: {err}" - ); -} - -/// Human (non-JSON) one-off must surface the same not-implemented error the -/// JSON envelope carries. Before the fix the human branch printed a -/// misleading "One-off rollback mode: fetching patch data..." progress line -/// — for work that never happens — and exited 1 with no error at all. -#[test] -fn rollback_one_off_human_reports_not_implemented_error() { - let tmp = tempfile::tempdir().expect("tempdir"); - let out = rollback_cmd(tmp.path()) - .args(["--one-off", "33333333-3333-4333-8333-333333333333"]) - .output() - .expect("run socket-patch"); - assert_eq!(out.status.code(), Some(2), "one-off mode is a usage error (exit 2)"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("not yet implemented"), - "human one-off must state the not-implemented error; stderr=\n{stderr}" - ); - assert!( - !stderr.contains("fetching patch data"), - "must not print a progress line for work that never happens; stderr=\n{stderr}" - ); -} - #[test] fn rollback_unknown_identifier_emits_error() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 53bc0949..280c9feb 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -2,8 +2,7 @@ //! patches, fetches their records in memory, and vendors each patched //! package into the committable `.socket/vendor/` tree instead of //! applying in place. Vendored mode is manifest-free: the ledger's -//! embedded records are the only state written (`--detached` is an -//! accepted no-op). Mock API + a real npm lockfile fixture, driven +//! embedded records are the only state written. Mock API + a real npm lockfile fixture, driven //! through the built binary. use std::path::{Path, PathBuf}; @@ -518,9 +517,8 @@ async fn scan_vendor_migrates_legacy_manifest_mode_project() { } #[tokio::test] -async fn scan_vendor_detached_mode_writes_no_manifest() { - // scan --vendor --detached: the flag is a compatibility no-op — the run - // is the same manifest-free flow, embedded-record ledger and all. +async fn scan_vendor_writes_no_manifest() { + // scan --vendor: the manifest-free flow, embedded-record ledger and all. let mock = MockServer::start().await; mount_patch_api(&mock, UUID).await; let tmp = tempfile::tempdir().unwrap(); @@ -529,7 +527,7 @@ async fn scan_vendor_detached_mode_writes_no_manifest() { let (code, stdout, stderr) = run_scan_vendor( tmp.path(), &mock.uri(), - &["--detached", "--vex", "out.vex.json"], + &["--vex", "out.vex.json"], ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); @@ -587,7 +585,7 @@ async fn scan_vendor_detached_mode_writes_no_manifest() { // Idempotent re-run: the ledger's embedded record short-circuits the // view fetch entirely (request-log proof) and the backend skips. let before_reqs = mock.received_requests().await.unwrap().len(); - let (code, stdout, _) = run_scan_vendor(tmp.path(), &mock.uri(), &["--detached"]); + let (code, stdout, _) = run_scan_vendor(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 0, "stdout={stdout}"); let v2: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); assert_eq!(v2["download"]["skipped"], 1, "envelope={v2}"); @@ -661,14 +659,14 @@ async fn scan_vendor_dry_run_previews_without_touching_disk() { ); } -/// Interactive (non-JSON) `scan --vendor --detached` with a failing patch +/// Interactive (non-JSON) `scan --vendor` with a failing patch /// view fetch must SAY what failed: exit 1 with a `[fail]` line naming the /// purl on stderr. Regression guard: `download_patch_records`' failure arms /// recorded the error only in their JSON report, so the human path exited /// non-zero with no error output at all (the JSON report is discarded and /// the vendor engine just says "No vendorable patches in scope"). #[tokio::test] -async fn scan_vendor_detached_fetch_failure_reports_error() { +async fn scan_vendor_fetch_failure_reports_error() { let mock = MockServer::start().await; // Discovery succeeds (batch + per-package search, same shapes as // `mount_patch_api`), but the view fetch fails. @@ -722,7 +720,6 @@ async fn scan_vendor_detached_fetch_failure_reports_error() { .args([ "scan", "--vendor", - "--detached", "--yes", "--api-url", &mock.uri(), @@ -764,11 +761,10 @@ async fn scan_vendor_detached_fetch_failure_reports_error() { #[tokio::test] async fn scan_vendor_flag_conflicts_are_clap_errors() { - // --vendor conflicts with --apply/--sync; --detached requires --vendor. + // --vendor conflicts with --apply/--sync. for argv in [ &["scan", "--vendor", "--apply"][..], &["scan", "--vendor", "--sync"][..], - &["scan", "--detached"][..], ] { let out = Command::new(binary()) .args(argv) @@ -782,7 +778,7 @@ async fn scan_vendor_flag_conflicts_are_clap_errors() { "argv={argv:?} must be a clap usage error: {stderr}" ); assert!( - stderr.contains("cannot be used with") || stderr.contains("required"), + stderr.contains("cannot be used with"), "argv={argv:?}: {stderr}" ); } @@ -1943,8 +1939,7 @@ async fn scan_apply_skips_lockfile_only_without_error() { } // --------------------------------------------------------------------------- -// Bun vendored-mode preflight through `scan`: download phase, --detached, -// --silent +// Bun vendored-mode preflight through `scan`: download phase, --silent // --------------------------------------------------------------------------- const BUN_WS_CODE: &str = "vendor_bun_workspace_unsupported"; @@ -2024,51 +2019,6 @@ async fn scan_vendored_bun_v1_workspace_refuses_in_download_phase() { ); } -/// The `--detached` (no-op) twin refuses BEFORE any fetch too: same record, -/// zero downloads, and no manifest at all. -#[tokio::test] -async fn scan_vendored_bun_detached_refuses_before_fetch() { - let mock = MockServer::start().await; - mount_patch_api(&mock, UUID).await; - let tmp = tempfile::tempdir().unwrap(); - write_bun_v1_workspace_fixture(tmp.path()); - let lock_before = std::fs::read(tmp.path().join("bun.lock")).unwrap(); - - let (code, stdout, stderr) = run_scan_vendor( - tmp.path(), - &mock.uri(), - &["--mode", "vendored", "--detached"], - ); - assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); - assert_eq!(v["status"], "partial_failure", "envelope={v}"); - assert_eq!(v["download"]["detached"], true, "envelope={v}"); - assert_eq!(v["download"]["downloaded"], 0, "envelope={v}"); - assert_eq!(v["download"]["failed"], 1, "envelope={v}"); - assert_eq!( - v["download"]["patches"][0]["action"], "failed", - "envelope={v}" - ); - assert_eq!( - v["download"]["patches"][0]["errorCode"], BUN_WS_CODE, - "envelope={v}" - ); - let reqs = mock.received_requests().await.unwrap(); - assert!( - !reqs.iter().any(|r| r.url.path().contains("/patches/view/")), - "detached must refuse before fetching" - ); - assert!( - !tmp.path().join(".socket/manifest.json").exists(), - "detached mode never writes a manifest" - ); - assert!(!tmp.path().join(".socket/vendor").exists()); - assert_eq!( - std::fs::read(tmp.path().join("bun.lock")).unwrap(), - lock_before - ); -} - /// The interactive (`--silent`, non-JSON) arm: "errors only" means the /// refusal line — code-tagged, naming the purl — stays on stderr while /// stdout is empty, exit 1. Regression guard: the line was gated on @@ -2228,8 +2178,7 @@ async fn scan_vendored_vlt_direct_dependency_vendors() { /// Manifest-less VEX over the committed state `scan --vendor` leaves /// (manifest-free since 5.0 — the ledger's `detached` entries embed the -/// records, and the hidden `--detached` flag is a no-op, so there is one -/// shape to cover): the checkout attests `(vendored)` from the ledger's +/// records, so there is one shape to cover): the checkout attests `(vendored)` from the ledger's /// embedded record, then from lockfile discovery + the patch API once the /// ledgers are gone too, never `--offline` (`record_unavailable`, zero /// requests), and not once the lock is reverted (`vendor_unwired`, diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs index fc4c5a38..7301b3ff 100644 --- a/crates/socket-patch-cli/tests/telemetry_e2e.rs +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -137,7 +137,6 @@ fn build_cmd_with_token( // (`is_telemetry_disabled()` flips on `VITEST=true`). cmd.env_remove("VITEST"); cmd.env_remove("SOCKET_TELEMETRY_DISABLED"); - cmd.env_remove("SOCKET_PATCH_TELEMETRY_DISABLED"); cmd.env_remove("SOCKET_OFFLINE"); // An ambient VIRTUAL_ENV hijacks the python crawler (its site-packages // get crawled as project packages), breaking the exact 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 df5ff53a..415dea70 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs @@ -39,7 +39,7 @@ //! 1. build the project with uv from PyPI (`uv lock` + `uv sync`, `uv lock //! --script`, `uv export --format pylock.toml`, `uv pip compile -o //! pylock.toml` or `pip lock`) and install the PRISTINE package; -//! 2. produce the committed state with our CLI — hosted: `scan --redirect +//! 2. produce the committed state with our CLI — hosted: `scan --mode hosted //! --vex` against a wiremock patch API that also serves the patched //! wheel; vendored: `vendor --offline --vex` over a staged manifest + //! blob — asserting the in-run document; @@ -53,7 +53,7 @@ //! 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 +//! `apply --vex` (+ `vendor --vex` / `scan --mode hosted --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 @@ -557,7 +557,7 @@ fn wheel_from_installed(site: &Path, version: &str, module: &[u8]) -> (String, V // ── the scan-side mock (hosted) ──────────────────────────────────────── -/// The authenticated routes `scan --redirect` uses for one pypi patch, plus +/// The authenticated routes `scan --mode hosted` uses for one pypi patch, plus /// the patched wheel itself at its hosted url. pub struct ScanApi { server: wiremock::MockServer, @@ -1209,7 +1209,7 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { &proj, &[ "scan", - "--redirect", + "--mode=hosted", "--json", "--yes", "--cwd", @@ -1226,34 +1226,34 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { PRODUCT, ], ); - let env = envelope(&out, &report.what("scan --redirect")); + let env = envelope(&out, &report.what("scan --mode hosted")); assert!( env["redirect"]["redirected"].as_u64().unwrap_or(0) >= 1, "{}: nothing redirected: {env:#}", - report.what("scan --redirect") + report.what("scan --mode hosted") ); // The in-run `--vex` judges the INSTALLED tree, which is still // the pristine wheel until uv reinstalls from the rewritten lock // ("installed evidence wins"): the redirect lands, the stale // install is reported, and nothing is attested (exit 1, no // document). The manifest-less matrix below re-runs `scan - // --redirect --vex` over the reinstalled fresh checkout. + // --mode hosted --vex` over the reinstalled fresh checkout. assert_eq!( out.status.code(), Some(1), "{}: {env:#}", - report.what("scan --redirect") + report.what("scan --mode hosted") ); assert_eq!( env["error"]["code"], "no_applicable_patches", "{}: {env:#}", - report.what("scan --redirect") + report.what("scan --mode hosted") ); assert!( env.to_string().contains("redirect_pypi_stale_install"), "{}: the stale pristine install is not reported: {env:#}", - report.what("scan --redirect") + report.what("scan --mode hosted") ); assert!( !embedded_doc.exists(), @@ -1525,7 +1525,7 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { match mode { Mode::Vendored => embedded.push(("vendor --vex", VexRun::offline().via(VexVia::Vendor))), Mode::Hosted => embedded.push(( - "scan --redirect --vex", + "scan --mode hosted --vex", VexRun { api_url: patch_server.clone(), api_token: Some("fake-token".into()), @@ -1533,7 +1533,7 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { ..VexRun::default() } .via(VexVia::Scan) - .arg("--redirect") + .arg("--mode=hosted") .arg("--yes"), )), } 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 eb2ee9a0..cdb642e2 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 @@ -16,7 +16,7 @@ //! mod vex_pdm_hatch_common; //! ``` //! -//! Every project is wired by the REAL CLI, not by hand: `scan --redirect` +//! Every project is wired by the REAL CLI, not by hand: `scan --mode hosted` //! (hosted) or `scan --vendor --vendor-source build` (vendored) runs against //! a wiremock stand-in for the Socket API, with the package's pristine //! install in a fabricated `.venv` for the vendored build. The wired tree is @@ -416,7 +416,7 @@ pub fn path_with_fake_hatch(scratch: &Path) -> std::ffi::OsString { std::env::join_paths(paths).unwrap() } -/// `scan --json` (hosted: `--redirect`; vendored: `--vendor --vendor-source +/// `scan --json` (hosted: `--mode hosted`; vendored: `--vendor --vendor-source /// build`) in `cwd` against `api`. pub fn run_scan( cwd: &Path, @@ -441,7 +441,7 @@ pub fn run_scan( .map(|s| s.to_string()) .collect(); match mode { - Mode::Hosted => args.push("--redirect".into()), + Mode::Hosted => args.push("--mode=hosted".into()), Mode::Vendored => args.extend(["--vendor", "--vendor-source", "build"].map(String::from)), } args.extend(extra.iter().map(|s| s.to_string())); @@ -1165,16 +1165,16 @@ pub fn g_vendored_attests_over_a_pristine_venv_with_a_warning(flavors: &[Flavor] } } -/// `scan --vendor --detached --vex` never writes a manifest; its own VEX +/// `scan --vendor --vex` never writes a manifest; its own VEX /// and a later standalone `vex` both attest (ledger present, then gone). pub fn embedded_detached_vendor_scan_attests_without_a_manifest(flavors: &[Flavor]) { for flavor in flavors.iter().filter(|f| f.vendored) { let (_tmp, cwd) = fresh(); - wire_into(&cwd, flavor, Mode::Vendored, &["--detached"]); - let what = format!("{} detached", flavor.label); + wire_into(&cwd, flavor, Mode::Vendored, &[]); + let what = format!("{} vendored", flavor.label); assert!( !cwd.join(".socket/manifest.json").exists(), - "{what}: detached writes no manifest" + "{what}: vendored mode writes no manifest" ); let run = VexRun { offline: true, @@ -1191,7 +1191,7 @@ pub fn embedded_detached_vendor_scan_attests_without_a_manifest(flavors: &[Flavo } } -/// A CI re-run of `scan --redirect --vex` / `scan --vendor --vex` on a +/// A CI re-run of `scan --mode hosted --vex` / `scan --vendor --vex` on a /// checkout whose `.socket/` was never committed (the wiring is already /// there): the embedded document still attests. `expect_refusal` names the /// flavors whose vendored backend documents a refusal for a ledgerless 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 0dadda5d..654e445f 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 @@ -16,7 +16,7 @@ //! mod vex_pipenv_pip_common; //! ``` //! -//! Every project is wired by the REAL CLI, not by hand: `scan --redirect` +//! Every project is wired by the REAL CLI, not by hand: `scan --mode hosted` //! (hosted) or `scan --vendor --vendor-source build` (vendored) runs against //! a wiremock stand-in for the Socket API, with the package's pristine //! install in a fabricated `.venv` for the vendored build. The wired tree is @@ -456,7 +456,7 @@ pub fn run_scan( cwd.to_str().unwrap(), ]; match mode { - Mode::Hosted => args.push("--redirect"), + Mode::Hosted => args.push("--mode=hosted"), Mode::Vendored => args.extend(["--vendor", "--vendor-source", "build"]), } args.extend_from_slice(extra); @@ -1227,16 +1227,16 @@ pub fn g_vendored_attests_over_a_pristine_venv_with_a_warning(flavors: &[Flavor] } } -/// `scan --vendor --detached --vex` never writes a manifest; its own VEX +/// `scan --vendor --vex` never writes a manifest; its own VEX /// and a later standalone `vex` both attest (ledger present, then gone). pub fn embedded_detached_vendor_scan_attests_without_a_manifest(flavors: &[Flavor]) { for flavor in flavors.iter().filter(|f| f.vendored) { let (_tmp, cwd) = fresh(); - wire_into(&cwd, flavor, Mode::Vendored, &["--detached"]); - let what = format!("{} detached", flavor.label); + wire_into(&cwd, flavor, Mode::Vendored, &[]); + let what = format!("{} vendored", flavor.label); assert!( !cwd.join(".socket/manifest.json").exists(), - "{what}: detached writes no manifest" + "{what}: vendored mode writes no manifest" ); let run = VexRun { offline: true, @@ -1253,7 +1253,7 @@ pub fn embedded_detached_vendor_scan_attests_without_a_manifest(flavors: &[Flavo } } -/// A CI re-run of `scan --redirect --vex` / `scan --vendor --vex` on a +/// A CI re-run of `scan --mode hosted --vex` / `scan --vendor --vex` on a /// checkout whose `.socket/` was never committed (the wiring is already /// there): the embedded document still attests, and the re-scan leaves the /// wired lockfile byte-identical. diff --git a/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs b/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs index 9c2b5940..9d0135d3 100644 --- a/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs +++ b/crates/socket-patch-cli/tests/vex_pipenv_pip_real/mod.rs @@ -94,7 +94,7 @@ impl Mode { /// The `scan` flags that produce this mode's wiring. pub fn scan_flags(self) -> &'static [&'static str] { match self { - Mode::Hosted => &["--redirect"], + Mode::Hosted => &["--mode=hosted"], Mode::Vendored => &["--vendor", "--vendor-source", "build"], } } diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 5125ab3f..e1afdcf4 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -180,8 +180,7 @@ pub async fn fetch_blobs_by_hash( /// Return the set of patch UUIDs whose archive at /// `/.tar.gz` is missing from disk. Used as the -/// "what do I need to download" query for diff mode, and as a presence -/// check for locally staged package archives (`.socket/packages/`). +/// "what do I need to download" query for diff mode. pub async fn get_missing_archives( manifest: &PatchManifest, archives_dir: &Path, @@ -347,7 +346,8 @@ pub const DIFF_ARCHIVE: ArtifactNoun = ArtifactNoun { abbreviate_ids: false, }; -/// Per-patch package archives (`.socket/packages/.tar.gz`). +/// Legacy per-patch package archives (`.socket/packages/.tar.gz`), +/// which nothing writes or reads any more; only the cleanup sweeps name them. pub const PACKAGE_ARCHIVE: ArtifactNoun = ArtifactNoun { one: "package archive", many: "package archives", diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index cf2677fc..4729e5d0 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -1948,8 +1948,7 @@ fn rewrite_url_host(original: &str, new_base: &str) -> Result /// Explicit overrides for environment-based API client construction. /// /// Each `Some(value)` wins over the corresponding env var; `None` falls -/// back to env-var lookup (with the legacy `SOCKET_PATCH_*` shim where -/// applicable). +/// back to env-var lookup. #[derive(Debug, Clone, Default)] pub struct ApiClientEnvOverrides { pub api_url: Option, @@ -1977,7 +1976,7 @@ pub struct ApiClientEnvOverrides { /// |---|---| /// | `SOCKET_API_URL` | Override the API URL (default `https://api.socket.dev`; socket-cli config `apiBaseUrl` sits between) | /// | `SOCKET_API_TOKEN` | API token for authenticated access (socket-cli config `apiToken` is the fallback) | -/// | `SOCKET_PROXY_URL` | Override the public proxy URL (default `https://patches-api.socket.dev`). Legacy: `SOCKET_PATCH_PROXY_URL`. | +/// | `SOCKET_PROXY_URL` | Override the public proxy URL (default `https://patches-api.socket.dev`) | /// | `SOCKET_ORG_SLUG` | Organization slug (socket-cli config `defaultOrg` is the fallback) | /// | `SOCKET_NO_API_TOKEN` | Truthy: ignore ambient tokens (env + config); only an explicit override authenticates | /// | `SOCKET_NO_CONFIG` | Truthy: disable the socket-cli config fallback layer entirely | @@ -3176,9 +3175,7 @@ mod tests { fn binary_url_rederives_proxy_from_env_when_org_slug_missing() { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; let saved_proxy = std::env::var("SOCKET_PROXY_URL").ok(); - let saved_legacy = std::env::var("SOCKET_PATCH_PROXY_URL").ok(); std::env::set_var("SOCKET_PROXY_URL", "http://env-proxy.test:9999/"); - std::env::remove_var("SOCKET_PATCH_PROXY_URL"); let client = ApiClient::new(ApiClientOptions { api_url: "https://api.socket.dev".into(), @@ -3188,7 +3185,7 @@ mod tests { }); let (env_url, env_use_auth) = client.binary_url("blob", HASH); - // With the vars unset the base falls back to the built-in default. + // With the var unset the base falls back to the built-in default. std::env::remove_var("SOCKET_PROXY_URL"); let (default_url, default_use_auth) = client.binary_url("blob", HASH); @@ -3196,10 +3193,6 @@ mod tests { Some(v) => std::env::set_var("SOCKET_PROXY_URL", v), None => std::env::remove_var("SOCKET_PROXY_URL"), } - match saved_legacy { - Some(v) => std::env::set_var("SOCKET_PATCH_PROXY_URL", v), - None => std::env::remove_var("SOCKET_PATCH_PROXY_URL"), - } assert_eq!( env_url, diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 146e53a9..92257849 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -609,10 +609,7 @@ pub fn parse_bun_bin_output(stdout: &str) -> Option { /// /// Production callers live inside `#[cfg(target_os = "macos")]` blocks of /// `get_global_node_modules_paths` (Homebrew/nvm/volta/fnm fallbacks). -/// `#[allow(dead_code)]` keeps the function visible to the inline -/// `#[cfg(test)] mod tests` callers on every target without tripping -/// `-D dead_code` on non-macOS clippy runs. -#[allow(dead_code)] +#[cfg_attr(not(any(test, target_os = "macos")), allow(dead_code))] fn find_node_dirs_sync(base: &Path, segments: &[&str]) -> Vec { if !base.is_dir() { return Vec::new(); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f6e53cd4..bc0afe8a 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -190,7 +190,7 @@ pub fn build_candidates( .unwrap_or_default(); // The yarn-berry cache zip carries the `yarnBerry10c0` checksum the // berry rewriter pins (berry verifies the zip, not the tarball). - // Merge it in and carry the zip URL (None when not stored yet). + // Merge it in; the zip URL itself is never read. let berry_zip = reference .artifacts .iter() @@ -242,7 +242,6 @@ pub fn build_candidates( token, patch_uuid: sel_uuid.clone(), artifact_url: url, - berry_zip_url: berry_zip.and_then(|a| a.url.clone()), registry_override: reference.registry_override.clone(), integrity, }, diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs index 699ed494..5b99fc2a 100644 --- a/crates/socket-patch-core/src/patch/apply.rs +++ b/crates/socket-patch-core/src/patch/apply.rs @@ -64,8 +64,6 @@ pub enum MismatchPolicy { /// Which patch source actually wrote the patched bytes for a file. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppliedVia { - /// Bytes came from a per-package archive in `.socket/packages/`. - Package, /// Bytes were produced by applying a bsdiff delta from /// `.socket/diffs/.tar.gz`. Diff, @@ -77,7 +75,6 @@ impl AppliedVia { /// Short lowercase tag, suitable for JSON and human output. pub fn as_tag(&self) -> &'static str { match self { - AppliedVia::Package => "package", AppliedVia::Diff => "diff", AppliedVia::Blob => "blob", } @@ -87,11 +84,10 @@ impl AppliedVia { /// Patch sources the apply pipeline may use to obtain patched bytes. /// /// `blobs_path` is always required and serves as the universal fallback. -/// `packages_path` and `diffs_path` are optional opt-ins. +/// `diffs_path` is an optional opt-in. #[derive(Debug, Clone, Copy)] pub struct PatchSources<'a> { pub blobs_path: &'a Path, - pub packages_path: Option<&'a Path>, pub diffs_path: Option<&'a Path>, /// In-memory blob overlay (`afterHash` → patched bytes), consulted /// BEFORE the on-disk blob dir. The vendor flows stage their patch @@ -108,7 +104,6 @@ impl<'a> PatchSources<'a> { pub(crate) fn blobs_only(blobs_path: &'a Path) -> Self { Self { blobs_path, - packages_path: None, diffs_path: None, mem_blobs: None, } @@ -718,13 +713,12 @@ async fn chown_blocking( /// /// For each file in `files`, this function: /// 1. Verifies the file is ready to be patched (or already patched). -/// 2. If not dry_run, tries patch sources in order: package archive → diff -/// archive → per-file blob. Each strategy is opt-in via `sources`. +/// 2. If not dry_run, tries patch sources in order: diff archive → +/// per-file blob. The diff strategy is opt-in via `sources`. /// 3. Returns a summary of what happened. /// -/// `uuid` is the patch UUID. Pass `Some` to enable package- and -/// diff-archive lookup (the corresponding `sources.packages_path` / -/// `sources.diffs_path` must also be set). Pass `None` to restrict the +/// `uuid` is the patch UUID. Pass `Some` to enable diff-archive lookup +/// (`sources.diffs_path` must also be set). Pass `None` to restrict the /// pipeline to per-file blobs only. /// /// For npm packages, one on-disk `pkg_path` is not necessarily the only @@ -912,12 +906,8 @@ async fn apply_package_patch_at( return result; } - // Eagerly load the package and diff archives (if any) into memory so - // we don't reparse the tar.gz once per file. Both are small archives. - let package_entries = match (uuid, sources.packages_path) { - (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await, - _ => None, - }; + // Eagerly load the diff archive (if any) into memory so we don't + // reparse the tar.gz once per file. let diff_entries = match (uuid, sources.diffs_path) { (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await, _ => None, @@ -940,10 +930,10 @@ async fn apply_package_patch_at( let normalized = normalize_file_path(file_name); // Resolve the patched bytes from the first applicable source, in - // order: package archive → per-file diff → in-memory blob overlay + // order: per-file diff → in-memory blob overlay // (the vendor flows stage there, so vendoring writes no - // `.socket/blobs` entries) → on-disk blob. An archive or diff - // candidate is applicable only when it hashes to `afterHash`; a + // `.socket/blobs` entries) → on-disk blob. A diff candidate is + // applicable only when its product hashes to `afterHash`; a // stale or corrupt entry falls through, it is not an error. The // blob is the universal fallback: failing to read it fails the // file. The diff needs the pre-apply on-disk hash that @@ -952,10 +942,7 @@ async fn apply_package_patch_at( // the diff still bails instead of producing garbage. let current_hash = verify_result.and_then(|v| v.current_hash.as_deref()); let (patched_content, via): (Cow<'_, [u8]>, AppliedVia) = if let Some(bytes) = - resolve_from_archive(package_entries.as_ref(), normalized, file_info) - { - (Cow::Borrowed(bytes), AppliedVia::Package) - } else if let Some(bytes) = resolve_from_diff( + resolve_from_diff( diff_entries.as_ref(), normalized, pkg_path, @@ -1043,19 +1030,7 @@ async fn apply_package_patch_at( result } -/// Strategy 1 — package archive: the entry for `normalized_path`, when it -/// is present and hashes to `afterHash`. Anything else is "not -/// applicable" and the caller falls through to the next source. -fn resolve_from_archive<'e>( - package_entries: Option<&'e HashMap>>, - normalized_path: &str, - file_info: &PatchFileInfo, -) -> Option<&'e [u8]> { - let bytes = package_entries?.get(normalized_path)?; - (compute_git_sha256_from_bytes(bytes) == file_info.after_hash).then_some(bytes.as_slice()) -} - -/// Strategy 2 — per-file diff: apply the bsdiff delta for +/// Strategy 1 — per-file diff: apply the bsdiff delta for /// `normalized_path` to the on-disk file and return the product. Not /// applicable (`None`) when there is no delta, the entry is a new file /// (nothing to diff against), `current_hash` is missing or is not the @@ -1080,7 +1055,7 @@ async fn resolve_from_diff( (compute_git_sha256_from_bytes(&patched) == file_info.after_hash).then_some(patched) } -/// Strategy 3 (on-disk half) — read `blobs_path/` fail-closed. +/// Strategy 2 (on-disk half) — read `blobs_path/` fail-closed. /// /// SECURITY: `hash` comes from a committed `.socket/manifest.json` that the /// CI `apply` step applies without user action, so it is validated as a blob @@ -2134,10 +2109,8 @@ mod tests { // ── Fallback-chain tests ───────────────────────────────────────── // - // Tests below exercise the archive strategies: - // package archive (.socket/packages/.tar.gz) and per-file diff - // archive (.socket/diffs/.tar.gz), plus the priority order - // package → diff → blob. + // Tests below exercise the per-file diff archive + // (.socket/diffs/.tar.gz) and the priority order diff → blob. use flate2::write::GzEncoder; use flate2::Compression as GzCompression; @@ -2170,14 +2143,13 @@ mod tests { delta } - /// Returns a fully-populated three-source fixture: original file on - /// disk, all of (package, diff, blob) available with valid patched - /// content. Caller can then delete sources to test fallback. + /// Returns a fully-populated two-source fixture: original file on + /// disk, both (diff, blob) available with valid patched content. + /// Caller can then delete sources to test fallback. async fn make_fixture() -> ( - tempfile::TempDir, // root holding pkg/, blobs/, packages/, diffs/ + tempfile::TempDir, // root holding pkg/, blobs/, diffs/ std::path::PathBuf, // pkg dir std::path::PathBuf, // blobs dir - std::path::PathBuf, // packages dir std::path::PathBuf, // diffs dir HashMap, Vec, // original bytes @@ -2186,11 +2158,9 @@ mod tests { let root = tempfile::tempdir().unwrap(); let pkg_dir = root.path().join("pkg"); let blobs_dir = root.path().join("blobs"); - let packages_dir = root.path().join("packages"); let diffs_dir = root.path().join("diffs"); tokio::fs::create_dir_all(&pkg_dir).await.unwrap(); tokio::fs::create_dir_all(&blobs_dir).await.unwrap(); - tokio::fs::create_dir_all(&packages_dir).await.unwrap(); tokio::fs::create_dir_all(&diffs_dir).await.unwrap(); let original: Vec = b"the original content of the file".to_vec(); @@ -2208,9 +2178,6 @@ mod tests { .await .unwrap(); - // Package archive containing the patched bytes - write_uuid_archive(&packages_dir, TEST_UUID, &[("index.js", &patched)]); - // Diff archive containing bsdiff(original -> patched) let delta = make_delta(&original, &patched); write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", &delta)]); @@ -2228,7 +2195,6 @@ mod tests { root, pkg_dir, blobs_dir, - packages_dir, diffs_dir, files, original, @@ -2237,49 +2203,12 @@ mod tests { } #[tokio::test] - async fn test_apply_via_package_when_archive_present() { - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) = - make_fixture().await; - - let sources = PatchSources { - blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success, "expected success: {:?}", result.error); - assert_eq!(result.files_patched, vec!["index.js".to_string()]); - assert_eq!( - result.applied_via.get("index.js"), - Some(&AppliedVia::Package) - ); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - - #[tokio::test] - async fn test_apply_falls_back_to_diff_when_no_package() { - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) = + async fn test_apply_via_diff_when_archive_present() { + let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - // Delete the package archive. - tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz"))) - .await - .unwrap(); let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; @@ -2302,19 +2231,15 @@ mod tests { #[tokio::test] async fn test_apply_falls_back_to_blob_when_no_archives() { - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) = + let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - // Delete both archives. - tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz"))) - .await - .unwrap(); + // Delete the diff archive. tokio::fs::remove_file(diffs_dir.join(format!("{TEST_UUID}.tar.gz"))) .await .unwrap(); let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; @@ -2339,12 +2264,11 @@ mod tests { async fn test_apply_uuid_none_disables_alt_sources() { // Even if archives exist, passing `uuid = None` must restrict the // pipeline to the blob path. - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, _patched) = + let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, _patched) = make_fixture().await; let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; @@ -2368,11 +2292,8 @@ mod tests { // Corrupt the on-disk file so its hash no longer matches // before_hash. Diff strategy must NOT run (its output would never // match after_hash), so we fall through to the blob. - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) = + let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz"))) - .await - .unwrap(); // Overwrite on-disk content with garbage; use --force so verify // promotes the HashMismatch to Ready and the pipeline still tries // to apply. @@ -2382,7 +2303,6 @@ mod tests { let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; @@ -2404,57 +2324,15 @@ mod tests { assert_eq!(written, patched); } - #[tokio::test] - async fn test_apply_via_package_skips_when_hash_mismatches() { - // Package archive contains the WRONG bytes (would not hash to - // after_hash). The package strategy must refuse the entry and - // fall back to diff or blob. - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) = - make_fixture().await; - // Replace the package archive with one whose entry is corrupt. - tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz"))) - .await - .unwrap(); - write_uuid_archive( - &packages_dir, - TEST_UUID, - &[("index.js", b"corrupt package payload")], - ); - - let sources = PatchSources { - blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), - diffs_path: Some(&diffs_dir), - mem_blobs: None, - }; - let result = apply_package_patch( - "pkg:npm/x@1.0.0", - &pkg_dir, - &files, - &sources, - Some(TEST_UUID), - false, - MismatchPolicy::Warn, - ) - .await; - - assert!(result.success); - // Package refused → diff succeeded next. - assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff)); - let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); - assert_eq!(written, patched); - } - #[tokio::test] async fn test_apply_dry_run_does_not_touch_alternative_sources() { - // Even with package/diff archives present, dry-run must not modify + // Even with a diff archive present, dry-run must not modify // files on disk. - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) = + let (_root, pkg_dir, blobs_dir, diffs_dir, files, original, _patched) = make_fixture().await; let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; @@ -2666,7 +2544,6 @@ mod tests { #[test] fn test_applied_via_as_tag() { - assert_eq!(AppliedVia::Package.as_tag(), "package"); assert_eq!(AppliedVia::Diff.as_tag(), "diff"); assert_eq!(AppliedVia::Blob.as_tag(), "blob"); } @@ -2675,7 +2552,6 @@ mod tests { fn test_patch_sources_blobs_only_disables_other_strategies() { let dir = tempfile::tempdir().unwrap(); let sources = PatchSources::blobs_only(dir.path()); - assert!(sources.packages_path.is_none()); assert!(sources.diffs_path.is_none()); } @@ -3244,17 +3120,13 @@ mod tests { /// to the blob strategy and still patch successfully. #[tokio::test] async fn test_apply_corrupt_diff_falls_through_to_blob() { - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) = + let (_root, pkg_dir, blobs_dir, diffs_dir, files, _orig, patched) = make_fixture().await; - // No package archive; diff archive holds garbage delta bytes. - tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz"))) - .await - .unwrap(); + // Diff archive holds garbage delta bytes. write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", b"garbage delta")]); let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; @@ -3425,18 +3297,21 @@ mod tests { /// SECURITY: the patch `uuid` is joined as `/.tar.gz`. A /// traversal uuid that would resolve to a real archive elsewhere is - /// treated as "no archive" (both strategies skipped, blob applies) — + /// treated as "no archive" (diff strategy skipped, blob applies) — /// never joined. #[tokio::test] async fn test_apply_unsafe_uuid_skips_archives() { - let (_root, pkg_dir, blobs_dir, _packages_dir, diffs_dir, files, _orig, patched) = + let (root, pkg_dir, blobs_dir, diffs_dir, files, original, patched) = make_fixture().await; - // `diffs/../packages/.tar.gz` IS the real package archive. - let escaping_uuid = format!("../packages/{TEST_UUID}"); + // `diffs/../escape/.tar.gz` IS a valid diff archive. + let escape_dir = root.path().join("escape"); + tokio::fs::create_dir_all(&escape_dir).await.unwrap(); + let delta = make_delta(&original, &patched); + write_uuid_archive(&escape_dir, TEST_UUID, &[("index.js", &delta)]); + let escaping_uuid = format!("../escape/{TEST_UUID}"); let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&diffs_dir), - diffs_path: None, + diffs_path: Some(&diffs_dir), mem_blobs: None, }; let result = apply_package_patch( @@ -3454,7 +3329,7 @@ mod tests { assert_eq!( result.applied_via.get("index.js"), Some(&AppliedVia::Blob), - "an escaping uuid must not reach the package archive" + "an escaping uuid must not reach the escaped diff archive" ); let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap(); assert_eq!(written, patched); @@ -3468,7 +3343,7 @@ mod tests { #[cfg(target_os = "macos")] #[tokio::test] async fn test_apply_write_failure_is_reported_not_masked_as_missing_blob() { - let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) = + let (_root, pkg_dir, blobs_dir, diffs_dir, files, original, _patched) = make_fixture().await; // Only the archives are staged — no blob to fall back on. let after_hash = &files["index.js"].after_hash; @@ -3485,7 +3360,6 @@ mod tests { let sources = PatchSources { blobs_path: &blobs_dir, - packages_path: Some(&packages_dir), diffs_path: Some(&diffs_dir), mem_blobs: None, }; diff --git a/crates/socket-patch-core/src/patch/package.rs b/crates/socket-patch-core/src/patch/package.rs index fe41c446..68919b08 100644 --- a/crates/socket-patch-core/src/patch/package.rs +++ b/crates/socket-patch-core/src/patch/package.rs @@ -1,14 +1,10 @@ -//! Package- and diff-archive tarball helpers. +//! Patch-archive tarball helpers. //! -//! Both package archives (`.socket/packages/.tar.gz`) and diff -//! archives (`.socket/diffs/.tar.gz`) use the same on-disk format: -//! a gzipped tar containing one entry per patched file. The entry's path -//! matches the **normalized** relative file path (i.e. without the -//! `package/` prefix used by the API). -//! -//! For package archives, each entry holds the patched file's full bytes. -//! For diff archives, each entry holds a bsdiff delta that transforms the -//! corresponding `beforeHash` content into the `afterHash` content. +//! Diff archives (`.socket/diffs/.tar.gz`) are a gzipped tar +//! containing one entry per patched file. The entry's path matches the +//! **normalized** relative file path (i.e. without the `package/` prefix +//! used by the API), and each entry holds a bsdiff delta that transforms +//! the corresponding `beforeHash` content into the `afterHash` content. use std::collections::HashMap; use std::io::Read; @@ -35,7 +31,7 @@ const MAX_ENTRY_BYTES: u64 = 16 * 1024 * 1024; /// the in-memory `HashMap`. const MAX_ENTRIES: usize = 10_000; -/// Errors produced while reading a package/diff archive. +/// Errors produced while reading a patch archive. #[derive(Debug, thiserror::Error)] pub enum ArchiveError { #[error("archive I/O error: {0}")] 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 7f7b1820..7658c547 100644 --- a/crates/socket-patch-core/src/patch/redirect/bun_binary.rs +++ b/crates/socket-patch-core/src/patch/redirect/bun_binary.rs @@ -123,7 +123,6 @@ mod tests { token: String::new(), patch_uuid: version.into(), artifact_url: format!("https://patch.example.test/{version}/minimist-{version}.tgz"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(format!( diff --git a/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs index 187c3c25..6a326239 100644 --- a/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs @@ -421,7 +421,6 @@ fn dep(rng: &mut Rng, pool: usize, n: usize) -> DepOverride { token: String::new(), patch_uuid: format!("00000000-0000-4000-8000-{n:012}"), artifact_url: url, - berry_zip_url: None, registry_override: None, integrity: Integrity { sha1: (!rng.chance(8)).then(|| { @@ -522,7 +521,6 @@ fn in_place_composer_rewrite_matches_oracle_on_fixture() { token: String::new(), patch_uuid: format!("00000000-0000-4000-8000-{n:012}"), artifact_url: format!("https://patch.socket.dev/composer/{n}.zip"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha1: Some("0123456789abcdef0123456789abcdef01234567".into()), diff --git a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs index 7ac73360..d3fe18cf 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_equivalence_tests.rs @@ -544,7 +544,6 @@ fn dep(rng: &mut Rng, pool: usize, n: usize) -> DepOverride { token: String::new(), patch_uuid: uuid(n), artifact_url: String::new(), - berry_zip_url: None, registry_override, integrity: Integrity { dirhash_h1: h1(rng), @@ -642,7 +641,6 @@ fn single_walk_golang_rewrite_matches_oracle_on_fixtures() { token: String::new(), patch_uuid: uuid(n), artifact_url: String::new(), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "goproxy".into(), index_url: "https://patch.socket.dev/patch-registry/golang".into(), diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index 65bfdeb8..10fe9d51 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -297,7 +297,6 @@ fn pypi_dep(name: &str, uuid: &str) -> DepOverride { token: String::new(), patch_uuid: uuid.into(), artifact_url: format!("https://patch.test/{name}-1.0.0-py3-none-any.whl"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some("a".repeat(64)), diff --git a/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs index e343e9d5..6f6004fc 100644 --- a/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs @@ -32,7 +32,6 @@ fn dep(name: &str, version: &str, uuid: usize, rng: &mut Rng) -> DepOverride { token: String::new(), patch_uuid: format!("00000000-0000-4000-8000-{uuid:012}"), artifact_url: format!("https://patch.socket.dev/{tag}/{name}-{version}.tgz"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: (!rng.chance(5)).then(|| format!("sha512-P{}==", rng.below(3))), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4163755d..0738302e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -167,8 +167,6 @@ pub struct DepOverride { pub patch_uuid: String, pub artifact_url: String, #[serde(default)] - pub berry_zip_url: Option, - #[serde(default)] pub registry_override: Option, pub integrity: Integrity, } @@ -6123,7 +6121,6 @@ mod tests { token: String::new(), patch_uuid: "11111111-1111-4111-8111-111111111111".into(), artifact_url: url.into(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(sha512.into()), @@ -6141,7 +6138,6 @@ mod tests { token: String::new(), patch_uuid: "11111111-1111-4111-8111-111111111111".into(), artifact_url: url.into(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some(sha256.into()), @@ -6292,7 +6288,6 @@ mod tests { artifact_url: "https://patch.socket.dev/patch/maven/org.slf4j/slf4j-api/1.7.36/tok/uuid/slf4j-api-1.7.36.jar" .into(), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "maven2".into(), index_url: "https://patch.socket.dev/patch-registry/maven/tok/uuid/maven2".into(), @@ -6596,7 +6591,6 @@ mod tests { token: "tok".into(), patch_uuid: "uuid".into(), artifact_url: "https://patch.test/newtonsoft.json.13.0.3.nupkg".into(), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "nuget-v3".into(), index_url: "https://patch.test/nuget/index.json".into(), @@ -8091,7 +8085,6 @@ mod tests { token: "tok".into(), patch_uuid: CARGO_UUID.into(), artifact_url: "https://patch.test/serde-1.0.190.crate".into(), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "cargo-sparse".into(), index_url: cargo_index_url(), @@ -9839,7 +9832,6 @@ mod tests { token: "tok".into(), patch_uuid: "uuid".into(), artifact_url: format!("https://patch.test/{name}-{version}.gem"), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "rubygems-compact-index".into(), index_url: "https://patch.test/gem/tok/uuid/".into(), @@ -12073,7 +12065,6 @@ snapshots: token: String::new(), patch_uuid: "44444444-4444-4444-4444-444444444444".into(), artifact_url: COMPOSER_ARTIFACT_URL.into(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha1: Some(COMPOSER_SHA1.into()), @@ -13749,7 +13740,6 @@ packages: "https://patch.socket.dev/patch-registry/golang/{}/@v/v1.4.2-socketpatch.1.zip", golang_socket_module() ), - berry_zip_url: None, registry_override: Some(RegistryOverride { kind: "goproxy".into(), index_url: "https://patch.socket.dev/patch-registry/golang".into(), @@ -16891,7 +16881,6 @@ mod python_lock_warning_tests { token: "11111111-1111-4111-8111-111111111111".into(), patch_uuid: "22222222-2222-4222-8222-222222222222".into(), artifact_url: "https://patch.socket.dev/requests-2.28.1-py3-none-any.whl".into(), - berry_zip_url: None, registry_override: None, integrity: Integrity::default(), }; @@ -16933,7 +16922,6 @@ mod python_metadata_pairing_tests { token: "11111111-1111-4111-8111-111111111111".into(), patch_uuid: "22222222-2222-4222-8222-222222222222".into(), artifact_url: "https://patch.socket.dev/click-8.1.7-py3-none-any.whl".into(), - berry_zip_url: None, registry_override: None, integrity: Integrity::default(), } @@ -17035,7 +17023,6 @@ mod hatch_tests { token: String::new(), patch_uuid: "test-uuid".into(), artifact_url: "https://patch.test/urllib3-1.26.18-py2.py3-none-any.whl".into(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some("a".repeat(64)), diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index 51cab65d..987a362d 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -211,7 +211,7 @@ mod tests { patch_uuid: "e828efa5-5c6d-43f3-9909-03f5ac232b98".into(), artifact_url: "https://patch.socket.dev/patch/pypi/urllib3/1.26.18/7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e/e828efa5-5c6d-43f3-9909-03f5ac232b98/urllib3-1.26.18-py2.py3-none-any.whl".into(), integrity: Integrity { sha256: Some("a".repeat(64)), ..Default::default() }, - namespace: None, token: "token".into(), berry_zip_url: None, registry_override: None, + namespace: None, token: "token".into(), registry_override: None, } } @@ -330,7 +330,6 @@ mod equivalence_tests { token: String::new(), patch_uuid: "e828efa5-5c6d-43f3-9909-03f5ac232b98".into(), artifact_url: format!("https://patch.socket.dev/patch/pypi/{name}/{url_tail}"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some(sha256.into()), @@ -536,7 +535,6 @@ mod parse_reuse_equivalence_tests { token: String::new(), patch_uuid: format!("00000000-0000-4000-8000-{n:012}"), artifact_url: url, - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some(sha), diff --git a/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs index 906427f0..d3f54db5 100644 --- a/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs @@ -193,7 +193,6 @@ fn dep(p: &Pkg, uuid: usize, url_tag: &str, sha512: Option<&str>) -> DepOverride "https://patch.socket.dev/{url_tag}/{}-{}.tgz", p.name, p.version ), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: sha512.map(str::to_string), diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index a2798cd6..3bd20f47 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -326,7 +326,6 @@ mod equivalence_tests { token: String::new(), patch_uuid: format!("00000000-0000-4000-8000-{uuid:012}"), artifact_url: format!("https://patch.socket.dev/patch/pypi/{name}/{uuid}/{wheel}"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: sha256.map(str::to_string), diff --git a/crates/socket-patch-core/src/patch/redirect/python_lock_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/python_lock_equivalence_tests.rs index f783a6a8..d1691c02 100644 --- a/crates/socket-patch-core/src/patch/redirect/python_lock_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/python_lock_equivalence_tests.rs @@ -490,7 +490,6 @@ fn dep(i: usize, v: &str, uuid: usize, rng: &mut Rng) -> DepOverride { "https://patch.socket.dev/patch/{uuid}/pkg-{i}-{v}{ext}{}", if rng.chance(10) { "?token=x#frag" } else { "" } ), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: (!rng.chance(5)).then(|| HEX.to_string()), @@ -625,7 +624,6 @@ fn refusal_in_the_middle_leaves_the_prior_rewrite_intact() { token: String::new(), patch_uuid: format!("00000000-0000-4000-8000-{n:012}"), artifact_url: format!("https://patch.socket.dev/{name}-1.0.0-py3-none-any.whl"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some(HEX.into()), diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index d6d64c3c..cdbd9eb6 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -351,7 +351,6 @@ mod tests { sha256: Some(HASH.into()), ..Default::default() }, - berry_zip_url: None, registry_override: None, } } 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 index aaf71292..f51142f6 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/bun_lockb.rs @@ -186,7 +186,6 @@ mod tests { 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!( diff --git a/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs b/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs index f848197c..1ef1ba1b 100644 --- a/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs +++ b/crates/socket-patch-core/src/patch/redirect/vlt_preflight.rs @@ -461,7 +461,6 @@ mod tests { token: String::new(), patch_uuid: format!("uuid-{name}"), artifact_url: format!("https://patch.socket.dev/patch/npm/t/u/{name}-1.0.0.tgz"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: sha512.map(str::to_string), diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index 752a83eb..d49aaa5c 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::constants::USER_AGENT; use crate::utils::env_compat::{ - is_debug_enabled, is_offline_env, proxy_url_from_env, read_env_with_legacy, + is_debug_enabled, is_offline_env, proxy_url_from_env, }; use crate::utils::fs::home_dir; use crate::vex::time::unix_to_ymdhms; @@ -118,7 +118,6 @@ struct PatchTelemetryEvent { /// /// Telemetry is disabled when: /// - `SOCKET_TELEMETRY_DISABLED` is `"1"` or `"true"` -/// (legacy `SOCKET_PATCH_TELEMETRY_DISABLED` still honored with warning) /// - `VITEST` is `"true"`. Load-bearing downstream dependency, not a relic: /// socket-cli's vitest integration suite /// (`packages/cli/test/integration/cli/cmd-patch*.test.mts`) spawns this @@ -133,11 +132,7 @@ struct PatchTelemetryEvent { /// is set the CLI dispatcher sets `SOCKET_TELEMETRY_DISABLED=1` for the /// duration of the process so this check stays the single source of truth. pub fn is_telemetry_disabled() -> bool { - let env_value = read_env_with_legacy( - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_TELEMETRY_DISABLED", - ) - .unwrap_or_default(); + let env_value = std::env::var("SOCKET_TELEMETRY_DISABLED").unwrap_or_default(); let disabled_via_env = matches!(env_value.as_str(), "1" | "true"); let vitest = std::env::var("VITEST").unwrap_or_default() == "true"; disabled_via_env || vitest || is_offline_env() @@ -1039,7 +1034,6 @@ mod tests { let saved: Vec<(&str, Option)> = [ "SOCKET_PROXY_URL", "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_TELEMETRY_DISABLED", "SOCKET_OFFLINE", "VITEST", ] @@ -1090,22 +1084,19 @@ mod tests { } /// Combined into a single test to avoid env-var races across parallel tests. - /// Exercises the `SOCKET_TELEMETRY_DISABLED` name, the legacy - /// `SOCKET_PATCH_TELEMETRY_DISABLED` shim, and the airgap gate via - /// `SOCKET_OFFLINE`. Serialized: SOCKET_* env is process-global and the + /// Exercises the `SOCKET_TELEMETRY_DISABLED` name and the airgap gate + /// via `SOCKET_OFFLINE`. Serialized: SOCKET_* env is process-global and the /// api/client.rs suite reads `SOCKET_OFFLINE` mid-test. #[test] #[serial_test::serial] fn test_is_telemetry_disabled() { // Save originals let orig_new = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); - let orig_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); let orig_vitest = std::env::var("VITEST").ok(); let orig_offline = std::env::var("SOCKET_OFFLINE").ok(); // Default: not disabled std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); - std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); std::env::remove_var("VITEST"); std::env::remove_var("SOCKET_OFFLINE"); assert!(!is_telemetry_disabled()); @@ -1115,13 +1106,6 @@ mod tests { assert!(is_telemetry_disabled()); std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); - // Disabled via legacy var (with deprecation warning) - std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "1"); - assert!(is_telemetry_disabled()); - std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true"); - assert!(is_telemetry_disabled()); - std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); - // Disabled via airgap: SOCKET_OFFLINE=1 implies "no network", // which includes the telemetry endpoint. std::env::set_var("SOCKET_OFFLINE", "1"); @@ -1146,10 +1130,6 @@ mod tests { Some(v) => std::env::set_var("SOCKET_TELEMETRY_DISABLED", v), None => std::env::remove_var("SOCKET_TELEMETRY_DISABLED"), } - match orig_legacy { - Some(v) => std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v), - None => std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"), - } match orig_vitest { Some(v) => std::env::set_var("VITEST", v), None => std::env::remove_var("VITEST"), diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index 3acaae86..56c7879a 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -1,83 +1,18 @@ -//! Legacy → new env-var compatibility shim. -//! -//! The v3.0 CLI surface migrated three env vars from the `SOCKET_PATCH_*` -//! prefix to the unified `SOCKET_*` prefix: -//! -//! | New | Legacy | -//! |------------------------------|-------------------------------------| -//! | `SOCKET_PROXY_URL` | `SOCKET_PATCH_PROXY_URL` | -//! | `SOCKET_DEBUG` | `SOCKET_PATCH_DEBUG` | -//! | `SOCKET_TELEMETRY_DISABLED` | `SOCKET_PATCH_TELEMETRY_DISABLED` | -//! -//! `read_env_with_legacy` reads the new name; if absent, it falls back to the -//! legacy name and prints a one-shot deprecation warning to stderr. The -//! warning fires **unconditionally** — even under `--silent` / `--json` — so -//! users see the transition signal in scripts and CI logs. The legacy names -//! will be removed in a future major release. +//! Environment-variable readers shared by the CLI and core, plus the +//! accepted `SOCKET_CLI_*` peer aliases. -use std::collections::HashSet; -use std::sync::Mutex; - -use once_cell::sync::Lazy; - -/// Names of legacy env vars that have already warned in this process. Used -/// so each legacy var warns at most once per invocation, even when read -/// from multiple call sites. -static WARNED: Lazy>> = Lazy::new(|| Mutex::new(HashSet::new())); - -/// Read the new-style env var `new_name`. If absent, fall back to -/// `legacy_name` and print a one-shot deprecation warning to stderr (the -/// warning fires regardless of CLI verbosity flags so users notice the -/// transition). -/// -/// Returns `None` when neither name is set (or both are set to an empty -/// string, matching the prior call sites' filtering). -pub(crate) fn read_env_with_legacy( - new_name: &'static str, - legacy_name: &'static str, -) -> Option { - if let Ok(v) = std::env::var(new_name) { - if !v.is_empty() { - return Some(v); - } - } - match std::env::var(legacy_name) { - Ok(v) if !v.is_empty() => { - warn_legacy_once(legacy_name, new_name); - Some(v) - } - _ => None, - } -} - -/// Print a one-shot deprecation warning for a legacy name. -fn warn_legacy_once(legacy_name: &'static str, new_name: &'static str) { - let mut warned = match WARNED.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - if warned.insert(legacy_name) { - eprintln!( - "[socket-patch] warning: env var `{legacy_name}` is deprecated; \ - use `{new_name}` instead. The legacy name will be removed in a \ - future major release." - ); - } -} - -/// Check if debug mode is enabled via `SOCKET_DEBUG` (with the legacy -/// `SOCKET_PATCH_DEBUG` shim). +/// Check if debug mode is enabled via `SOCKET_DEBUG`. pub fn is_debug_enabled() -> bool { matches!( - read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG").as_deref(), - Some("1" | "true") + std::env::var("SOCKET_DEBUG").unwrap_or_default().as_str(), + "1" | "true" ) } -/// Strict-airgap gate: `SOCKET_OFFLINE` is `"1"` or `"true"`. No legacy -/// name — it lives here as the single definition of the vocabulary shared -/// by every offline gate (telemetry kill-switch, API-client advisory and -/// org-slug auto-resolution). The CLI mirrors `--offline` (whose clap-side +/// Strict-airgap gate: `SOCKET_OFFLINE` is `"1"` or `"true"`. It lives +/// here as the single definition of the vocabulary shared by every offline +/// gate (telemetry kill-switch, API-client advisory and org-slug +/// auto-resolution). The CLI mirrors `--offline` (whose clap-side /// bool parse accepts a wider vocabulary) into `SOCKET_OFFLINE=1` before /// any of those gates run, so the env read alone is authoritative. pub(crate) fn is_offline_env() -> bool { @@ -87,44 +22,23 @@ pub(crate) fn is_offline_env() -> bool { ) } -/// The public patch-API proxy base URL: `SOCKET_PROXY_URL` (with the legacy -/// `SOCKET_PATCH_PROXY_URL` shim), defaulting to +/// The public patch-API proxy base URL: `SOCKET_PROXY_URL`, defaulting to /// [`DEFAULT_PATCH_API_PROXY_URL`](crate::constants::DEFAULT_PATCH_API_PROXY_URL). pub(crate) fn proxy_url_from_env() -> String { - read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") + std::env::var("SOCKET_PROXY_URL") + .ok() + .filter(|v| !v.is_empty()) .unwrap_or_else(|| crate::constants::DEFAULT_PATCH_API_PROXY_URL.to_string()) } -/// Promote legacy `SOCKET_PATCH_*` env vars to their new `SOCKET_*` names -/// in-process. When the new name is unset and the legacy name is set, copy -/// the value over and emit a one-shot deprecation warning to stderr. -/// -/// Call this *once*, very early in `main`, before clap parses. After -/// promotion every downstream reader (clap `env =`, core code) only needs -/// to know the new name. -/// -/// The warning fires unconditionally — even under `--silent` / `--json` -/// — so the transition signal isn't swallowed in CI logs. -pub fn promote_legacy_env_vars() { - promote_renames(&[ - ("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL"), - ("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG"), - ( - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_TELEMETRY_DISABLED", - ), - ]); -} - /// Peer env-var aliases accepted from the sibling JS Socket CLI, so an /// environment configured for `socket` (e.g. a CI job exporting /// `SOCKET_CLI_API_TOKEN`) works for `socket-patch` unchanged. /// /// First entry is the canonical `SOCKET_*` name (what clap and core read); -/// second is the accepted `SOCKET_CLI_*` peer name. Unlike -/// [`promote_legacy_env_vars`] these are **not** deprecated — promotion is -/// silent and the canonical name simply wins when both are set. The list is -/// deliberately tight: `SOCKET_CLI_CONFIG` (ephemeral JSON override), +/// second is the accepted `SOCKET_CLI_*` peer name. These are **not** +/// deprecated — promotion is silent and the canonical name simply wins +/// when both are set. The list is deliberately tight: `SOCKET_CLI_CONFIG` (ephemeral JSON override), /// `SOCKET_CLI_API_PROXY` (an HTTP forward proxy — reqwest already honors /// `HTTP_PROXY`/`HTTPS_PROXY`), and `SOCKET_CLI_DEBUG` are intentionally /// not mirrored. @@ -137,8 +51,7 @@ pub const PEER_ENV_ALIASES: &[(&str, &str)] = &[ /// Silently copy each set-and-non-empty [`PEER_ENV_ALIASES`] value onto its /// canonical `SOCKET_*` name when the canonical name is unset or empty. -/// Call once, early in `main`, right after [`promote_legacy_env_vars`] and -/// before the empty-var scrub / clap parse. +/// Call once, early in `main`, before the empty-var scrub / clap parse. pub fn promote_peer_env_vars() { promote_aliases(PEER_ENV_ALIASES); } @@ -159,173 +72,12 @@ fn promote_aliases(aliases: &[(&str, &str)]) { } } -/// Core of [`promote_legacy_env_vars`], parameterized over the rename table so -/// it can be exercised in tests with isolated env-var names (the real names are -/// read concurrently by other tests in this binary). -fn promote_renames(renames: &[(&'static str, &'static str)]) { - for &(new_name, legacy_name) in renames { - let new_already_set = matches!(std::env::var(new_name).as_deref(), Ok(v) if !v.is_empty()); - if new_already_set { - continue; - } - // New name is unset/empty, so any value returned here came from the - // legacy name (with the one-shot warning already emitted). - if let Some(value) = read_env_with_legacy(new_name, legacy_name) { - std::env::set_var(new_name, value); - } - } -} - #[cfg(test)] mod tests { use super::*; - /// The warning bookkeeping is process-global, so tests must use env-var - /// names that no other test touches. `std::env` serializes access behind - /// an internal lock, so distinct names never race for memory safety; the - /// only hazard is two tests fighting over the *same* name, which unique - /// names avoid. - #[test] - fn warn_legacy_once_fires_only_once_per_name() { - let name = "SOCKET_TEST_LEGACY_ONCE_PATCH"; - let new = "SOCKET_TEST_LEGACY_ONCE"; - warn_legacy_once(name, new); - warn_legacy_once(name, new); - // The dedup is driven by `HashSet::insert` returning `false` once the - // name has been recorded. Prove that directly: after `warn_legacy_once` - // ran, re-inserting the same name must report "already present", which - // is exactly what suppresses any second eprintln. - let mut warned = WARNED.lock().unwrap(); - assert!(warned.contains(name)); - assert!( - !warned.insert(name), - "name should already be recorded, so a second warning is suppressed" - ); - } - - #[test] - fn read_env_prefers_new_var_over_legacy() { - const NEW: &str = "SOCKET_TEST_READ_PREFERS_NEW"; - const LEGACY: &str = "SOCKET_TEST_READ_PREFERS_NEW_PATCH"; - std::env::set_var(NEW, "new-value"); - std::env::set_var(LEGACY, "legacy-value"); - assert_eq!( - read_env_with_legacy(NEW, LEGACY), - Some("new-value".to_string()) - ); - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - } - - #[test] - fn read_env_falls_back_to_legacy_when_new_unset() { - const NEW: &str = "SOCKET_TEST_READ_FALLBACK_NEW"; - const LEGACY: &str = "SOCKET_TEST_READ_FALLBACK_NEW_PATCH"; - std::env::remove_var(NEW); - std::env::set_var(LEGACY, "legacy-value"); - assert_eq!( - read_env_with_legacy(NEW, LEGACY), - Some("legacy-value".to_string()) - ); - std::env::remove_var(LEGACY); - } - - /// Regression: an empty new var must be treated as "unset" and fall back to - /// the legacy name, matching the prior call sites' `!is_empty()` filtering. - #[test] - fn read_env_empty_new_falls_back_to_legacy() { - const NEW: &str = "SOCKET_TEST_READ_EMPTY_NEW"; - const LEGACY: &str = "SOCKET_TEST_READ_EMPTY_NEW_PATCH"; - std::env::set_var(NEW, ""); - std::env::set_var(LEGACY, "legacy-value"); - assert_eq!( - read_env_with_legacy(NEW, LEGACY), - Some("legacy-value".to_string()) - ); - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - } - - #[test] - fn read_env_none_when_neither_set() { - const NEW: &str = "SOCKET_TEST_READ_NONE_NEW"; - const LEGACY: &str = "SOCKET_TEST_READ_NONE_NEW_PATCH"; - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - assert_eq!(read_env_with_legacy(NEW, LEGACY), None); - } - - /// Regression: both names set but empty → `None` (empty == unset on both - /// sides), per the documented contract. - #[test] - fn read_env_none_when_both_empty() { - const NEW: &str = "SOCKET_TEST_READ_BOTH_EMPTY_NEW"; - const LEGACY: &str = "SOCKET_TEST_READ_BOTH_EMPTY_NEW_PATCH"; - std::env::set_var(NEW, ""); - std::env::set_var(LEGACY, ""); - assert_eq!(read_env_with_legacy(NEW, LEGACY), None); - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - } - - /// `promote_renames` copies a set legacy value over to the unset new name, - /// so downstream readers (clap `env =`, core code) only need the new name. - #[test] - fn promote_copies_legacy_to_new_when_new_unset() { - const NEW: &str = "SOCKET_TEST_PROMOTE_COPY_NEW"; - const LEGACY: &str = "SOCKET_TEST_PROMOTE_COPY_NEW_PATCH"; - std::env::remove_var(NEW); - std::env::set_var(LEGACY, "legacy-value"); - promote_renames(&[(NEW, LEGACY)]); - assert_eq!(std::env::var(NEW).ok().as_deref(), Some("legacy-value")); - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - } - - /// A non-empty new value must win: promote must not clobber it with the - /// legacy value. - #[test] - fn promote_does_not_clobber_existing_new() { - const NEW: &str = "SOCKET_TEST_PROMOTE_KEEP_NEW"; - const LEGACY: &str = "SOCKET_TEST_PROMOTE_KEEP_NEW_PATCH"; - std::env::set_var(NEW, "new-value"); - std::env::set_var(LEGACY, "legacy-value"); - promote_renames(&[(NEW, LEGACY)]); - assert_eq!(std::env::var(NEW).ok().as_deref(), Some("new-value")); - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - } - - /// An empty new value counts as unset, so the legacy value is promoted in - /// over it — mirroring `read_env_with_legacy`'s empty-is-unset rule. - #[test] - fn promote_treats_empty_new_as_unset() { - const NEW: &str = "SOCKET_TEST_PROMOTE_EMPTY_NEW"; - const LEGACY: &str = "SOCKET_TEST_PROMOTE_EMPTY_NEW_PATCH"; - std::env::set_var(NEW, ""); - std::env::set_var(LEGACY, "legacy-value"); - promote_renames(&[(NEW, LEGACY)]); - assert_eq!(std::env::var(NEW).ok().as_deref(), Some("legacy-value")); - std::env::remove_var(NEW); - std::env::remove_var(LEGACY); - } - - /// An empty legacy value is not promoted (empty == unset on the legacy - /// side too), leaving the new name untouched. - #[test] - fn promote_ignores_empty_legacy() { - const NEW: &str = "SOCKET_TEST_PROMOTE_EMPTY_LEGACY_NEW"; - const LEGACY: &str = "SOCKET_TEST_PROMOTE_EMPTY_LEGACY_NEW_PATCH"; - std::env::remove_var(NEW); - std::env::set_var(LEGACY, ""); - promote_renames(&[(NEW, LEGACY)]); - assert_eq!(std::env::var(NEW).ok(), None); - std::env::remove_var(LEGACY); - } - /// Peer-alias promotion copies a set alias onto the unset canonical - /// name — and, unlike the legacy shim, records **no** deprecation - /// warning (peer names are supported, not deprecated). + /// name. #[test] fn peer_alias_promotes_silently_when_canonical_unset() { const CANONICAL: &str = "SOCKET_TEST_PEER_PROMOTE"; @@ -334,10 +86,6 @@ mod tests { std::env::set_var(ALIAS, "from-alias"); promote_aliases(&[(CANONICAL, ALIAS)]); assert_eq!(std::env::var(CANONICAL).ok().as_deref(), Some("from-alias")); - assert!( - !WARNED.lock().unwrap().contains(ALIAS), - "peer promotion must not register a deprecation warning" - ); std::env::remove_var(CANONICAL); std::env::remove_var(ALIAS); } diff --git a/crates/socket-patch-core/src/utils/pdm_lock.rs b/crates/socket-patch-core/src/utils/pdm_lock.rs index 4dbd1816..dab934e0 100644 --- a/crates/socket-patch-core/src/utils/pdm_lock.rs +++ b/crates/socket-patch-core/src/utils/pdm_lock.rs @@ -3,7 +3,7 @@ use toml_edit::DocumentMut; use toml_edit::{value, Array, InlineTable, Item, Table, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::python_lock::preserve_line_endings; +use crate::utils::python_lock::{is_prior_hosted_url, preserve_line_endings}; pub fn lock_version(lock: &Table) -> Result<&str, String> { let version = lock @@ -120,32 +120,6 @@ pub fn wheel_matches(filename: &str, name: &str, version: &str) -> bool { && parts[1] == version } -/// Whether `existing` is an earlier hosted redirect of the SAME artifact: -/// same origin (`scheme://host[:port]`) and same trailing wheel filename as the -/// current artifact URL, fragments ignored. Grant tokens and patch uuids live -/// in the path between them, so a rotated token or a superseded patch (new -/// uuid) takes over the stale pin in place instead of being refused as a -/// foreign source (the poetry / bun rewriters make the same call). -fn is_prior_hosted_url(existing: &str, current: &str) -> bool { - fn origin_and_leaf(url: &str) -> Option<(&str, &str)> { - if !url.starts_with("https://") && !url.starts_with("http://") { - return None; - } - let url = url.split('#').next()?; - let scheme_end = url.find("://")? + 3; - let path_start = url[scheme_end..].find('/')? + scheme_end; - let leaf = url[path_start..] - .rsplit('/') - .next() - .filter(|leaf| !leaf.is_empty())?; - Some((&url[..path_start], leaf)) - } - match (origin_and_leaf(existing), origin_and_leaf(current)) { - (Some(old), Some(new)) => old == new, - _ => false, - } -} - pub fn rewrite_pdm_lock( text: &str, name: &str, diff --git a/crates/socket-patch-core/src/utils/poetry_lock.rs b/crates/socket-patch-core/src/utils/poetry_lock.rs index a59f79a6..6d1b76d1 100644 --- a/crates/socket-patch-core/src/utils/poetry_lock.rs +++ b/crates/socket-patch-core/src/utils/poetry_lock.rs @@ -15,7 +15,7 @@ use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table, TableLike, Value}; use crate::crawlers::python_crawler::canonicalize_pypi_name; -use crate::utils::python_lock::table_likes; +use crate::utils::python_lock::{is_prior_hosted_url, table_likes}; /// The `{file, hash}` tables Poetry records in `package`'s own /// `files = [...]` (lock 2.x; also written into 1.0/1.1 locks). Read by the @@ -162,32 +162,6 @@ pub fn generated_by_version(lock_text: &str) -> Option<(u64, u64)> { Some((major, minor)) } -/// Whether `existing` is an earlier hosted redirect of the SAME artifact: -/// same origin (`scheme://host[:port]`) and same trailing filename as the -/// current artifact URL, fragments ignored. Grant tokens and patch uuids live -/// in the path between them, so a rotated token or a republished patch is -/// superseded in place instead of stranding the pin on a URL that no longer -/// serves (the bun / requirements / cargo rewriters make the same call). -fn is_prior_hosted_url(existing: &str, current: &str) -> bool { - fn origin_and_leaf(url: &str) -> Option<(&str, &str)> { - if !url.starts_with("https://") && !url.starts_with("http://") { - return None; - } - let url = url.split('#').next()?; - let scheme_end = url.find("://")? + 3; - let path_start = url[scheme_end..].find('/')? + scheme_end; - let leaf = url[path_start..] - .rsplit('/') - .next() - .filter(|leaf| !leaf.is_empty())?; - Some((&url[..path_start], leaf)) - } - match (origin_and_leaf(existing), origin_and_leaf(current)) { - (Some(old), Some(new)) => old == new, - _ => false, - } -} - /// Rewrite the `[[package]]` for `name`@`version` to install the wheel at /// `source_url` (`source_type` `"url"` for hosted, `"file"` for vendored), /// pinning `sha256`. Returns `Ok(None)` when the lock has no such entry (or diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 37ff911d..feebdd25 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -243,6 +243,32 @@ pub fn preserve_line_endings(original: &str, rendered: String) -> String { output } +/// Whether `existing` is an earlier hosted redirect of the SAME artifact: +/// same origin (`scheme://host[:port]`) and same trailing filename as the +/// current artifact URL, fragments ignored. Grant tokens and patch uuids live +/// in the path between them, so a rotated token or a superseded patch (new +/// uuid) takes over the stale pin in place instead of being refused as a +/// foreign source or stranding it on a URL that no longer serves. +pub(crate) fn is_prior_hosted_url(existing: &str, current: &str) -> bool { + fn origin_and_leaf(url: &str) -> Option<(&str, &str)> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return None; + } + let url = url.split('#').next()?; + let scheme_end = url.find("://")? + 3; + let path_start = url[scheme_end..].find('/')? + scheme_end; + let leaf = url[path_start..] + .rsplit('/') + .next() + .filter(|leaf| !leaf.is_empty())?; + Some((&url[..path_start], leaf)) + } + match (origin_and_leaf(existing), origin_and_leaf(current)) { + (Some(old), Some(new)) => old == new, + _ => false, + } +} + fn inline(entries: &[(&str, Value)]) -> Value { let mut table = InlineTable::new(); for (key, value) in entries { diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 161d7b22..20fb60dc 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -81,18 +81,6 @@ pub async fn cleanup_binary_workspace_artifacts( super::bun_workspace::cleanup(project_root, entry, dry_run).await } -/// Snapshot member copies before a repair whose rebuilt bytes need checking -/// against the original lockfile integrity. `None` records a missing file. -pub type BinaryWorkspaceArtifactSnapshot = Vec<(std::path::PathBuf, Option>)>; - -/// Read the validated workspace artifacts, recording missing copies as well. -pub fn snapshot_binary_workspace_artifacts( - project_root: &Path, - entry: &VendorEntry, -) -> Result { - super::bun_workspace::snapshot(project_root, entry) -} - /// The `WiringRecord.kind` this backend owns: key = the `packages` map key, /// original/new = the verbatim entry LINE. const KIND_LOCK_PACKAGE: &str = "bun_lock_package"; diff --git a/crates/socket-patch-core/src/vendor/bun_workspace.rs b/crates/socket-patch-core/src/vendor/bun_workspace.rs index c8f66bf2..79f5a06f 100644 --- a/crates/socket-patch-core/src/vendor/bun_workspace.rs +++ b/crates/socket-patch-core/src/vendor/bun_workspace.rs @@ -139,25 +139,6 @@ pub(super) async fn repair( Ok((wiring, !updates.is_empty())) } -/// Preserve both existing and missing member copies before a rebuild whose -/// source still has to be checked against the lockfile's trust anchor. -pub(super) fn snapshot( - root: &Path, - entry: &VendorEntry, -) -> Result { - required_mirrors(root, entry)? - .into_iter() - .map(|(_, rel, path)| { - let before = match read_regular_to_bytes_sync(&path) { - Ok(bytes) => Some(bytes), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => return Err(format!("cannot snapshot workspace tarball {rel}: {e}")), - }; - Ok((path, before)) - }) - .collect() -} - /// Remove superseded mirrors only when every recorded artifact remains ours. /// Validate the entire set before removing any file. pub(super) async fn cleanup(root: &Path, entry: &VendorEntry, dry_run: bool) -> Result<(), String> { diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 78308656..2e728264 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -46,7 +46,9 @@ use super::common::{ use super::parse_memo::ParseMemo; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::{extract_on_blocking_pool, extract_tgz}; -use super::service_fetch::{claim_prestaged, fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{ + claim_prestaged, fetch_verified_archive, ServiceAttempt, ServicePolicy, ServiceTerminal, +}; use super::source::PackageSource; use super::state::{ write_marker_or_warn, CargoLockOriginal, VendorArtifact, VendorEntry, VendorMarker, @@ -319,15 +321,9 @@ async fn cleanup_failed_stage(stage: &Path, uuid_dir: &Path, unwind_uuid_dir: bo prune_empty_vendor_levels(uuid_dir).await; } -/// Outcome of attempting to materialise the cargo copy from the patch service. -enum CargoServiceCopy { - /// The prebuilt crate was extracted into `copy_dir`. - Used, - /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). - HardFail(Box), - /// Fall back to copying + patching the pristine source. - FallBack, -} +/// Outcome of attempting to materialise the cargo copy from the patch service +/// (`Used`: the prebuilt crate was extracted into `copy_dir`). +type CargoServiceCopy = ServiceAttempt<()>; /// Download the prebuilt `.crate`, integrity-verify it, and extract it into /// `copy_dir` (a path-dep copy must carry no `.cargo-checksum.json`). Maps each @@ -349,125 +345,83 @@ async fn cargo_service_copy( if !cfg.service_enabled() { return CargoServiceCopy::FallBack; } - fn hard(code: &'static str, detail: String) -> CargoServiceCopy { - CargoServiceCopy::HardFail(Box::new(refused(code, detail))) - } - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - CargoServiceCopy::FallBack - } + let policy = ServicePolicy::new(cfg, ServiceTerminal::Refused); + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + let subject = format!("crate for {name}"); + let mut archive = match policy.settle(fetched, "crate", &subject, warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, }; - match fetch_verified_archive(cfg, &record.uuid).await { - ServiceArtifact::Ready(mut archive) => { - // Extract the `.crate` (tar.gz; strip its single - // `{name}-{version}/` top-level dir) into a STAGE sibling and - // swap it into the copy dir only once fully verified — a failure - // then leaves any pre-existing copy untouched and no husk behind. - let stage = stage_dir_for(copy_dir); - // A tree the download plan already extracted from these bytes - // (see `prestage`) is moved into the stage instead; otherwise — - // or should the move fail — extract here, as always. - if !claim_prestaged(&mut archive, &stage, copy_dir).await { - let _ = remove_tree(&stage).await; - if let Err(e) = tokio::fs::create_dir_all(&stage).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", stage.display()), - ); - } - let crate_bytes = std::mem::take(&mut archive.bytes); - if let Err(e) = extract_on_blocking_pool(crate_bytes, &stage, extract_tgz).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return hard( - "vendor_prebuilt_extract_failed", - format!("cannot extract the prebuilt crate: {e}"), - ); - } - } - let _ = tokio::fs::remove_file(stage.join(".cargo-checksum.json")).await; - // Verify the EXTRACTED TREE, not just the archive bytes: the SRI - // proves the download is intact, but an unexpected internal - // layout (the single `{name}-{version}/` strip leaving an extra - // wrapper, or an over-strip) lands the patched files at the wrong - // paths and the caller would synthesize success from - // `record.files` while the copy is wrong. Fail closed → `auto` - // falls back to the local build. (Mirrors composer_lock.rs.) - if !copy_matches_after_hashes(&stage, &record.files).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return miss( - warnings, - "vendor_prebuilt_layout_mismatch", - format!( - "prebuilt crate for {name} extracted to an unexpected \ - layout (patched files absent at their recorded paths)" - ), - ); - } - // The copy's version carries the patch uuid tag, written in the - // stage so a swapped-in copy is never untagged. - if let Err(e) = cargo_tag::tag_copy_manifest(&stage, version, &record.uuid).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return miss( - warnings, - "vendor_prebuilt_layout_mismatch", - format!("prebuilt crate for {name}: cannot tag its version ({e})"), - ); - } - if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot move the extracted crate into place: {e}"), - ); - } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name} from the patch service ({})", - archive.source_url - ), - )); - CargoServiceCopy::Used + // Extract the `.crate` (tar.gz; strip its single `{name}-{version}/` + // top-level dir) into a STAGE sibling and swap it into the copy dir only + // once fully verified — a failure then leaves any pre-existing copy + // untouched and no husk behind. + let stage = stage_dir_for(copy_dir); + // A tree the download plan already extracted from these bytes (see + // `prestage`) is moved into the stage instead; otherwise — or should the + // move fail — extract here, as always. + if !claim_prestaged(&mut archive, &stage, copy_dir).await { + let _ = remove_tree(&stage).await; + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot create {}: {e}", stage.display()), + ); } - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback (`ServiceArtifact`'s documented - // contract; nothing was extracted, so there is nothing to clean up). - ServiceArtifact::IntegrityMismatch(reason) => hard( - "vendor_prebuilt_integrity_mismatch", + let crate_bytes = std::mem::take(&mut archive.bytes); + if let Err(e) = extract_on_blocking_pool(crate_bytes, &stage, extract_tgz).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.hard( + "vendor_prebuilt_extract_failed", + format!("cannot extract the prebuilt crate: {e}"), + ); + } + } + let _ = tokio::fs::remove_file(stage.join(".cargo-checksum.json")).await; + // Verify the EXTRACTED TREE, not just the archive bytes: the SRI proves + // the download is intact, but an unexpected internal layout (the single + // `{name}-{version}/` strip leaving an extra wrapper, or an over-strip) + // lands the patched files at the wrong paths and the caller would + // synthesize success from `record.files` while the copy is wrong. Fail + // closed → `auto` falls back to the local build. (Mirrors + // composer_lock.rs.) + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.miss( + warnings, + "vendor_prebuilt_layout_mismatch", format!( - "prebuilt crate for {name} failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" + "prebuilt crate for {name} extracted to an unexpected \ + layout (patched files absent at their recorded paths)" ), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - "prebuilt crate is still building".to_string(), - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard( - "vendor_prebuilt_required", - format!("prebuilt crate unavailable: {reason}"), - ) - } else { - CargoServiceCopy::FallBack - } - } - ServiceArtifact::Failed(reason) => miss( + ); + } + // The copy's version carries the patch uuid tag, written in the stage so + // a swapped-in copy is never untagged. + if let Err(e) = cargo_tag::tag_copy_manifest(&stage, version, &record.uuid).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.miss( warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), + "vendor_prebuilt_layout_mismatch", + format!("prebuilt crate for {name}: cannot tag its version ({e})"), + ); } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted crate into place: {e}"), + ); + } + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {name} from the patch service ({})", + archive.source_url + ), + )); + CargoServiceCopy::Used(()) } /// Copy the pristine source into a STAGE sibling of `copy_dir`, run the @@ -1049,7 +1003,9 @@ pub async fn vendor_cargo_crate<'a>( ) .await { - CargoServiceCopy::Used => already_patched_result(purl, ©_dir, &record.files), + CargoServiceCopy::Used(()) => { + already_patched_result(purl, ©_dir, &record.files) + } CargoServiceCopy::HardFail(outcome) => return *outcome, CargoServiceCopy::FallBack => { match copy_and_patch( @@ -1195,7 +1151,7 @@ pub async fn vendor_cargo_crate<'a>( ) .await { - CargoServiceCopy::Used => { + CargoServiceCopy::Used(()) => { // The service crate is the patched package; trust its verified // integrity (every file reads as AlreadyPatched). already_patched_result(purl, ©_dir, &record.files) diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 58a575dd..7a1cd1e2 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -54,7 +54,9 @@ use crate::formats::composer::{composer_lock_packages, ComposerLockPackage}; use super::parse_memo::ParseMemo; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::{extract_on_blocking_pool, extract_zip}; -use super::service_fetch::{claim_prestaged, fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{ + claim_prestaged, fetch_verified_archive, ServiceAttempt, ServicePolicy, ServiceTerminal, +}; use super::source::PackageSource; use super::state::{ write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, @@ -306,7 +308,9 @@ pub async fn vendor_composer<'a>( ) .await { - ComposerServiceCopy::Used => already_patched_result(purl, ©_dir, &record.files), + ComposerServiceCopy::Used(()) => { + already_patched_result(purl, ©_dir, &record.files) + } ComposerServiceCopy::HardFail(outcome) => return *outcome, ComposerServiceCopy::FallBack => { match copy_and_patch( @@ -390,7 +394,7 @@ pub async fn vendor_composer<'a>( match composer_service_copy(service, record, &pkg, ©_dir, &uuid_dir, &mut warnings) .await { - ComposerServiceCopy::Used => already_patched_result(purl, ©_dir, &record.files), + ComposerServiceCopy::Used(()) => already_patched_result(purl, ©_dir, &record.files), ComposerServiceCopy::HardFail(outcome) => return *outcome, ComposerServiceCopy::FallBack => { match copy_and_patch( @@ -746,15 +750,9 @@ async fn copy_and_patch( Ok(result) } -/// Outcome of attempting to materialise the composer copy from the patch service. -enum ComposerServiceCopy { - /// The prebuilt dist zip was extracted into `copy_dir`. - Used, - /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). - HardFail(Box), - /// Fall back to copying + patching the installed package. - FallBack, -} +/// Outcome of attempting to materialise the composer copy from the patch +/// service (`Used`: the prebuilt dist zip was extracted into `copy_dir`). +type ComposerServiceCopy = ServiceAttempt<()>; /// Download the prebuilt dist zip, integrity-verify it, and extract it into /// `copy_dir` (dropping the zip's variable top-level dir). Maps each service @@ -774,120 +772,78 @@ async fn composer_service_copy( if !cfg.service_enabled() { return ComposerServiceCopy::FallBack; } - fn hard(code: &'static str, detail: String) -> ComposerServiceCopy { - ComposerServiceCopy::HardFail(Box::new(refused(code, detail))) - } - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - ComposerServiceCopy::FallBack - } + let policy = ServicePolicy::new(cfg, ServiceTerminal::Refused); + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + let subject = format!("dist zip for {pkg}"); + let mut archive = match policy.settle(fetched, "dist zip", &subject, warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, }; - match fetch_verified_archive(cfg, &record.uuid).await { - ServiceArtifact::Ready(mut archive) => { - // Extract into a STAGE sibling and swap it into the copy dir only - // once fully verified — a failure then leaves any pre-existing - // (possibly live-wired) copy and its marker untouched and no husk - // behind. - let stage = stage_dir_for(copy_dir); - // A tree the download plan already extracted from these bytes - // (see `prestage`) is moved into the stage instead; otherwise — - // or should the move fail — extract here. - if !claim_prestaged(&mut archive, &stage, copy_dir).await { - let _ = remove_tree(&stage).await; - if let Err(e) = tokio::fs::create_dir_all(&stage).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", stage.display()), - ); - } - // composer dist zips carry a single variable top-level dir. - let zip_bytes = std::mem::take(&mut archive.bytes); - if let Err(e) = extract_on_blocking_pool(zip_bytes, &stage, extract_dist_zip).await - { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return hard( - "vendor_prebuilt_extract_failed", - format!("cannot extract the prebuilt dist zip: {e}"), - ); - } - } - // Verify the EXTRACTED TREE, not just the archive bytes. The - // archive-bytes SRI (checked in fetch_verified_archive) proves - // the download is intact, but says nothing about whether the - // internal layout lands the patched files at the paths the - // record names: a zip with an unexpected wrapper dir (the - // single-level `strip_first` leaves an extra `pkg-/` - // segment) or a root-level `src/…` (over-stripped) extracts - // "successfully" with every file at the WRONG path, and the - // caller would ship a copy missing its patched files. Fail - // closed here and let the `auto` source fall back to the local - // build. - if !copy_matches_after_hashes(&stage, &record.files).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return miss( - warnings, - "vendor_prebuilt_layout_mismatch", - format!( - "prebuilt dist zip for {pkg} extracted to an \ - unexpected layout (patched files absent at their \ - recorded paths)" - ), - ); - } - if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { - cleanup_failed_stage(&stage, uuid_dir, false).await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot move the extracted dist into place: {e}"), - ); - } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {pkg} from the patch service ({})", - archive.source_url - ), - )); - ComposerServiceCopy::Used + // Extract into a STAGE sibling and swap it into the copy dir only + // once fully verified — a failure then leaves any pre-existing + // (possibly live-wired) copy and its marker untouched and no husk + // behind. + let stage = stage_dir_for(copy_dir); + // A tree the download plan already extracted from these bytes + // (see `prestage`) is moved into the stage instead; otherwise — + // or should the move fail — extract here. + if !claim_prestaged(&mut archive, &stage, copy_dir).await { + let _ = remove_tree(&stage).await; + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot create {}: {e}", stage.display()), + ); } - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback (`ServiceArtifact`'s documented contract). - ServiceArtifact::IntegrityMismatch(reason) => hard( - "vendor_prebuilt_integrity_mismatch", - format!( - "prebuilt dist zip for {pkg} failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" - ), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - "prebuilt dist zip is still building".to_string(), - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard( - "vendor_prebuilt_required", - format!("prebuilt dist zip unavailable: {reason}"), - ) - } else { - ComposerServiceCopy::FallBack - } + // composer dist zips carry a single variable top-level dir. + let zip_bytes = std::mem::take(&mut archive.bytes); + if let Err(e) = extract_on_blocking_pool(zip_bytes, &stage, extract_dist_zip).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.hard( + "vendor_prebuilt_extract_failed", + format!("cannot extract the prebuilt dist zip: {e}"), + ); } - ServiceArtifact::Failed(reason) => miss( + } + // Verify the EXTRACTED TREE, not just the archive bytes. The + // archive-bytes SRI (checked in fetch_verified_archive) proves + // the download is intact, but says nothing about whether the + // internal layout lands the patched files at the paths the + // record names: a zip with an unexpected wrapper dir (the + // single-level `strip_first` leaves an extra `pkg-/` + // segment) or a root-level `src/…` (over-stripped) extracts + // "successfully" with every file at the WRONG path, and the + // caller would ship a copy missing its patched files. Fail + // closed here and let the `auto` source fall back to the local + // build. + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.miss( warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), + "vendor_prebuilt_layout_mismatch", + format!( + "prebuilt dist zip for {pkg} extracted to an \ + unexpected layout (patched files absent at their \ + recorded paths)" + ), + ); } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_stage(&stage, uuid_dir, false).await; + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted dist into place: {e}"), + ); + } + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {pkg} from the patch service ({})", + archive.source_url + ), + )); + ComposerServiceCopy::Used(()) } /// Locate the package's entry: `packages[]` first, then `packages-dev[]`. diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 2d5dd4ef..078f452d 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -75,7 +75,7 @@ use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::{extract_gem_data, extract_on_blocking_pool}; use super::service_fetch::{ claim_prestaged, fetch_verified_archive, fetch_verified_secondary, SecondaryArtifactResult, - ServiceArtifact, + ServiceAttempt, ServicePolicy, ServiceTerminal, }; use super::source::PackageSource; use super::state::{ @@ -976,47 +976,15 @@ async fn gem_service_copy( }; // Step 1: the prebuilt `.gem` (sha512-verified against the reference). - let mut archive = match fetch_verified_archive(cfg, &record.uuid).await { - ServiceArtifact::Ready(archive) => archive, - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback (`ServiceArtifact`'s documented contract). - ServiceArtifact::IntegrityMismatch(reason) => { - return hard( - "vendor_prebuilt_integrity_mismatch", - format!( - "prebuilt .gem for {name} failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" - ), - ); - } - ServiceArtifact::Pending => { - return miss( - warnings, - "vendor_prebuilt_pending", - ("vendor_prebuilt_required", ""), - "prebuilt .gem is still building".to_string(), - false, - ); - } - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - return hard( - "vendor_prebuilt_required", - format!("prebuilt .gem unavailable: {reason}"), - ); - } + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + let subject = format!(".gem for {name}"); + let policy = ServicePolicy::new(cfg, ServiceTerminal::Refused); + let mut archive = match policy.settle::<()>(fetched, ".gem", &subject, warnings) { + Ok(archive) => archive, + Err(ServiceAttempt::HardFail(outcome)) => return GemServiceCopy::HardFail(outcome), + Err(ServiceAttempt::Used(()) | ServiceAttempt::FallBack) => { return GemServiceCopy::FallBack(None); } - ServiceArtifact::Failed(reason) => { - return miss( - warnings, - "vendor_prebuilt_unavailable", - ("vendor_prebuilt_required", ""), - format!("patch service request failed ({reason})"), - false, - ); - } }; // Step 2: the stub gemspec the converter generated alongside the `.gem`. diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 541b12fb..c54d16b8 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -36,7 +36,9 @@ use super::common::{ }; use super::path::vendor_uuid_dir_rel; use super::registry_fetch::{extract_on_blocking_pool, extract_zip_with_prefix}; -use super::service_fetch::{claim_prestaged, fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{ + claim_prestaged, fetch_verified_archive, ServiceAttempt, ServicePolicy, ServiceTerminal, +}; use super::source::PackageSource; use super::state::{ write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, @@ -281,7 +283,7 @@ pub async fn vendor_go_module<'a>( ) .await { - GoServiceRedirect::Used => { + GoServiceRedirect::Used(()) => { // No local apply to verify (the downloaded zip IS the patched // module), so every patched file reads as `AlreadyPatched` — trust // is the verified service integrity (sha512 + the `h1:` dirhash). @@ -496,15 +498,9 @@ pub async fn vendor_go_module<'a>( done(result, Some(entry), warnings) } -/// Outcome of attempting to materialise the go copy from the patch service. -enum GoServiceRedirect { - /// The prebuilt module zip was extracted and the `replace` wired. - Used, - /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). - HardFail(Box), - /// Fall back to copying + patching the pristine module source. - FallBack, -} +/// Outcome of attempting to materialise the go copy from the patch service +/// (`Used`: the prebuilt module zip was extracted and the `replace` wired). +type GoServiceRedirect = ServiceAttempt<()>; /// Download the prebuilt module zip, verify it (sha512 + the `h1:` dirhash, /// done by `fetch_verified_archive`), extract it into `copy_dir` (stripping its @@ -547,188 +543,108 @@ async fn go_service_redirect( if !cfg.service_enabled() || record.files.is_empty() { return GoServiceRedirect::FallBack; } - fn hard(code: &'static str, detail: String) -> GoServiceRedirect { - GoServiceRedirect::HardFail(Box::new(refused(code, detail))) - } - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - GoServiceRedirect::FallBack - } + let policy = ServicePolicy::new(cfg, ServiceTerminal::Refused); + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + let subject = format!("module zip for {module}"); + let mut archive = match policy.settle(fetched, "module zip", &subject, warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, }; - match fetch_verified_archive(cfg, &record.uuid).await { - ServiceArtifact::Ready(mut archive) => { - // Extract the module zip (strip its literal `{module}@{version}/` - // prefix) into a STAGE sibling of the copy dir and swap it into - // place only once verified — the cargo / composer / gem shape: a - // failed re-download never destroys a pre-existing copy the - // vendor `replace` still points at. - let stage = stage_dir_for(copy_dir); - let prefix = format!("{module}@{version}/"); - // A tree the download plan already extracted from these bytes - // (see `prestage`) is moved into the stage instead; otherwise — - // or should the move fail — extract here, as always. - if !claim_prestaged(&mut archive, &stage, copy_dir).await { - let _ = remove_tree(&stage).await; // a crashed earlier run's litter - if let Err(e) = tokio::fs::create_dir_all(&stage).await { - cleanup_failed_service_stage( - &stage, - project_root, - base_rel, - copy_dir, - module, - wired, - ) - .await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", stage.display()), - ); - } - let zip_bytes = std::mem::take(&mut archive.bytes); - let prefix_owned = prefix.clone(); - if let Err(e) = extract_on_blocking_pool(zip_bytes, &stage, move |b, d| { - extract_zip_with_prefix(b, d, &prefix_owned) - }) - .await - { - cleanup_failed_service_stage( - &stage, - project_root, - base_rel, - copy_dir, - module, - wired, - ) - .await; - return hard( - "vendor_prebuilt_extract_failed", - format!("cannot extract the prebuilt module zip: {e}"), - ); - } - } - // A `replace` target needs a go.mod declaring the module path; - // pre-modules zips may lack one — synthesize the minimal form. - if let Err(e) = ensure_module_go_mod(&stage, module).await { - cleanup_failed_service_stage( - &stage, - project_root, - base_rel, - copy_dir, - module, - wired, - ) - .await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot synthesize go.mod for the copy: {e}"), - ); - } - // Verify the EXTRACTED TREE before it replaces the copy or the - // consumer's go.mod is wired: the SRI proves the zip bytes are - // intact, but an unexpected internal layout (the - // `{module}@{version}/` prefix strip mismatching) lands the - // patched files at the wrong paths, and the caller would - // synthesize success from `record.files` while the copy is - // wrong. Fail closed → `auto` falls back to the local build; - // nothing points at the bad stage. (Mirrors composer_lock.rs.) - if !copy_matches_after_hashes(&stage, &record.files).await { - cleanup_failed_service_stage( - &stage, - project_root, - base_rel, - copy_dir, - module, - wired, - ) + // Extract the module zip (strip its literal `{module}@{version}/` + // prefix) into a STAGE sibling of the copy dir and swap it into + // place only once verified — the cargo / composer / gem shape: a + // failed re-download never destroys a pre-existing copy the + // vendor `replace` still points at. + let stage = stage_dir_for(copy_dir); + let prefix = format!("{module}@{version}/"); + // A tree the download plan already extracted from these bytes + // (see `prestage`) is moved into the stage instead; otherwise — + // or should the move fail — extract here, as always. + if !claim_prestaged(&mut archive, &stage, copy_dir).await { + let _ = remove_tree(&stage).await; // a crashed earlier run's litter + if let Err(e) = tokio::fs::create_dir_all(&stage).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) .await; - return miss( - warnings, - "vendor_prebuilt_layout_mismatch", - format!( - "prebuilt module zip for {module} extracted to an \ - unexpected layout (patched files absent at their \ - recorded paths)" - ), - ); - } - if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { - cleanup_failed_service_stage( - &stage, - project_root, - base_rel, - copy_dir, - module, - wired, - ) + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot create {}: {e}", stage.display()), + ); + } + let zip_bytes = std::mem::take(&mut archive.bytes); + let prefix_owned = prefix.clone(); + if let Err(e) = extract_on_blocking_pool(zip_bytes, &stage, move |b, d| { + extract_zip_with_prefix(b, d, &prefix_owned) + }) + .await + { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired) .await; - return hard( - "vendor_prebuilt_write_failed", - format!("cannot move the extracted module into place: {e}"), - ); - } - if let Err(e) = - go_mod_edit::ensure_replace_entry(project_root, module, version, base_rel, false) - .await - { - // The verified copy is in place. A wired run's directive - // already targets this uuid's (now refreshed) copy, so both - // stay consistent as they are; a first run has nothing - // pointing at the copy — tear the uuid dir down so no orphan - // survives the wire failure. - if !wired { - teardown_failed_service_copy(project_root, base_rel, module, false).await; - } - return hard( - "vendor_prebuilt_wire_failed", - format!("failed to update go.mod: {e}"), - ); - } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {module} from the patch service ({})", - archive.source_url - ), - )); - GoServiceRedirect::Used + return policy.hard( + "vendor_prebuilt_extract_failed", + format!("cannot extract the prebuilt module zip: {e}"), + ); } - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback (`ServiceArtifact`'s documented contract). - ServiceArtifact::IntegrityMismatch(reason) => hard( - "vendor_prebuilt_integrity_mismatch", + } + // A `replace` target needs a go.mod declaring the module path; + // pre-modules zips may lack one — synthesize the minimal form. + if let Err(e) = ensure_module_go_mod(&stage, module).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired).await; + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot synthesize go.mod for the copy: {e}"), + ); + } + // Verify the EXTRACTED TREE before it replaces the copy or the + // consumer's go.mod is wired: the SRI proves the zip bytes are + // intact, but an unexpected internal layout (the + // `{module}@{version}/` prefix strip mismatching) lands the + // patched files at the wrong paths, and the caller would + // synthesize success from `record.files` while the copy is + // wrong. Fail closed → `auto` falls back to the local build; + // nothing points at the bad stage. (Mirrors composer_lock.rs.) + if !copy_matches_after_hashes(&stage, &record.files).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired).await; + return policy.miss( + warnings, + "vendor_prebuilt_layout_mismatch", format!( - "prebuilt module zip for {module} failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" + "prebuilt module zip for {module} extracted to an \ + unexpected layout (patched files absent at their \ + recorded paths)" ), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - "prebuilt module zip is still building".to_string(), - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard( - "vendor_prebuilt_required", - format!("prebuilt module zip unavailable: {reason}"), - ) - } else { - GoServiceRedirect::FallBack - } + ); + } + if let Err(e) = swap_stage_into_place(&stage, copy_dir).await { + cleanup_failed_service_stage(&stage, project_root, base_rel, copy_dir, module, wired).await; + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot move the extracted module into place: {e}"), + ); + } + if let Err(e) = + go_mod_edit::ensure_replace_entry(project_root, module, version, base_rel, false).await + { + // The verified copy is in place. A wired run's directive + // already targets this uuid's (now refreshed) copy, so both + // stay consistent as they are; a first run has nothing + // pointing at the copy — tear the uuid dir down so no orphan + // survives the wire failure. + if !wired { + teardown_failed_service_copy(project_root, base_rel, module, false).await; } - ServiceArtifact::Failed(reason) => miss( - warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), + return policy.hard( + "vendor_prebuilt_wire_failed", + format!("failed to update go.mod: {e}"), + ); } + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {module} from the patch service ({})", + archive.source_url + ), + )); + GoServiceRedirect::Used(()) } /// Failure cleanup for the service legs (the vendor-side sibling of the diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index b7dc6040..3f98fb7d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2807,7 +2807,6 @@ async fn the_hosted_rewriters_own_output_reinventories() { sha256: Some("c".repeat(64)), ..Default::default() }, - berry_zip_url: None, registry_override: None, }; let rewritten = rewrite_registry_redirect( diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index db5105bf..02e55b99 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -15,9 +15,7 @@ use std::sync::Arc; use crate::constants::npm_family::{ BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, PNP_MARKERS, VLT_LOCK, }; -use crate::utils::fs::{ - read_regular_to_bytes, read_regular_to_string, read_regular_to_string_sync, -}; +use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; use crate::vendor::npm_flavor::NpmLockFlavor; use crate::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; use crate::formats::yarn::{sniff_grammar, YarnLockGrammar, UNIDENTIFIED_DETAIL}; @@ -53,22 +51,16 @@ impl MemoryProject { self.entries.insert(rel.into(), entry); } - pub fn insert_text(&mut self, rel: impl Into, text: impl Into>) { + #[cfg(test)] + pub(crate) fn insert_text(&mut self, rel: impl Into, text: impl Into>) { self.insert(rel, MemoryEntry::Text(text.into())); } - pub fn insert_binary(&mut self, rel: impl Into, bytes: impl Into>) { - self.insert(rel, MemoryEntry::Binary(bytes.into())); - } - - pub fn insert_present(&mut self, rel: impl Into) { + #[cfg(test)] + pub(crate) fn insert_present(&mut self, rel: impl Into) { self.insert(rel, MemoryEntry::Present); } - pub fn insert_symlink(&mut self, rel: impl Into) { - self.insert(rel, MemoryEntry::Symlink); - } - pub fn remove(&mut self, rel: &str) -> Option { self.entries.remove(rel) } @@ -92,11 +84,8 @@ impl MemoryProject { matches!(self.entries.get(rel), Some(MemoryEntry::Symlink)) } - pub fn paths(&self) -> impl Iterator { - self.entries.keys().map(String::as_str) - } - - pub fn entries(&self) -> impl Iterator { + #[cfg(test)] + pub(crate) fn entries(&self) -> impl Iterator { self.entries.iter().map(|(k, v)| (k.as_str(), v)) } @@ -202,7 +191,6 @@ type ReadCache = std::collections::HashMap, (io::ErrorK /// path hits the disk, later ones return the same content (or the same /// error). Everything that is not a content read (existence, file type, /// directory listings, the disk-only probes) goes to the disk directly. -/// [`DiskSnapshot::invalidate`] drops what a write may have changed. #[derive(Debug)] pub struct DiskSnapshot<'a> { pub root: &'a Path, @@ -217,11 +205,6 @@ impl<'a> DiskSnapshot<'a> { } } - /// Forget every cached read (after a write under `root`). - pub fn invalidate(&self) { - self.lock().clear(); - } - fn lock(&self) -> std::sync::MutexGuard<'_, ReadCache> { self.reads .lock() @@ -251,15 +234,6 @@ impl<'a> DiskSnapshot<'a> { self.remember(rel, &read); read } - - fn read_bytes_sync(&self, rel: &str) -> io::Result> { - if let Some(hit) = self.cached(rel) { - return hit.map(|b| b.to_vec()); - } - let read = crate::utils::fs::read_regular_to_bytes_sync(&self.root.join(rel)); - self.remember(rel, &read); - read - } } fn utf8(bytes: Vec) -> io::Result { @@ -276,17 +250,6 @@ pub enum ProjectView<'a> { } impl ProjectView<'_> { - /// The checkout root when the view reads a real filesystem (the disk - /// and snapshot variants), for the probes that only exist on disk. - pub fn disk_root(&self) -> Option<&Path> { - match self { - ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { - Some(root) - } - ProjectView::Memory(_) => None, - } - } - /// FIFO-safe regular-file text read. pub async fn read_text(&self, rel: &str) -> io::Result { match self { @@ -318,15 +281,6 @@ impl ProjectView<'_> { } } - /// Synchronous twin of [`Self::read_text`]. - pub fn read_text_sync(&self, rel: &str) -> io::Result { - match self { - ProjectView::Disk(root) => read_regular_to_string_sync(&root.join(rel)), - ProjectView::Memory(project) => project.read_text(rel), - ProjectView::Snapshot(snap) => snap.read_bytes_sync(rel).and_then(utf8), - } - } - /// `metadata` (follows links) succeeds. pub async fn exists(&self, rel: &str) -> bool { match self { @@ -619,7 +573,7 @@ mod tests { } #[tokio::test] - async fn a_snapshot_reads_each_file_once_until_invalidated() { + async fn a_snapshot_reads_each_file_once() { let tmp = tempfile::tempdir().unwrap(); std::fs::write(tmp.path().join("a.lock"), "one").unwrap(); let snap = DiskSnapshot::new(tmp.path()); @@ -628,7 +582,6 @@ mod tests { std::fs::write(tmp.path().join("a.lock"), "two").unwrap(); assert_eq!(view.read_text("a.lock").await.unwrap(), "one", "cached"); assert_eq!(view.read_bytes("a.lock").await.unwrap(), b"one"); - assert_eq!(view.read_text_sync("a.lock").unwrap(), "one"); let missing = view.read_text("b.lock").await.unwrap_err(); assert_eq!(missing.kind(), io::ErrorKind::NotFound); std::fs::write(tmp.path().join("b.lock"), "late").unwrap(); @@ -638,9 +591,5 @@ mod tests { "a cached miss stays a miss" ); assert!(view.exists("b.lock").await, "non-read probes go to disk"); - snap.invalidate(); - assert_eq!(view.read_text("a.lock").await.unwrap(), "two"); - assert_eq!(view.read_text("b.lock").await.unwrap(), "late"); - assert_eq!(view.disk_root(), Some(tmp.path())); } } diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 5d6fc529..38cfa6b6 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -97,7 +97,6 @@ pub(crate) mod test_support; mod toml_surgery; pub(crate) mod verify; pub mod vlt_lock; -#[allow(dead_code)] pub(crate) mod vlt_lock_text; pub(crate) mod yarn_berry_lock; pub(crate) mod yarn_classic_lock; diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 0c6e89f3..0fd4315e 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -31,7 +31,9 @@ use super::common::{ use super::npm_pack::{pack_deterministic, PackedTarball}; use super::path::vendor_uuid_dir_rel; use super::reuse; -use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{ + fetch_verified_archive, ServiceArtifact, ServiceAttempt, ServicePolicy, ServiceTerminal, +}; use super::source::PackageSource; use super::{RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; @@ -452,17 +454,9 @@ async fn reuse_committed_pack( // ───────────────────────── service-download path ───────────────────────── -/// Outcome of attempting the service-download fast path in [`stage_patch_pack`]. -enum ServicePackDecision { - /// Use the service artifact — the staged pack + a synthesized success. - /// Boxed: the pair is large relative to the other (small) variants. - Used(Box<(Option, ApplyResult)>), - /// Abort vendoring this package (a `service`-mode miss, or a downloaded - /// artifact we could not turn into a staged pack). - HardFail(Box), - /// Fall back to the local stage→patch→pack build. - FallBack, -} +/// Outcome of attempting the service-download fast path in [`stage_patch_pack`] +/// (`Used`: the staged pack + a synthesized success, boxed — the pair is large). +type ServicePackDecision = ServiceAttempt, ApplyResult)>>; /// Download + verify the prebuilt tarball and turn it into an [`NpmStagedPack`], /// mapping each service outcome onto the `auto` / `service` fallback policy. @@ -474,99 +468,60 @@ async fn try_service_pack( cfg: &VendorServiceConfig, warnings: &mut Vec, ) -> ServicePackDecision { - let hard_fail = - |detail: String| ServicePackDecision::HardFail(Box::new(done_failure(purl, detail))); - match fetch_verified_archive(cfg, &record.uuid).await { - // The SRI proves only that the transfer is intact: require the - // tarball to carry every patched file at its afterHash before - // reporting the package patched and wiring the lock to it. - ServiceArtifact::Ready(archive) - if !tgz_bytes_match_after_hashes(&archive.bytes, record) => - { - let reason = format!( + let policy = ServicePolicy::new(cfg, ServiceTerminal::Failure(purl)); + let archive = match fetch_verified_archive(cfg, &record.uuid).await { + // This backend's `service` refusal words a request failure differently. + ServiceArtifact::Failed(reason) if cfg.source.requires_service() => { + return policy.hard( + "vendor_prebuilt_required", + format!("patch service request failed: {reason}"), + ); + } + fetched => match policy.settle(fetched, "artifact", "artifact", warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, + }, + }; + // The SRI proves only that the transfer is intact: require the tarball to + // carry every patched file at its afterHash before reporting the package + // patched and wiring the lock to it. + if !tgz_bytes_match_after_hashes(&archive.bytes, record) { + return policy.miss( + warnings, + "vendor_prebuilt_layout_mismatch", + format!( "prebuilt tarball for {}@{} does not carry the patched files at their \ recorded paths", coords.name, coords.version - ); - if cfg.source.requires_service() { - hard_fail(reason) - } else { - warnings.push(VendorWarning::new( - "vendor_prebuilt_layout_mismatch", - format!("{reason}; building locally instead"), - )); - ServicePackDecision::FallBack - } - } - ServiceArtifact::Ready(archive) => { - match staged_pack_from_service_bytes( - purl, - project_root, - coords, - record, - &archive.bytes, - &archive.integrity_sri, - ) - .await - { - Ok(staged) => { - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {}@{} from the patch service ({})", - coords.name, coords.version, archive.source_url - ), - )); - // No local apply to verify — every patched file reads as - // `AlreadyPatched` (the tarball's members were checked - // against their afterHashes above). - let result = already_patched_result( - purl, - &project_root.join(&staged.rel_tgz), - &record.files, - ); - ServicePackDecision::Used(Box::new((Some(staged), result))) - } - Err(outcome) => ServicePackDecision::HardFail(outcome), - } - } - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback (`ServiceArtifact`'s documented contract). - ServiceArtifact::IntegrityMismatch(reason) => hard_fail(format!( - "prebuilt artifact failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" - )), - ServiceArtifact::Pending => { - if cfg.source.requires_service() { - hard_fail("prebuilt artifact is still building".to_string()) - } else { - warnings.push(VendorWarning::new( - "vendor_prebuilt_pending", - "prebuilt artifact is still building; building locally instead".to_string(), - )); - ServicePackDecision::FallBack - } - } - // The common, quiet miss: not built / free-only / not found. - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard_fail(format!("prebuilt artifact unavailable: {reason}")) - } else { - ServicePackDecision::FallBack - } - } - ServiceArtifact::Failed(reason) => { - if cfg.source.requires_service() { - hard_fail(format!("patch service request failed: {reason}")) - } else { - warnings.push(VendorWarning::new( - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason}); building locally instead"), - )); - ServicePackDecision::FallBack - } + ), + ); + } + match staged_pack_from_service_bytes( + purl, + project_root, + coords, + record, + &archive.bytes, + &archive.integrity_sri, + ) + .await + { + Ok(staged) => { + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {}@{} from the patch service ({})", + coords.name, coords.version, archive.source_url + ), + )); + // No local apply to verify — every patched file reads as + // `AlreadyPatched` (the tarball's members were checked against + // their afterHashes above). + let result = + already_patched_result(purl, &project_root.join(&staged.rel_tgz), &record.files); + ServicePackDecision::Used(Box::new((Some(staged), result))) } + Err(outcome) => ServicePackDecision::HardFail(outcome), } } diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index 1887f0ff..d8440495 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -34,7 +34,9 @@ use super::common::{already_patched_result, refused, service_offline_conflict}; use super::npm_common::{ declares_bundled_deps, done_failure, done_failure_unstage, guard_coordinates, }; -use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{ + fetch_verified_archive, ServiceAttempt, ServicePolicy, ServiceTerminal, +}; use super::source::PackageSource; use super::state::VENDOR_MARKER_FILE; use super::vlt_lock_text::vendored_dir_rel; @@ -611,7 +613,7 @@ pub(super) async fn stage_patch_dir( ) .await { - ServiceDir::Used => { + ServiceDir::Used(()) => { result = Some(already_patched_result(purl, &rel_abs, &record.files)); } ServiceDir::HardFail(outcome) => return Err(outcome), @@ -877,11 +879,7 @@ async fn tree_matches_after_hashes(stage: &Path, record: &PatchRecord) -> bool { true } -enum ServiceDir { - Used, - HardFail(Box), - FallBack, -} +type ServiceDir = ServiceAttempt<()>; /// The service fast path: the prebuilt tarball, integrity- and /// afterHash-verified, extracted into `stage` with its first path component @@ -896,75 +894,44 @@ async fn try_service_dir( version: &str, warnings: &mut Vec, ) -> ServiceDir { - let hard_fail = |detail: String| ServiceDir::HardFail(Box::new(done_failure(purl, detail))); - let fallback_or_fail = - |reason: String, code: &'static str, warnings: &mut Vec| { - if cfg.source.requires_service() { - hard_fail(reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - ServiceDir::FallBack - } - }; - match fetch_verified_archive(cfg, &record.uuid).await { - ServiceArtifact::Ready(archive) => { - let (bytes, dest) = (archive.bytes, stage.to_path_buf()); - let extracted = tokio::task::spawn_blocking(move || { - super::registry_fetch::extract_tgz_strict(&bytes, &dest) - }) - .await - .map_err(|e| e.to_string()) - .and_then(|r| r); - if let Err(e) = extracted { - return hard_fail(format!( - "prebuilt tarball for {name}@{version} is unsafe: {e}" - )); - } - if !tree_matches_after_hashes(stage, record).await { - let _ = remove_tree(stage).await; - return fallback_or_fail( - format!( - "prebuilt tarball for {name}@{version} does not carry the patched files \ - at their recorded paths" - ), - "vendor_prebuilt_layout_mismatch", - warnings, - ); - } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name}@{version} from the patch service ({})", - archive.source_url - ), - )); - ServiceDir::Used - } - ServiceArtifact::IntegrityMismatch(reason) => hard_fail(format!( - "prebuilt artifact failed integrity verification ({reason}); refusing to fall back \ - to a local build on tampered bytes" - )), - ServiceArtifact::Pending => fallback_or_fail( - "prebuilt artifact is still building".to_string(), - "vendor_prebuilt_pending", - warnings, - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard_fail(format!("prebuilt artifact unavailable: {reason}")) - } else { - ServiceDir::FallBack - } - } - ServiceArtifact::Failed(reason) => fallback_or_fail( - format!("patch service request failed ({reason})"), - "vendor_prebuilt_unavailable", + let policy = ServicePolicy::new(cfg, ServiceTerminal::Failure(purl)); + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + let archive = match policy.settle(fetched, "artifact", "artifact", warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, + }; + let (bytes, dest) = (archive.bytes, stage.to_path_buf()); + let extracted = tokio::task::spawn_blocking(move || { + super::registry_fetch::extract_tgz_strict(&bytes, &dest) + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r); + if let Err(e) = extracted { + return policy.hard( + "vendor_prebuilt_extract_failed", + format!("prebuilt tarball for {name}@{version} is unsafe: {e}"), + ); + } + if !tree_matches_after_hashes(stage, record).await { + let _ = remove_tree(stage).await; + return policy.miss( warnings, - ), + "vendor_prebuilt_layout_mismatch", + format!( + "prebuilt tarball for {name}@{version} does not carry the patched files \ + at their recorded paths" + ), + ); } + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {name}@{version} from the patch service ({})", + archive.source_url + ), + )); + ServiceDir::Used(()) } #[cfg(test)] diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index a0ffe424..e1e284f3 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -565,7 +565,6 @@ pub async fn lock_text_refusals( let nowhere = nowhere_buf.as_path(); let no_sources = PatchSources { blobs_path: nowhere, - packages_path: None, diffs_path: None, mem_blobs: None, }; @@ -783,7 +782,6 @@ mod lock_text_refusal_tests { let nowhere = root.join("not-installed"); let sources = PatchSources { blobs_path: &nowhere, - packages_path: None, diffs_path: None, mem_blobs: None, }; diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index f95d46ee..5b3cb18f 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -40,7 +40,9 @@ use super::pypi_wheel::{ WheelArtifact, }; use super::reuse; -use super::service_fetch::{fetch_verified_archive, ServiceArtifact}; +use super::service_fetch::{ + fetch_verified_archive, ServiceArtifact, ServiceAttempt, ServicePolicy, ServiceTerminal, +}; use super::source::PackageSource; use super::state::{ write_marker_or_warn, PdmMeta, PipenvMeta, PoetryMeta, UvMeta, VendorArtifact, VendorEntry, @@ -1874,15 +1876,9 @@ async fn acquire_patched_wheel( }) } -/// Outcome of attempting a pypi service download. -enum PypiServiceWheel { - /// Boxed: the wheel facts are large relative to the other variants. - Used(Box), - /// Bubble this terminal outcome (a `service`-mode miss, or a write failure). - HardFail(Box), - /// Fall back to the local build. - FallBack, -} +/// Outcome of attempting a pypi service download (the wheel facts boxed — +/// they are large). +type PypiServiceWheel = ServiceAttempt>; /// Download + verify the prebuilt wheel for `record.uuid`, mapping each service /// outcome onto the `auto` / `service` policy. Only `.whl` artifacts are usable @@ -1897,146 +1893,100 @@ async fn try_pypi_service_wheel( expected_pin: Option<&(String, String)>, warnings: &mut Vec, ) -> PypiServiceWheel { - // A terminal `service`-mode refusal (boxed — the enum's other variants are - // small). A nested fn so both `miss` and the write-failure sites can use it. - fn hard_fail(code: &'static str, detail: String) -> PypiServiceWheel { - PypiServiceWheel::HardFail(Box::new(refused(code, detail))) - } - // service-required → hard fail; `auto` → warn + fall back to the local build. - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard_fail("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - PypiServiceWheel::FallBack + let policy = ServicePolicy::new(cfg, ServiceTerminal::Refused); + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + // The client refused a non-wheel before downloading it. + if let ServiceArtifact::Unavailable(reason) = &fetched { + if reason == PYPI_NOT_A_WHEEL { + return policy.miss(warnings, "vendor_prebuilt_unavailable", reason.clone()); } + } + let archive = match policy.settle(fetched, "wheel", "wheel", warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, }; - - match fetch_verified_archive(cfg, &record.uuid).await { - ServiceArtifact::Ready(archive) => { - let Some(wheel_name) = wheel_filename_from_url(&archive.source_url) else { - return miss( - warnings, - "vendor_prebuilt_unavailable", - PYPI_NOT_A_WHEEL.to_string(), - ); - }; - // The SRI proves only that the transfer is intact. A wheel's - // members are site-packages-relative (the `record.files` keys), - // so require each patched file to carry its afterHash before - // reporting the package patched and pinning the lockfile to it. - if !archive - .prestaged - .zip_verdict(&record.files) - .unwrap_or_else(|| zip_bytes_match_after_hashes(&archive.bytes, &record.files)) - { - return miss( - warnings, - "vendor_prebuilt_layout_mismatch", - format!( - "prebuilt wheel for {base} does not carry the patched files at \ - their recorded paths" - ), - ); - } - let rel_wheel = format!("{uuid_dir_rel}/{wheel_name}"); - // Digested on first ask: pypi is the only backend that pins it. - let sha256_hex = archive.sha256_hex().to_string(); - // In-sync rebuild: the lockfile still pins the first vendor's - // wheel path + sha256, and a prebuilt wheel that differs would - // break every subsequent hash-checked install the moment vendor - // reports success. Checked BEFORE writing, so a mismatch leaves - // no poisoned artifact behind (`auto` falls back to the - // deterministic local build, which reproduces a local pin). - if let Some((pin_path, pin_sha)) = expected_pin { - if *pin_path != rel_wheel || *pin_sha != sha256_hex { - return miss( - warnings, - "vendor_prebuilt_pin_mismatch", - format!( - "the prebuilt wheel ({rel_wheel}, sha256 {sha256_hex}) does not \ - match the wheel the lockfile still pins ({pin_path}, sha256 \ - {pin_sha})" - ), - ); - } - } - let dest = project_root.join(uuid_dir_rel).join(&wheel_name); - if let Some(parent) = dest.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - return hard_fail( - "vendor_prebuilt_write_failed", - format!("cannot create {}: {e}", parent.display()), - ); - } - } - if let Err(e) = atomic_write_artifact(&dest, &archive.bytes).await { - return hard_fail( - "vendor_prebuilt_write_failed", - format!("cannot write the vendored wheel: {e}"), - ); - } - let (platform_locked, platform_tags_display) = - wheel_platform_from_filename(&wheel_name); - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored the wheel for {base} from the patch service ({})", - archive.source_url - ), - )); - PypiServiceWheel::Used(Box::new(AcquiredWheel { - rel_wheel, - result: already_patched_result(base, &dest, &record.files), - artifact: Some(WheelArtifact { - file_name: wheel_name.clone(), - sha256_hex, - size: archive.bytes.len() as u64, - }), - wheel_name, - platform_locked, - platform_tags_display, - })) - } - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback (`ServiceArtifact`'s documented contract). - ServiceArtifact::IntegrityMismatch(reason) => hard_fail( - "vendor_prebuilt_integrity_mismatch", + let Some(wheel_name) = wheel_filename_from_url(&archive.source_url) else { + return policy.miss( + warnings, + "vendor_prebuilt_unavailable", + PYPI_NOT_A_WHEEL.to_string(), + ); + }; + // The SRI proves only that the transfer is intact. A wheel's + // members are site-packages-relative (the `record.files` keys), + // so require each patched file to carry its afterHash before + // reporting the package patched and pinning the lockfile to it. + if !archive + .prestaged + .zip_verdict(&record.files) + .unwrap_or_else(|| zip_bytes_match_after_hashes(&archive.bytes, &record.files)) + { + return policy.miss( + warnings, + "vendor_prebuilt_layout_mismatch", format!( - "prebuilt wheel failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" + "prebuilt wheel for {base} does not carry the patched files at \ + their recorded paths" ), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - "prebuilt wheel is still building".to_string(), - ), - // The client refused a non-wheel before downloading it. - ServiceArtifact::Unavailable(reason) if reason == PYPI_NOT_A_WHEEL => { - miss(warnings, "vendor_prebuilt_unavailable", reason) + ); + } + let rel_wheel = format!("{uuid_dir_rel}/{wheel_name}"); + // Digested on first ask: pypi is the only backend that pins it. + let sha256_hex = archive.sha256_hex().to_string(); + // In-sync rebuild: the lockfile still pins the first vendor's + // wheel path + sha256, and a prebuilt wheel that differs would + // break every subsequent hash-checked install the moment vendor + // reports success. Checked BEFORE writing, so a mismatch leaves + // no poisoned artifact behind (`auto` falls back to the + // deterministic local build, which reproduces a local pin). + if let Some((pin_path, pin_sha)) = expected_pin { + if *pin_path != rel_wheel || *pin_sha != sha256_hex { + return policy.miss( + warnings, + "vendor_prebuilt_pin_mismatch", + format!( + "the prebuilt wheel ({rel_wheel}, sha256 {sha256_hex}) does not \ + match the wheel the lockfile still pins ({pin_path}, sha256 \ + {pin_sha})" + ), + ); } - // Quiet under `auto` (the common "not built / free-only" case). - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard_fail( - "vendor_prebuilt_required", - format!("prebuilt wheel unavailable: {reason}"), - ) - } else { - PypiServiceWheel::FallBack - } + } + let dest = project_root.join(uuid_dir_rel).join(&wheel_name); + if let Some(parent) = dest.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot create {}: {e}", parent.display()), + ); } - ServiceArtifact::Failed(reason) => miss( - warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), } + if let Err(e) = atomic_write_artifact(&dest, &archive.bytes).await { + return policy.hard( + "vendor_prebuilt_write_failed", + format!("cannot write the vendored wheel: {e}"), + ); + } + let (platform_locked, platform_tags_display) = wheel_platform_from_filename(&wheel_name); + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored the wheel for {base} from the patch service ({})", + archive.source_url + ), + )); + PypiServiceWheel::Used(Box::new(AcquiredWheel { + rel_wheel, + result: already_patched_result(base, &dest, &record.files), + artifact: Some(WheelArtifact { + file_name: wheel_name.clone(), + sha256_hex, + size: archive.bytes.len() as u64, + }), + wheel_name, + platform_locked, + platform_tags_display, + })) } /// Derive `(platform_locked, display)` from a wheel filename's trailing tag diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index 1639aee0..cbde546a 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -152,18 +152,117 @@ pub(crate) async fn claim_prestaged( } } -/// Outcome of attempting to materialise a single-file artifact from the patch -/// service (the Tier-A backends — maven `.jar`, nuget `.nupkg` — where the -/// verified archive bytes ARE the vendored artifact, written verbatim). -pub(crate) enum ServiceCopy { - /// The prebuilt patched bytes (write them verbatim). - Used(Vec), +/// Outcome of a backend's service fast path, mapped onto the `auto` / +/// `service` fallback policy by [`ServicePolicy`]. +pub(crate) enum ServiceAttempt { + /// The verified service artifact was used; `T` is what the backend made + /// of it. + Used(T), /// Bubble this terminal outcome (boxed — `VendorOutcome` is large). HardFail(Box), /// Fall back to the local rebuild. FallBack, } +/// The single-file outcome for the Tier-A backends (maven `.jar`, nuget +/// `.nupkg`): the prebuilt patched bytes, written verbatim. +pub(crate) type ServiceCopy = ServiceAttempt>; + +/// How a backend reports a terminal service failure. +#[derive(Clone, Copy)] +pub(crate) enum ServiceTerminal<'a> { + /// A [`VendorOutcome::Refused`] carrying the refusal code. + Refused, + /// The npm backends' failed `Done` for `purl` (the code is not reported). + Failure(&'a str), +} + +/// The `auto` / `service` fallback policy every service-backed backend +/// shares: `service` refuses every miss, `auto` warns (or, for a plain +/// `Unavailable`, stays quiet) and builds locally. Tampered bytes are always +/// terminal. +pub(crate) struct ServicePolicy<'a> { + requires_service: bool, + terminal: ServiceTerminal<'a>, +} + +impl<'a> ServicePolicy<'a> { + pub(crate) fn new(cfg: &VendorServiceConfig, terminal: ServiceTerminal<'a>) -> Self { + Self { + requires_service: cfg.source.requires_service(), + terminal, + } + } + + pub(crate) fn hard(&self, code: &'static str, detail: String) -> ServiceAttempt { + ServiceAttempt::HardFail(Box::new(match self.terminal { + ServiceTerminal::Refused => refused(code, detail), + ServiceTerminal::Failure(purl) => super::npm_common::done_failure(purl, detail), + })) + } + + /// `service` refuses with `reason`; `auto` warns under `code` and falls + /// back. + pub(crate) fn miss( + &self, + warnings: &mut Vec, + code: &'static str, + reason: String, + ) -> ServiceAttempt { + if self.requires_service { + self.hard("vendor_prebuilt_required", reason) + } else { + warnings.push(VendorWarning::new( + code, + format!("{reason}; building locally instead"), + )); + ServiceAttempt::FallBack + } + } + + /// The verified archive of a `Ready` outcome, or every other outcome + /// mapped onto the policy. `noun` names the artifact kind in messages + /// ("crate"); `subject` names this artifact ("crate for serde"). + pub(crate) fn settle( + &self, + artifact: ServiceArtifact, + noun: &str, + subject: &str, + warnings: &mut Vec, + ) -> Result> { + match artifact { + ServiceArtifact::Ready(archive) => Ok(archive), + // Bytes that fail integrity verification are an active tamper + // signal: ALWAYS a hard error, in `auto` exactly as in `service` + // — never a quiet local-build fallback ([`ServiceArtifact`]'s + // documented contract). + ServiceArtifact::IntegrityMismatch(reason) => Err(self.hard( + "vendor_prebuilt_integrity_mismatch", + format!( + "prebuilt {subject} failed integrity verification ({reason}); \ + refusing to fall back to a local build on tampered bytes" + ), + )), + ServiceArtifact::Pending => Err(self.miss( + warnings, + "vendor_prebuilt_pending", + format!("prebuilt {noun} is still building"), + )), + // The common, quiet miss: not built / free-only / not found. + ServiceArtifact::Unavailable(reason) if self.requires_service => Err(self.hard( + "vendor_prebuilt_required", + format!("prebuilt {noun} unavailable: {reason}"), + )), + ServiceArtifact::Unavailable(_) => Err(ServiceAttempt::FallBack), + ServiceArtifact::Failed(reason) => Err(self.miss( + warnings, + "vendor_prebuilt_unavailable", + format!("patch service request failed ({reason})"), + )), + } + } +} + /// Download + integrity-verify the prebuilt patched archive for the Tier-A /// backends, mapping each service outcome onto the `auto` / `service` fallback /// policy. `noun` is the artifact kind used in messages (".jar" / ".nupkg"). @@ -186,83 +285,39 @@ pub(crate) async fn service_archive_copy( if !cfg.service_enabled() { return ServiceCopy::FallBack; } - fn hard(code: &'static str, detail: String) -> ServiceCopy { - ServiceCopy::HardFail(Box::new(refused(code, detail))) - } - let miss = |warnings: &mut Vec, code: &'static str, reason: String| { - if cfg.source.requires_service() { - hard("vendor_prebuilt_required", reason) - } else { - warnings.push(VendorWarning::new( - code, - format!("{reason}; building locally instead"), - )); - ServiceCopy::FallBack - } + let policy = ServicePolicy::new(cfg, ServiceTerminal::Refused); + let fetched = fetch_verified_archive(cfg, &record.uuid).await; + let archive = match policy.settle(fetched, noun, noun, warnings) { + Ok(archive) => archive, + Err(attempt) => return attempt, }; - match fetch_verified_archive(cfg, &record.uuid).await { - // The SRI proves the download is intact, not that it carries the - // patch: the bytes are written verbatim and reported AlreadyPatched, - // so every patched member must hash to its afterHash first (the - // Tier-B backends' extracted-tree check). Fail closed → `auto` - // falls back to the local rebuild. - ServiceArtifact::Ready(archive) - if !archive - .prestaged - .zip_verdict(&record.files) - .unwrap_or_else(|| zip_bytes_match_after_hashes(&archive.bytes, &record.files)) => - { - miss( - warnings, - "vendor_prebuilt_layout_mismatch", - format!( - "prebuilt {noun} for {name} does not carry the patched files at their \ - recorded paths" - ), - ) - } - ServiceArtifact::Ready(archive) => { - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name} from the patch service ({})", - archive.source_url - ), - )); - ServiceCopy::Used(archive.bytes) - } - // Bytes that fail integrity verification are an active tamper signal: - // ALWAYS a hard error, in `auto` exactly as in `service` — never a - // quiet local-build fallback ([`ServiceArtifact`]'s documented - // contract). - ServiceArtifact::IntegrityMismatch(reason) => hard( - "vendor_prebuilt_integrity_mismatch", + // The SRI proves the download is intact, not that it carries the + // patch: the bytes are written verbatim and reported AlreadyPatched, + // so every patched member must hash to its afterHash first (the + // Tier-B backends' extracted-tree check). Fail closed → `auto` + // falls back to the local rebuild. + if !archive + .prestaged + .zip_verdict(&record.files) + .unwrap_or_else(|| zip_bytes_match_after_hashes(&archive.bytes, &record.files)) + { + return policy.miss( + warnings, + "vendor_prebuilt_layout_mismatch", format!( - "prebuilt {noun} failed integrity verification ({reason}); \ - refusing to fall back to a local build on tampered bytes" + "prebuilt {noun} for {name} does not carry the patched files at their \ + recorded paths" ), - ), - ServiceArtifact::Pending => miss( - warnings, - "vendor_prebuilt_pending", - format!("prebuilt {noun} is still building"), - ), - ServiceArtifact::Unavailable(reason) => { - if cfg.source.requires_service() { - hard( - "vendor_prebuilt_required", - format!("prebuilt {noun} unavailable: {reason}"), - ) - } else { - ServiceCopy::FallBack - } - } - ServiceArtifact::Failed(reason) => miss( - warnings, - "vendor_prebuilt_unavailable", - format!("patch service request failed ({reason})"), - ), + ); } + warnings.push(VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {name} from the patch service ({})", + archive.source_url + ), + )); + ServiceCopy::Used(archive.bytes) } /// Outcome of fetching + verifying a named secondary artifact. diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index 49c708f5..ca95185c 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -1436,12 +1436,6 @@ pub async fn restore_vlt_uuid_metadata( super::npm_dir::restore_uuid_metadata(&project_root.join(uuid_dir)).await } -/// Whether `text` passes the router sniff (a BOM-less JSON object -/// with `lockfileVersion` 0 or 1). -pub fn vlt_lock_sniff_ok(text: &str) -> bool { - sniff_vendor_lock(text).is_ok() -} - /// The importer package.json files of the project's canonical /// `vlt-lock.json`, project-relative: the root one plus every workspace /// importer its edges name. Just the root one when the lock is missing or diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index 214a47d0..e94386d9 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -779,10 +779,6 @@ impl EdgeEntry<'_> { self.value.rsplit_once(' ').map_or("", |(_, to)| to) } - pub(crate) fn entry_text(&self) -> String { - entry_text(self.key, self.raw_value) - } - pub(crate) fn sort_key(&self) -> EdgeSortKey<'_> { EdgeSortKey { from: self.from(), @@ -1999,7 +1995,7 @@ mod tests { assert_eq!(edge.entry.spec(), "^3.0.0 || ^4.0.0"); assert_eq!(edge.entry.target(), "~npm~js-tokens@4.0.0"); assert_eq!( - edge.entry.entry_text(), + entry_text(edge.entry.key, edge.entry.raw_value), "\"~npm~loose-envify@1.4.0 js-tokens\": \"prod ^3.0.0 || ^4.0.0 ~npm~js-tokens@4.0.0\"" ); let missing = parse_edge_line(" \"~npm~tap@15.2.3~peer.6f88d0ccf17dbbdc ts-node\": \"peerOptional >=8.5.2 MISSING\"") diff --git a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs index f720bfd8..4e22591a 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -137,7 +137,6 @@ fn hosted_override() -> DepOverride { token: TOKEN.to_string(), patch_uuid: UUID.to_string(), artifact_url: HOSTED_URL.to_string(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(HOSTED_SRI.to_string()), @@ -926,7 +925,6 @@ async fn berry_hosted_redirect_leaves_builtin_patch_entries_untouched() { token: TOKEN.to_string(), patch_uuid: UUID.to_string(), artifact_url: hosted_url.clone(), - berry_zip_url: None, registry_override: None, integrity: Integrity { yarn_berry10c0: Some( @@ -1009,7 +1007,6 @@ async fn berry_hosted_redirect_of_builtin_patched_package_skips_patch_entry() { token: TOKEN.to_string(), patch_uuid: UUID.to_string(), artifact_url: hosted_url.clone(), - berry_zip_url: None, registry_override: None, integrity: Integrity { yarn_berry10c0: Some(format!("10c0/{}", "f".repeat(128))), diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index ed2ed694..d20b08a5 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -887,7 +887,6 @@ mod tests { token: TOKEN.into(), patch_uuid: uuid.into(), artifact_url, - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(SRI.into()), diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 3dbfcc55..8c13232a 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -1042,7 +1042,6 @@ mod tests { token: TOKEN.into(), patch_uuid: uuid.into(), artifact_url: url, - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some(PNPM_SRI.into()), diff --git a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs index a4591bd5..cc11c8dc 100644 --- a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs +++ b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs @@ -181,7 +181,6 @@ async fn fetch_missing_sources_diff_mode_with_no_diffs_path() { std::fs::create_dir(&blobs).unwrap(); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: None, mem_blobs: None, }; @@ -581,7 +580,6 @@ async fn fetch_missing_sources_diff_downloads_and_writes_archive() { std::fs::create_dir(&diffs).unwrap(); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: Some(&diffs), mem_blobs: None, }; @@ -634,7 +632,6 @@ async fn fetch_missing_sources_diff_404_is_failure_with_kind_message() { std::fs::create_dir(&diffs).unwrap(); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: Some(&diffs), mem_blobs: None, }; @@ -677,7 +674,6 @@ async fn fetch_missing_sources_diff_invokes_progress_callback() { std::fs::create_dir(&diffs).unwrap(); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: Some(&diffs), mem_blobs: None, }; diff --git a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 460bb2e6..1e0bc614 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -238,7 +238,6 @@ async fn fetch_missing_sources_diff_uncreatable_archives_dir_is_per_archive_writ let diffs = notadir.join("diffs"); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: Some(&diffs), mem_blobs: None, }; @@ -469,7 +468,6 @@ async fn fetch_missing_sources_diff_disk_write_failure_is_per_archive_failure() let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: Some(&diffs), mem_blobs: None, }; diff --git a/crates/socket-patch-core/tests/covgap_patch_apply.rs b/crates/socket-patch-core/tests/covgap_patch_apply.rs index e04849ce..9056c458 100644 --- a/crates/socket-patch-core/tests/covgap_patch_apply.rs +++ b/crates/socket-patch-core/tests/covgap_patch_apply.rs @@ -60,7 +60,6 @@ async fn apply_foo(root: &Path, primary: &Path) -> socket_patch_core::patch::app ); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: None, mem_blobs: None, }; diff --git a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs index 5f712da1..b4eb5baa 100644 --- a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs +++ b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs @@ -66,7 +66,6 @@ async fn stage_tempdir_creation_failure_is_reported_not_fatal() { }; let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: None, mem_blobs: None, }; diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index d3fbc903..226b9b47 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -2172,7 +2172,6 @@ async fn apply_and_rollback_reach_every_pnpm_peer_variant_copy() { let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: None, mem_blobs: None, }; @@ -2282,7 +2281,6 @@ async fn apply_heals_unpatched_pnpm_twin_when_primary_already_patched() { ); let sources = PatchSources { blobs_path: &blobs, - packages_path: None, diffs_path: None, mem_blobs: None, }; @@ -3082,7 +3080,6 @@ async fn vlt_apply( use socket_patch_core::patch::apply::{apply_package_patch, MismatchPolicy, PatchSources}; let sources = PatchSources { blobs_path: &patch.blobs, - packages_path: None, diffs_path: None, mem_blobs: None, }; diff --git a/crates/socket-patch-core/tests/pnpm_hosted.rs b/crates/socket-patch-core/tests/pnpm_hosted.rs index 00f58eea..33a07f3a 100644 --- a/crates/socket-patch-core/tests/pnpm_hosted.rs +++ b/crates/socket-patch-core/tests/pnpm_hosted.rs @@ -15,7 +15,6 @@ fn dep(name: &str) -> DepOverride { token: "token".into(), patch_uuid: "patch-id".into(), artifact_url: "https://patch.example/left-pad-1.3.0.tgz".into(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha512: Some("sha512-PATCHED==".into()), diff --git a/crates/socket-patch-core/tests/poetry_hosted.rs b/crates/socket-patch-core/tests/poetry_hosted.rs index 80fae366..bd3ca3c8 100644 --- a/crates/socket-patch-core/tests/poetry_hosted.rs +++ b/crates/socket-patch-core/tests/poetry_hosted.rs @@ -29,7 +29,6 @@ fn patch() -> DepOverride { token: "7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e".into(), patch_uuid: "e828efa5-5c6d-43f3-9909-03f5ac232b98".into(), artifact_url: URL.into(), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some("a".repeat(64)), diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index 1b600a92..33c34297 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -5,7 +5,7 @@ //! and the home-dir redaction were uncovered. //! //! Hardening notes: every disable-gate test runs inside `with_clean_env`, -//! which scrubs ALL four disabling vars first. Each test then proves +//! which scrubs ALL three disabling vars first. Each test then proves //! *causation*, not mere correlation: //! 1. clean env => NOT disabled (kills an always-`true` impl + ambient //! `SOCKET_OFFLINE=1` masking the result), @@ -20,7 +20,6 @@ use socket_patch_core::telemetry::{is_telemetry_disabled, sanitize_error_message /// Scrubbing the full set is what makes the per-var causation asserts honest. const DISABLE_VARS: &[&str] = &[ "SOCKET_TELEMETRY_DISABLED", - "SOCKET_PATCH_TELEMETRY_DISABLED", "VITEST", "SOCKET_OFFLINE", ]; @@ -148,32 +147,6 @@ fn telemetry_not_disabled_when_vitest_is_not_literal_true() { }); } -#[test] -#[serial] -fn telemetry_disabled_legacy_socket_patch_var_honored() { - with_clean_env(|| { - assert!(!is_telemetry_disabled(), "baseline must be enabled"); - // Both accepted spellings of the legacy var must work on their own, - // with the new var name absent. - for v in ["1", "true"] { - std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v); - assert!( - std::env::var("SOCKET_TELEMETRY_DISABLED").is_err(), - "precondition: new var must be unset so legacy is the only cause" - ); - assert!( - is_telemetry_disabled(), - "legacy SOCKET_PATCH_TELEMETRY_DISABLED={v:?} must still disable" - ); - std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); - assert!( - !is_telemetry_disabled(), - "removing legacy var must re-enable telemetry" - ); - } - }); -} - #[test] #[serial] fn telemetry_disabled_when_socket_offline_eq_1() { diff --git a/crates/socket-patch-core/tests/uv_hosted.rs b/crates/socket-patch-core/tests/uv_hosted.rs index 81428d28..9a2526cd 100644 --- a/crates/socket-patch-core/tests/uv_hosted.rs +++ b/crates/socket-patch-core/tests/uv_hosted.rs @@ -13,7 +13,6 @@ fn patch(name: &str) -> DepOverride { token: "11111111-1111-4111-8111-111111111111".into(), patch_uuid: "22222222-2222-4222-8222-222222222222".into(), artifact_url: format!("https://patch.socket.dev/pkg/{name}-1.0.0-py3-none-any.whl"), - berry_zip_url: None, registry_override: None, integrity: Integrity { sha256: Some("a".repeat(64)), diff --git a/crates/socket-patch-core/tests/vlt_locks.rs b/crates/socket-patch-core/tests/vlt_locks.rs index e3d67008..ae77dfe1 100644 --- a/crates/socket-patch-core/tests/vlt_locks.rs +++ b/crates/socket-patch-core/tests/vlt_locks.rs @@ -547,7 +547,6 @@ fn stage(case: &Case, crlf: bool) -> Staged { async fn vendor(case: &Case, staged: &Staged) -> VendorOutcome { let sources = PatchSources { blobs_path: &staged.blobs, - packages_path: None, diffs_path: None, mem_blobs: None, };