diff --git a/CHANGELOG.md b/CHANGELOG.md index 3615d14f..5277041c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,8 @@ into the new version's section — see docs/releasing.md. (hosted-only and vendored projects; the truly-empty project keeps the "Manifest not found" exit 1, and a wired-but-ledgerless project errors naming `socket-patch repair`). Wet non-preserve runs confirm once - ("Roll back N patch(es), remove them from the local manifest, and delete - M vendored artifact(s)?" — auto-accepted under `--yes`/`--json`/non-TTY; + ("Roll back N patches, remove them from the local manifest, and delete + M vendored artifacts and their ledger records?" — auto-accepted under `--yes`/`--json`/non-TTY; declining prints "Rollback cancelled." and exits 0). Drift-keeps, hosted refusals/unsupported targets, corrupt ledgers, and a failed manifest write exit 1 `partial_failure`; not-installed entries still exit 0. @@ -78,16 +78,16 @@ into the new version's section — see docs/releasing.md. - **A plain `scan` without a TTY is report-only.** When stdin is not a TTY, `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, human-mode `scan` prints the - discovery report and the "To apply a patch, run: …" hint, downloads + discovery report and the "To apply a single patch, run: …" hint, downloads nothing, creates no `.socket/`, and exits 0 — it no longer auto-accepts the apply prompt. Any intent flag, `--yes`, or a TTY keeps the previous behavior; `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. Human `scan --mode hosted` now prints the results table and update - detection like the other modes and confirms once ("Redirect N package(s) + detection like the other modes and confirms once ("Redirect N packages to the hosted patch server?" — the same prompt as `get --mode hosted` — default yes, skipped by `--yes`/`--json`/`--dry-run`; on a non-TTY stdin - without `--yes` it prints `Non-interactive mode detected, proceeding with - default.` and proceeds), fetches patch details with the agent arm's + without `--yes` it prints `Non-interactive mode detected, proceeding + automatically.` and proceeds), fetches patch details with the agent arm's progress counter and per-package warnings, and an empty hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the redirect engine (was `Redirected 0 package(s)`); a discovery @@ -345,6 +345,29 @@ into the new version's section — see docs/releasing.md. ### Fixed +- **Terminal output is clean on every command.** Progress lines no longer + leave stale text behind (`scan` printed e.g. `Found 7 patches for 1 + packagesatch 7/7)`) or run into warnings printed while they are active. + Progress, prompts, color and truncation now share one implementation. + - **Progress lines:** a status line clears itself on finish. It is never + drawn off a TTY, under `TERM=dumb`, in debug mode, or under + `--json`/`--silent`. `fetch`, `vendor`, `setup`, lock waits and + `--update` checks now show progress instead of going quiet. + - **Prompts:** Ctrl-D at a `[Y/n]` prompt now declines instead of + accepting. Keys pressed while a scan is running no longer answer the + prompt that follows. The cursor is restored when a selection menu is + interrupted. + - **Color:** `NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE` and `TERM=dumb` are + honored. Colored table rows now align. + - **Wording:** counted nouns read `1 package` / `2 packages` instead of + `package(s)`. `Error:` / `Warning:` prefixes are consistent, and + warnings go to stderr. `--silent` is errors-only, but a failing run + still prints why. Output that came out in random order is now sorted. + `--help` pages no longer show developer notes. + - **Behavior fixes:** `get --dry-run` and `vex --dry-run` no longer write + anything, and `vex -O -` writes to stdout. `scan --json` never stops at + an interactive menu. API errors show the server's message instead of a + raw JSON body. - **Reversal leaves no `.socket/` residue.** `rollback`, `remove`, `vendor --revert`, the hosted unwind and the GC sweeps now prune what they empty: an emptied redirect or vendor ledger is deleted together with the @@ -374,7 +397,7 @@ into the new version's section — see docs/releasing.md. record for a purl the vendor ledger holds at another uuid carries `oldUuid` and the human `[fetch]` line reads `(replacing )`; `get --mode vendored --dry-run` prints `[dry-run] Would download and vendor - N patch(es).` on both identifier paths; the `[note]` and + N patches. No changes made.` on both identifier paths; the `[note]` and `Patch record saved to` lines are gone with the manifest. - **Agent-mode `get` leaves nothing behind when it records nothing.** `.socket/` and `.socket/blobs/` are created only when a record is diff --git a/Cargo.lock b/Cargo.lock index bfe94398..9dc76883 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -893,19 +893,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -1059,12 +1046,6 @@ dependencies = [ "libc", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "once_cell" version = "1.21.3" @@ -1118,12 +1099,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "portable-pty" version = "0.9.0" @@ -1685,12 +1660,12 @@ version = "4.0.0" dependencies = [ "base64", "clap", + "console", "dialoguer", "flate2", "fs2", "glob", "hex", - "indicatif", "libc", "portable-pty", "regex", @@ -1706,6 +1681,7 @@ dependencies = [ "tempfile", "tokio", "uuid", + "windows-sys 0.59.0", "wiremock", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index a94d6051..c1e95483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,8 +24,8 @@ tokio = { version = "=1.50.0", features = ["full"] } thiserror = "=2.0.18" walkdir = "=2.5.0" uuid = { version = "=1.21.0", features = ["v4"] } +console = "=0.15.11" dialoguer = "=0.11.0" -indicatif = "=0.17.11" tempfile = "=3.26.0" regex = "=1.12.3" glob = "=0.3.4" @@ -38,6 +38,7 @@ zip = { version = "=8.6.0", default-features = false, features = ["deflate"] } fs2 = "=0.4.3" same-file = "=1.0.6" libc = "=0.2.182" +windows-sys = "=0.59.0" semver = "=1.0.27" self-replace = "=1.5.0" wiremock = "=0.6.5" diff --git a/README.md b/README.md index 9c92d937..3d058c0e 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ socket-patch list ``` ``` -Found 1 patch(es): +Found 1 patch: Package: pkg:npm/flatted@3.3.1 UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 @@ -525,7 +525,7 @@ it finds. `scan` is the entry point for all three [patch modes](#three-patch-mod Without a mode, interactive `scan` prompts before applying (in a TTY — when stdin is not a TTY and neither `--yes` nor a mode/`--prune` flag is given, it is report-only: it prints what -it found plus the "To apply a patch, run: …" hint, writes nothing, and exits 0), and +it found plus the "To apply a single patch, run: …" hint, writes nothing, and exits 0), and `scan --json` is read-only (discovery plus an `updates[]` array; no mutation). `scan --mode agent --prune` is the single command bots need for full auto-update: it @@ -946,7 +946,7 @@ 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: apply the patch immediately without saving to the `.socket` folder. **Not yet implemented** — the command currently errors up front. | +| `--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. | > Authenticated lookups run against an org. The slug is auto-resolved from your token @@ -1002,7 +1002,7 @@ socket-patch list --json **Sample output:** ``` -Found 1 patch(es): +Found 1 patch: Package: pkg:npm/flatted@3.3.1 UUID: 5cac955f-eab1-4d29-8f4f-c408a6cc9647 @@ -1238,7 +1238,7 @@ socket-patch apply --json | jq '.status' When stdin is not a TTY (e.g. in CI pipelines), interactive prompts auto-proceed instead of blocking — with one deliberate exception: a plain `scan` (no `--mode`/`--apply`/`--sync`/ `--vendor`/`--prune` and no `--yes`) is report-only there. It prints what it found and the -"To apply a patch, run: …" hint, writes nothing, and exits 0; add `--yes` or a mode flag +"To apply a single patch, run: …" hint, writes nothing, and exits 0; add `--yes` or a mode flag to mutate. Progress indicators and ANSI colors are automatically suppressed when output is piped. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 16d99fd0..caaf2ae5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -81,7 +81,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments | `scan` | `--vendor` / `--detached` | — | Vendor every patched dependency instead of applying in place (`--vendor` == `--mode vendored`; conflicts with `--apply`/`--sync`, combines with `--prune`). Vendored mode is manifest-free (v5.0): the vendor ledger embeds the patch records and `.socket/manifest.json` is never written. `--detached` — the former opt-in for exactly that — is **hidden** and retained for compatibility as a no-op; it is still a usage error (exit 2) without vendored mode in either spelling | | `scan` | `--batch-size` | `SOCKET_BATCH_SIZE` | API batch chunk size (default `100`) | | `get`, `scan` | `--all-releases` | `SOCKET_ALL_RELEASES` | Download patches for every release/distribution variant of a matched package — PyPI wheel/sdist (`artifact_id`), RubyGems (`platform`), Maven (`classifier`) — not just the one(s) matching the locally-installed distribution. On `scan` this makes the stored manifest portable across environments (e.g. cross-platform CI caches). On `get` (v3.6) it ALSO disables the coarse installed-**version** narrowing of CVE/GHSA fan-outs (see "get --mode and installed narrowing"): every found version's patch is fetched, installed or not | -| `get` | positional `identifier`; `--id` / `--cve` / `--ghsa` / `--package` (`-p`); `--save-only` (alias `--no-apply`); `--one-off`; `--mode ` | `SOCKET_SAVE_ONLY`, `SOCKET_ONE_OFF` | Patch lookup + consumption mode (v3.6). `--mode` reuses scan's value enum (same hidden value aliases `host`/`redirect`/`vendor`; deliberately no env binding, matching scan). Default `agent` = today's save+apply flow, unchanged. `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | +| `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 `agent` = today's save+apply flow, unchanged. `--save-only` conflicts with `--mode hosted\|vendored` — rejected with **exit 1** via get's established self-enforced-conflict style (unlike scan's exit-2 mode conflicts; see the exit-code table) | | `remove` | positional `identifier`; `--skip-rollback`; `--preserve-state` (v5.0) | `SOCKET_SKIP_ROLLBACK`, `SOCKET_PRESERVE_STATE` | Manifest entry removal. `--preserve-state` is the single-patch twin of `rollback --preserve-state`: restore the tree and unwind the identifier's vendored/hosted wiring, but keep the manifest entry, the vendored artifact + ledger entry, and skip all GC. Combining it with `--skip-rollback` is a self-enforced usage error (exit 2): one flag keeps the tree and drops the state, the other restores the tree and keeps the state — together they select the do-nothing quadrant ("the combination would be a no-op: nothing would change"). The conflict fires whether either flag is spelled on the command line or sourced from its env var | | `rollback` | optional variadic positional `targets` (PURL \| UUID \| path glob); `--one-off`; `--preserve-state` (v5.0) | `SOCKET_ONE_OFF`, `SOCKET_PRESERVE_STATE` | Rollback scope. Multiple targets union. A token becomes a path glob ONLY when it is path-SHAPED — contains a separator (`/` or `\`) or a glob metacharacter (`*?[`), or starts with `./`, or is absolute; a `pkg:` prefix is a PURL and every other bare word keeps identifier (PURL/UUID) semantics, so a mistyped identifier or truncated UUID stays a safe exit-1 "No patch found matching identifier: X" (with a hint suggesting `./X` or `X/**` for directory targeting) instead of silently becoming a path scope. An unparseable glob is a usage error (exit 2) | | `vex` | `--output` / `-O`, `--product`, `--no-verify`, `--doc-id`, `--compact` | `SOCKET_VEX_OUTPUT`, `SOCKET_VEX_PRODUCT`, `SOCKET_VEX_NO_VERIFY`, `SOCKET_VEX_DOC_ID`, `SOCKET_VEX_COMPACT` | OpenVEX 0.2.0 document generation; see "vex output channels" below | @@ -98,13 +98,13 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Takeover reconciliation (npm family, bun included)**: vendoring over a hosted-redirected purl (`vendor`, `scan --mode vendored`, `get --mode vendored`) first REVERTS that purl's hosted lockfile edits to their pre-redirect registry values through the per-purl redirect revert, drops the purl's record + package edits from `redirect-state.json`, and then vendors — so the vendor ledger records the PRISTINE registry fragment as its wiring `original` and `vendor --revert` lands back on registry state, never on an expiring hosted URL. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; the human path prints `Warning (vendor_takeover_reverted_redirect): …`). `--dry-run` PROBES the same revert against an in-memory ledger clone instead of promising it: a clean probe reports `vendor_would_revert_redirect`, and a drifted lock or an undecidable ledger edit surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose hosted edits cannot be cleanly reverted fails `redirect_revert_failed` (exit 1 / `partial_failure`, nothing vendored for it, the hosted wiring left in place, the remedy in the detail). **bun** participates like every other npm-family flavor: binary `redirect_bun_lockb_package` snapshots are claimed by their recorded package identity and restore individual binary resolutions; its text `redirect_bun_lock_package` edits are claimed by the recorded line's spec — the registry spec `@`, or a hosted URL whose tarball leaf is `-.tgz` — so a sibling version's or an aliased sibling's edit is neither claimed nor a refusal, and only an edit that mentions the package without being a bun packages-entry line refuses (remedy: an unscoped `socket-patch rollback`, whose whole-ledger replay unwinds bun.lock hosted edits; never hand-edit the ledger). The same claim rule serves scoped `rollback ` / `remove ` of one of several hosted bun records (see "Hosted unwind coverage"). Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `redirect.warnings[]`) both work in place on bun locks the target mode accepts. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the per-purl hosted revert, so a hosted-redirected purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring, the redirect ledger and active Bun lock byte-untouched (exit 1 / `partial_failure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The separate run-level `vendor_supersedes_redirect` warning covers the reconcile-only case — a live lock that already proves vendored won over a stale hosted ledger record (the vendor wiring then holds the hosted-spliced fragment as `original`) — and fires exactly once, on the run that drops the stale records. -`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. +`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + the `updates` array + the `redirectState` state block below). No effect outside `--json` mode. The non-JSON path prompts the user interactively in a TTY; when stdin is NOT a TTY (CI, a pipe), `--yes` is absent, and no intent flag (`--mode`, `--apply`, `--sync`, `--vendor`, `--redirect`, `--prune`) is given, a human-mode `scan` is **report-only** (v5.0): it prints the discovery report and the existing "To apply a single patch, run: …" hint, downloads nothing, writes nothing (no `.socket/`), and exits 0. Any intent flag, `--yes`, or a TTY keeps the previous behavior (prompt in a TTY, auto-proceed otherwise). Only `scan` gained this pre-check — `rollback`/`remove`/`get`'s non-TTY auto-accept is unchanged. **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning — a hosted-wired project's report-only scan used to be byte-identical to a never-touched project's. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`. Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning (ownership_not_restored): ` (stderr, muted by `--silent`); never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (legacy manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — entries carrying an embedded `record` (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entr(y|ies): …` for `failedVendoredEntries`. `keptVendoredEntries` lists drift-kept entries the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so `revertableVendoredEntries` may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob, diff, and package-archive files from `.socket/`. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) and the runtime-gated maven/nuget crawlers with their gate off are exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (legacy manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — entries carrying an embedded `record` (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). The JSON `gc` sub-object gains `revertedVendoredEntries` + `keptVendoredEntries` + `failedVendoredEntries` + `removedVendorOrphanDirs` (wet) / `revertableVendoredEntries` + `vendorOrphanDirs` (preview), plus two ADDITIVE wet-only keys: `skipped: {code, message}` — present exactly when the pass was skipped at the lock (`lock_held` | `lock_io`; every count is then zero) — and `warnings: [{code, detail}]` — `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed) and `cleanup_failed` (an orphan sweep failed mid-way). Human mode prints `GC: skipped (): .`, one `GC: .` line per warning, and `GC: failed to revert N vendored 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. @@ -116,11 +116,11 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: optional variadic positional path globs scope DISCOVERY at the **purl level** — a package is in scope iff ANY of its crawled installed copies sits under a matching path, and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` with `--mode hosted` or `--mode vendored` is a usage error (exit 2, `resolve_mode_flags`: "path targeting … applies to agent-mode and read-only scans" — their lockfile rewiring is whole-project by construction); `PATHS` with `--apply`/`--sync`/`--prune`/`--global` is fine. Every scan JSON shape (success, zero-package, and error alike) gains an additive always-present `paths` key echoing the patterns verbatim (empty array when unscoped). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on `scan` (exit 0)**. -`scan --vendor` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and rebuilds committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest record(s) moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). JSON output gains a `download` sub-object — the detached download envelope `{found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (no `applied` field — nothing is applied in place; `detached: true` is pinned and always present; a `downloaded` record whose purl the ledger already holds at another uuid carries the additive `oldUuid` — the re-vendor the vendor step then performs — and its human `[fetch]` line reads ` (replacing )`) — and a `vendor` sub-object (a full vendor Envelope). Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per-patch `would_vendor` | `would_revendor` (+`oldUuid`) | `already_vendored` — plus, additive, `would_refuse` (+`errorCode`, `error`) for npm purls the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints `[would-refuse] (): ` lines behind the `--silent` gate). Interactive mode prompts "Download and vendor N patch(es)?". +`scan --vendor` 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), `repair` (health-checked and rebuilt from the ledger), `scan --prune` (lockfile-driven reconcile) and `setup --check`'s patch-consistency property (consulted from the embedded records). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches embedded-record entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). The hidden `--detached` flag (`scan --vendor --detached`) names exactly this — the only — vendored posture and is accepted as a no-op for compatibility. -`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N package(s) to the hosted patch server?`, default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 package(s)`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding with default.` to stderr and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 package(s)`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. +`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/` (and never quarantine: a `--dry-run` or a zero-grant wet run that finds a malformed `redirect-state.json` reports it as the hard error it is — exit 1, the repair-or-move-aside remedy — but moves nothing; only a run holding the lock moves it aside to `redirect-state.json.corrupt`); contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE the redirect ledger is read or written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: `scan --mode hosted` prints the results table and update detection like the other modes and confirms once — `Redirect N packages to the hosted patch server?` (singular for one), default yes, skipped by `--yes`/`--json`, on `--dry-run` (the engine honors the preview itself; nothing mutates), and when the detail fetch leaves nothing to redirect (that run enters the engine as a no-op — `Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/` — without prompting); without `--yes` on a non-TTY stdin the shared prompt prints `Non-interactive mode detected, proceeding automatically.` to stderr (unless `--silent`) and proceeds — before rewriting anything (parity with the agent/vendored arms and with `get --mode hosted`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine (previously `Redirected 0 packages; rewrote 0 files.`); a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). A malformed redirect ledger on a human hosted run that returns before the engine (empty discovery, nothing downloadable, a detail-fetch failure, a declined confirm) is surfaced there as the read-only `Warning: the redirect ledger … is malformed …` advisory (muted by `--silent`), never moved; the `--json` arm always enters the engine and hard-errors instead. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang where it used to skip silently, and the ledger's `redirect_nuget_source` edit records `action: "added"` when `nuget.config` was authored from scratch (`rewritten` otherwise). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock` / `bun.lockb`), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). @@ -135,12 +135,12 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **get --mode and installed narrowing (v3.6).** `get --mode hosted|vendored` consumes the resolved patch(es) through the SAME engines as `scan --mode hosted|vendored`, so for the same selected (purl, uuid) set the on-disk result is identical by construction — this is the per-advisory selector hosted/vendored previously lacked (the old workaround, `get --save-only` then `vendor`, still works but is superseded). **Agent mode (v5.0 lock + residue rules)**: the download phase runs under `<.socket>/apply.lock` and hands the guard to the nested apply, so download → manifest write → apply is one lock window (the nested apply never re-acquires and inherits every caller flag — `--lock-timeout` and `--verbose` included); a failed acquire is `{status: "error", errorCode: "lock_held" | "lock_io", error}` on get's legacy envelope, exit 1, before any fetch (a read-only `.socket/` fails here, naming the lock path). `.socket/` and `.socket/blobs/` are created only when a record is actually persisted — an all-skipped or all-failed run leaves no `.socket/` on a fresh project — and a same-uuid `get ` re-run rewrites neither the manifest nor the blobs. Semantics: -* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N package(s) to the hosted patch server?` (get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). -* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patch(es)?`; `--dry-run` prints `[dry-run] Would download and vendor N patch(es).` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. -* **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found patches:` listing deliberately shows ALL found patches (pre-narrowing, main's behavior) with the `[skip]` lines following; machine output (the prompt count, the JSON envelope) uses the kept set. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines. -* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Plain agent-mode `get` continues to ignore `--dry-run` (pre-existing; hosted/vendored honor it — see below). +* **Hosted** (`get GHSA-… --mode hosted`): resolves the advisory, then hands the selected (purl, uuid) pairs to scan's hosted engine — reference grants, cross-mode takeover pre-revert, lockfile rewrite, `redirect-state.json` ledger (merge-never-clobber), gem stale-install probe, warnings, confirmation rules (cargo via `confirmed_cargo_uuids` only) all identical to `scan --mode hosted`, and (v5.0) under the same `apply.lock` acquisition — taken around the first wet write, never on `--dry-run` or when nothing would be written; a failed acquire folds as top-level `errorCode: "lock_held" | "lock_io"` + string `error` (exit 1), and `--dry-run` under a held lock still exits 0. **No manifest write, no blobs** — the ledger is the persistence. JSON: get's legacy envelope gains the same nested `redirect` sub-object as scan's (`{mode:"hosted", redirected, rewrittenFiles, skipped, warnings, dryRun}`); the top-level shape is `{status, found, patches:[], warnings?}` — `downloaded`/`applied` are absent (nothing is downloaded into `.socket/`). Exit codes follow scan's hosted semantics: skipped grants and rewriter warnings never flip the exit; infra errors (reference fetch, corrupt/unwritable ledger, file writes) exit 1. Human prompt: `Redirect N packages to the hosted patch server?` (singular for one; get keeps its confirm gate, `--yes`/`--json`/non-TTY auto-accept as usual; as of v5.0 human `scan --mode hosted` prompts too — see the hosted section above). +* **Vendored** (`get GHSA-… --mode vendored`): the download phase is scan's vendored posture — **manifest-free (v5.0)**: the selected records are fetched into memory (`download_patch_records`; blobs held in memory; nothing under `.socket/` is written; the nested apply never runs), then scan's vendor step runs under the apply lock over exactly the selected records, like `scan --mode vendored` (no whole-manifest scope and no `[note]` about other records — that blast radius is retired with the manifest; a legacy manifest record for a vendored purl is migrated out of `.socket/manifest.json` the same way scan does it). JSON: get's envelope takes the detached download envelope's shape — `{status, found, downloaded, skipped, failed, detached: true, patches: [{purl, uuid, action: "downloaded" | "skipped" | "failed", …}], warnings?}` (`applied` is absent; `detached: true` is pinned; a `downloaded` record for a purl the vendor ledger holds at another uuid carries the additive `oldUuid`, derived from the ledger — the human `[fetch]` line reads ` (replacing )`) — and gains the nested `vendor` Envelope exactly like scan's `result["vendor"]`; a vendor-step error folds the partial envelope + `{status:"error", error:{code,message}}` in (a pre-failure takeover reconcile may have already mutated the ledger — its events must reach the consumer). Exit: download failures or vendor `has_errors` → `partial_failure`/1. Human prompt: `Download and vendor N patches?`; `--dry-run` prints `[dry-run] Would download and vendor N patches. No changes made.` on both identifier paths (uuid and search). Telemetry mirrors scan's vendored arms (`track_outcomes_for_vendor` / `track_patch_vendor_failed`). **Bun vendored preflight (additive)** — shared by `get --mode vendored` on both its paths and `scan --mode vendored`: before ANY patch download, and only when the selection holds a `pkg:npm/` purl, the download phase reads `bun.lock`/`bun.lockb` once (`preflight_vendor`) and, when the vendor backend would refuse the project — a malformed, unreadable or unsupported `bun.lockb` → `vendor_bun_lockb_invalid`; an unreadable `bun.lock` → `vendor_lockfile_missing`; a `lockfileVersion` other than 0/1/2 or a non-canonical `packages` grammar → `vendor_lockfile_version_unsupported`; `workspace:` packages in a lock below version 2 → `vendor_bun_workspace_unsupported` — every `pkg:npm/` result becomes `{action:"failed", errorCode:, error:}` with NO fetch (the patch view is never requested) and no patch record; other ecosystems' results are untouched. **Search path** (`get --mode vendored`) and `scan --mode vendored`: the records ride `patches[]` / `download.patches[]` with `downloaded: 0`, the download phase writes nothing under `.socket/` (v5.0 — a pre-existing `.socket/manifest.json`, including a record seeded for another purl, is left byte-untouched; previously the run re-serialized the manifest), the vendor step still runs over the remaining records (no event for the refused purl), exit `partial_failure`/1. **uuid path** (`get --mode vendored`): the uuid lookup is the only fetch; the run exits 1 BEFORE the vendor step with exactly `{status:"error", found:1, downloaded:0, skipped:0, failed:1, error:{code, message}, patches:[{purl, uuid, action:"failed", errorCode, error}]}` (the `error` OBJECT is the vendored-mode error shape of the vendor-step fold-in above) and writes nothing — no `.socket/` on a fresh project; human mode prints `Error (): ` on stderr. **Already-vendored exemption**: a purl is exempt from the workspace refusal only when every instance of its `name@version` in `bun.lock` is already a `.socket/vendor/npm/…` local tuple (any uuid; the digest-less 2-tuple counts) — the engine's own criterion — so in-sync re-runs, `repair`, and a superseding patch uuid on a project vendored before it grew a workspace member all flow to the engine (re-pinning an already-local tuple adds no workspace-relative exposure); a wiped ledger alone is not a refusal (the engine path decides). UUID equality in the ledger alone never exempts a purl: `rollback --preserve-state` retains its record after unwiring. Dry-run refusal takes priority over `already_vendored`. **Unreadable vendor ledger**: a `.socket/vendor/state.json` the preflight cannot read or parse is itself the refusal — `vendor_state_unreadable` with the io/parse detail, fail-closed (nothing is exempt) — on the uuid path, the search / `scan` path and the `--dry-run` preview alike; never a Bun lock code. **`--silent`** is "errors only" and never mutes the refusal: the code-tagged `[error] (): ` (per-patch paths) / `Error (): …` (uuid path) line stays on stderr with an empty stdout. **`--dry-run`** previews the refusal as the additive `would_refuse` action (see `--dry-run` below). Agent-mode `get --save-only` is NOT preflighted (record-only intent has no consumption precondition). Pinned by `tests/in_process_vendor_bun.rs` (exact uuid-path envelope, seeded-manifest survival, `--silent`, `--dry-run`) and `tests/scan_vendor_e2e.rs`. +* **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` records (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with the additive status **`not_installed`** (`{status:"not_installed", found:N, downloaded:0, applied:0, patches:[], warnings?}`) — never `no_match`, which remains pinned to the fuzzy package-name path. PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `not_installed` — consumers dispatch on the per-record `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `(release_narrowing)`-prefixed strings in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). +* **Deliberate divergences from scan** (documented, not drift): get keeps its `selection_required` JSON posture for free multi-patch PURLs (scan auto-picks); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--detached` (moot — `get --mode vendored` is manifest-free by construction), no `--prune`; get does not run scan's pre-confirm vendor baseline annotation; and an all-narrowed-out run exits `not_installed` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.x; it used to download, save and apply anyway): 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 (v3.6) `get --mode hosted|vendored` — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --apply` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.x) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no ledger write, no lockfile writes, `redirect.dryRun: true`); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`would_vendor` / `already_vendored` / `would_revendor`+`oldUuid` under the nested `vendor` key — plus, additive, `would_refuse` + `errorCode` + `error` for npm purls the wet run's Bun preflight would refuse: an in-sync `already_vendored` entry is exempt, as is a `would_revendor` entry whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted unwind (the redirect engines resolve every inverse and drift check exactly like a wet run, flush nothing to disk, and claim the IN-MEMORY ledger clone exactly like a wet run — so the composed preview, per-purl reverts then whole-ledger replay, sees the same intermediate state a wet run would; the ON-DISK ledger is untouched), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. The hidden alias `--no-apply` on `get --save-only` is **part of the contract** — it does not appear in `--help` but is widely used in existing scripts. @@ -158,7 +158,7 @@ Contract details: * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold setup files or vendored state) does **not** trigger VEX generation — there is nothing to attest. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install; the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `vendor_state_unreadable` (a corrupt `.socket/vendor/state.json` degraded to "nothing vendored", including under `--vex-no-verify`), `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened) and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. ### VEX provenance markers (contract) @@ -224,7 +224,7 @@ in particular, are behavior changes that gate a version bump when implemented). written, is reported as a `not persisting --exclude: — ` warning — never exit 1 — and a byte-identical exclude list neither locks nor rewrites. `--check` (property 4) reads the vendor ledger even without a manifest; a ledger it cannot read or parse is surfaced as a - `Warning: unreadable vendor state (…)` line (muted by `--silent`) plus a `vendor_ledger` `files[]` + `Warning: Unreadable vendor state (…)` line (muted by `--silent`) plus a `vendor_ledger` `files[]` entry with `status: error` — verdict `error`, exit 1 — never as a `configured` verdict. It never writes outside `--cwd` — no `$HOME`, no global `site-packages` (the Python `.pth` wheel is installed later by the user's package manager, not by `setup`; the gem patch stamp is written by the plugin at @@ -794,7 +794,7 @@ A bare `rollback` (or a scoped one, for its scope) restores the SYSTEM to unpatc 5. **Manifest cleanup** — entries are removed ONLY for in-scope purls whose legs fully succeeded, were not-installed, or were release-variant siblings narrowed away by an attempted variant that succeeded (half a variant group never lingers — `remove` parity); drift-kept and failed purls keep their records, and a failed variant holds its whole group. No-op removals never rewrite the file. A failed write surfaces as `manifest_write_failed` (warning + `partial_failure` exit 1; GC still runs against the unchanged manifest). 6. **GC** — `cleanup_unused_blobs` + diff/package-archive sweeps against the post-removal manifest, with beforeHash blobs pinned (synthetic afterHash-slot records) for (a) removed-but-not-installed entries (a crawler miss must not destroy the only local revert data — `remove` parity) and (b) EVERY entry remaining in the post-removal manifest — still-active patches (failed, drift-kept, eco-/path-excluded) keep their revert data, so a scoped or failed run never destroys the blobs a later rollback needs; only blobs referenced solely by genuinely-removed entries are swept. GC errors warn (`cleanup_failed`) and continue — they never affect the exit (repair's posture). -**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply: `[Roll back N patch(es) and remove them from the local manifest][, and delete M vendored artifact(s) and their ledger records][, and unwind H hosted redirect(s)]?` (clauses joined by `, and `, first letter capitalized; a hosted ledger with leftover edits but no records gets `replay K leftover hosted redirect edit(s)` instead of the unwind clause) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). +**Confirmation prompt.** A wet, non-preserve run with work prompts once, remove-style, composing only the clauses that apply into one English list (`a and b`, `a, b, and c`) with counted nouns: `Roll back N patches`, `remove them from the local manifest`, `delete M vendored artifacts and their ledger records`, `unwind H hosted redirects` (a hosted ledger with leftover edits but no records gets `replay K leftover hosted redirect edits` instead of the unwind clause; e.g. `Roll back 1 patch, remove it from the local manifest, and unwind 1 hosted redirect?`) — default yes, auto-accepted under `--yes`/`--json`/non-TTY (the shared `confirm` semantics; CI unaffected). Decline prints `Rollback cancelled.` and exits 0. `--dry-run` and `--preserve-state` runs are prompt-free (they delete no local state). ### `--preserve-state` (opt-out, both `rollback` and `remove`) @@ -849,7 +849,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf | Detected channel | Hint | |---|---| -| npm (`node_modules` path component) | `npm update -g @socketsecurity/socket-patch` | +| npm (`node_modules` path component) | project-local (the directory holding the outermost `node_modules` has a `package.json`, and it is not directly under `lib`/`npm` or below a yarn/pnpm `global` store): `npm install @socketsecurity/socket-patch@latest`; otherwise global (including version-manager prefixes such as nvm-windows and fnm): `npm update -g @socketsecurity/socket-patch` | | PyPI wheel (`site-packages`/`dist-packages`) | `pip install --upgrade socket-patch` | | `cargo install` (`$CARGO_HOME/bin`, `~/.cargo/bin`) | `cargo install socket-patch-cli` | | gem launcher cache (`/socket-patch/bin/…`) | `gem update socket-patch` | @@ -857,7 +857,7 @@ Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the conf **Pipeline order** (each step gates the next; a failure at any point leaves the installed binary untouched): fetch `SHA256SUMS` → fetch the archive (`socket-patch-.tar.gz`/`.zip`, explicit timeouts, size caps) → verify the SHA-256 **before** extraction → extract the single expected member → stage as an executable sibling **in the install directory** (`EACCES` here is the permissions preflight → exit 1 with a sudo hint; system temp is never used, so `noexec` mounts don't matter) → run the staged binary's `--version` self-check (against real GitHub the reported version must equal the release tag; under a `SOCKET_UPDATE_BASE_URL` override a mismatch only warns) → one atomic rename over the install path (mode-preserving; a **setuid/setgid** target — or, on Linux, one carrying **file capabilities** (`setcap`) — is refused, since an unprivileged swap cannot restore those grants; Windows uses the rename-dance via `self-replace`). Concurrent updates are single-flighted per environment by an advisory lock at `/update.lock` (`errorCode: update_in_progress`; the OS releases a dead holder's lock, so there is no stale-lock state). Two updaters whose state dirs diverge (e.g. different `$HOME`s targeting one shared `/usr/local/bin`) are not serialized, but every path to the destination is a whole-file rename and stage cleanup is age-gated — the worst case is duplicated work, never a torn binary. -**Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Non-fatal advisories ride the run-level `warnings[]` (`{code, detail}`, omitted when empty) — human runs print the same text to stderr as `Warning: `, and `--json` (which silences stderr) carries them here instead so an override is never silent: `managed_install_override` (a `--force` run replaced a package-manager-owned binary that manager's next upgrade will overwrite) and `update_warning` (a non-fatal note from the update engine, today the relaxed version self-check under a `SOCKET_UPDATE_BASE_URL` override). Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. +**Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Non-fatal advisories ride the run-level `warnings[]` (`{code, detail}`, omitted when empty) — human runs print the same text to stderr as `Warning: ` (first letter capitalized), and `--json` (which silences stderr) carries them here instead so an override is never silent: `managed_install_override` (a `--force` run replaced a package-manager-owned binary that manager's next upgrade will overwrite) and `update_warning` (a non-fatal note from the update engine, today the relaxed version self-check under a `SOCKET_UPDATE_BASE_URL` override). Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. **Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh and the launcher wrappers): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. @@ -870,7 +870,7 @@ Commands other than `--update` itself may print, on **stderr only**, after all c [socket-patch] Run `socket-patch --update` to upgrade (set SOCKET_NO_UPDATE_CHECK=1 to hide) ``` -The second line is channel-aware (an npm-managed install is pointed at `npm update -g …`, not at `--update`). Contract promises: +The notice is preceded by one blank line (it follows the command's own output, often an error). The second line is channel-aware (an npm-managed install is pointed at its npm upgrade command from the table above, not at `--update`). Contract promises: - At most one release-metadata fetch per 24 h (cached in the state file below; a failed fetch also counts), and at most one notice per 24 h while an update is pending. - Never under `--json`, `--silent`, `--offline`/`SOCKET_OFFLINE`, in CI (`CI`/`GITHUB_ACTIONS` env), when stderr is not a terminal, or when `SOCKET_NO_UPDATE_CHECK` is truthy. Silenced means **zero network I/O**, not just no output. @@ -1076,7 +1076,8 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `package_not_installed` | `skipped` | apply: manifest entry has no matching installed package. | | `apply_failed` | `failed` | apply: hash mismatch, write error, archive read error. | | `no_local_source` | `skipped`/`failed` | `--offline` and the patch is missing from `.socket/`. | -| `paid_required` | `failed` / status=`paidRequired` | get/scan: patch needs a paid plan and the caller's token isn't entitled. | +| `offline_missing_sources` / `sources_download_failed` | apply run-level `warnings[]` | apply (additive): the patch sources were unavailable — `--offline` with no local source, or the download left a patch with no source — so nothing was attempted. The envelope keeps its pinned shape (`partialFailure`, empty `events[]`, zero summary, no top-level `error`); the warning is its machine-readable reason (the human path prints the staging `Error:` line on stderr instead, even under `--silent`). | +| `paid_required` | `failed` / status=`paidRequired` | get/scan: patch needs a paid plan and the caller's token isn't entitled. `get ` on the public proxy reports it (exit 0) both for a `tier: "paid"` view and for the proxy's 403 refusal, whose record then carries only `uuid` + `tier` (the proxy never named the purl). | | `download_failed` | `failed` | repair/get: network or 404 on patch fetch. | | `cleanup_failed` | `skipped` (warning) | repair: an orphan-sweep pass (blobs, diff or package archives) failed mid-way (e.g. permission error). The run continues and exits 0; human mode carries the warning on stderr (not muted by `--silent`). v5.0: `rollback`'s default GC surfaces the same condition in its run-level `warnings[]` (and `remove`'s extended archive GC on stderr) — same posture, never affects the exit. | | `rollback_failed` | `failed` | remove/rollback: file restore could not complete. | @@ -1157,7 +1158,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | Code | Subcommands | Meaning | |-----------------------|----------------------------------|---------| -| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead). v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | +| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). v5.0: `list` reads the vendor ledger the same way — a vendored-only project (every `scan`/`get --mode vendored` project) lists its ledger entries' embedded records labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode — the twin of the hosted `Mode: hosted (recorded in .socket/vendor/redirect-state.json)` line — (`details.mode: "vendored"` + `details.ledger: ".socket/vendor/state.json"` in JSON), exit 0. All stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NONE of the three stores has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead); `list --json` carries it in the run-level `warnings[]` as `redirect_ledger_corrupt` instead of on stderr. v5.0: `rollback` likewise proceeds manifest-less when the vendor ledger or the redirect ledger holds work (its error is the legacy `{status: "error", error: "Manifest not found", path}` shape, not this envelope code); only the truly-empty project — all three stores absent — keeps the exit-1 error, and a project whose lockfiles still reference `.socket/vendor/` artifacts with NO vendor ledger gets a distinct error naming `socket-patch repair`. `remove` (v5.0) proceeds manifest-less whenever a vendor OR redirect ledger file exists (two existence probes before the lock; the stores themselves load under it): ANY vendor-ledger entry matching the identifier — detached or not — is removed through the ledger path (`--preserve-state` and drift-keeps behave exactly as on the manifest path), a hosted-only match unwinds its redirect, and when the ledgers exist but hold nothing for the identifier the error is `not_found` (exit 1), not this code — `manifest_not_found` fires from `remove` only when all three stores are absent. Manifest entries are removed in sorted purl order. | | `manifest_invalid` | list, remove | Manifest exists but is unparseable. | | `manifest_unreadable` | list, remove | I/O error reading manifest. | | `apply_failed` | apply | apply pipeline error before any patch ran. | @@ -1345,7 +1346,9 @@ patches:" listing, and the `selection_required` `options[]` array — so Free/unauthorized callers with more than one candidate for a PURL still get the interactive picker (or `selection_required` in `--json`); the ranking decides the presented order and hence the highlighted default, -not the outcome. +not the outcome. `--yes` answers the picker with that default without +showing it (the same pick a non-terminal run makes); `--json` keeps +`selection_required` even with `--yes`. One additive key may appear on `scan --json`'s `packages[].patches[]` entries, omitted when absent: `publishedAt`, present whenever the server @@ -1438,7 +1441,9 @@ The VEX document is JSON-LD, which collides with the standard `--json` envelope | set to `` | set | `` | stdout (full envelope, with one `verified` event per emitted subcomponent) | | unset | set | (error: `json_requires_output`, exit `2`) | stdout (envelope-only) | -When verification is enabled (the default) and a patch is omitted, the failed PURLs are surfaced on stderr in plain mode or as `skipped` events on the envelope in JSON mode. Status becomes `partialFailure` when at least one patch was omitted but at least one was emitted. +`--output -` means stdout (the first row). With `--dry-run`, a document bound for `--output` is built and verified but not written, a previous document at that path is left alone, the one-line summary reads `[dry-run] Would write OpenVEX document with N statements to `, and the envelope's `dryRun` is `true`. A written file ends with a newline, like the stdout form. + +When verification is enabled (the default) and a patch is omitted, the failed PURLs are surfaced on stderr in plain mode (one `Warning: omitting from VEX: ()` line each, sorted by PURL) or as `skipped` events on the envelope in JSON mode (same order; `errorCode` is the tag). Status becomes `partialFailure` when at least one patch was omitted but at least one was emitted. ## Semver policy @@ -1495,7 +1500,7 @@ launcher-gem legs are gated on the GitHub release — with its binaries and Every item in this document is locked in by at least one of: - **clap parser snapshots** in `crates/socket-patch-cli/tests/cli_parse_*.rs` — assert flag names, short forms, defaults, aliases, and CSV delimiters by calling `socket_patch_cli::Cli::try_parse_from(...)`. -- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, `format_severity`, `color`, and the JSON serializers. +- **Helper unit tests** in `crates/socket-patch-cli/src/**` (`#[cfg(test)] mod tests` blocks) — cover `looks_like_uuid`, `parse_argv_with_shortcuts`, `detect_identifier_type`, `select_patches`, `find_patches_to_rollback`, `partition_purls`, `verify_status_str`, the JSON serializers, and the terminal UI in `src/ui/` (`StatusLine` redraw/clear/`println` byte streams, `confirm_with` answers and non-interactive notes, `select_one`'s JSON/empty guards, `plural`, `truncate`, the `color_enabled` truth table, `paint`/`severity`, and `pad`/`strip_ansi` alignment). - **Async `run()` integration tests** in `tests/cli_parse_list.rs`, `tests/cli_parse_remove.rs`, `tests/cli_parse_setup.rs` — exercise the no-network error paths and assert JSON shape via `serde_json::from_str::` + per-key assertions. If you add a new flag/subcommand/JSON key, add a test here that locks the new surface in the same PR. diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 9075a09d..00046ebe 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -22,8 +22,8 @@ clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +console = { workspace = true } dialoguer = { workspace = true } -indicatif = { workspace = true } uuid = { workspace = true } regex = { workspace = true } glob = { workspace = true } @@ -34,6 +34,11 @@ tempfile = { workspace = true } # (`socket-patch scan | head -1`) die quietly instead of panicking. libc = { workspace = true } +[target.'cfg(windows)'.dependencies] +# ui::prompt flushes console typeahead before a confirm (the Unix twin is +# tcflush). Same major as console's own windows-sys, so no new crate. +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Console"] } + [features] # Every ecosystem (npm, PyPI, Ruby gems, Go, Cargo, NuGet, Maven, Composer, # Deno) is unconditionally compiled in AND enabled at runtime — there are no diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index c2fbdbf1..df137f10 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -41,9 +41,18 @@ fn parse_supported_ecosystem(s: &str) -> Result { .map(|e| e.cli_name()) .collect::>() .join(", "); - Err(format!( - "unsupported ecosystem `{s}` in this build (supported: {supported})" - )) + Err(unsupported_ecosystem_message(s, &supported)) + } +} + +/// The `--ecosystems` rejection text. An empty token (`--ecosystems ,npm`, +/// a trailing comma) gets its own wording: "unsupported ecosystem ``" +/// reads like a rendering glitch. +fn unsupported_ecosystem_message(token: &str, supported: &str) -> String { + if token.trim().is_empty() { + format!("empty ecosystem name in the list (supported: {supported})") + } else { + format!("unsupported ecosystem `{token}` (supported: {supported})") } } @@ -78,49 +87,63 @@ pub(crate) fn parse_bool_flag(s: &str) -> Result { } } -/// Arguments inherited by every subcommand via `#[command(flatten)]`. -/// -/// **Every** global flag is parseable on **every** subcommand. Commands that -/// don't use a given flag ignore it silently — e.g. `list --global` parses -/// fine and the `global` field is unused at runtime. +/// `--help` heading for every [`GlobalArgs`] flag, so each subcommand's own +/// flags (listed first, under "Options") are not buried among the ~24 shared +/// ones. Set per-arg rather than as the struct's `next_help_heading`: that +/// would leak onto the subcommand-local flags declared after the flatten. +const GLOBAL_OPTIONS: &str = "Global options"; + +// Arguments inherited by every subcommand via `#[command(flatten)]`. +// +// **Every** global flag is parseable on **every** subcommand. Commands that +// don't use a given flag ignore it silently — e.g. `list --global` parses +// fine and the `global` field is unused at runtime. +// +// (Plain `//` comments: clap turns a doc comment here into the `--help` +// description of any subcommand that has none of its own.) #[derive(Args, Debug, Clone)] pub struct GlobalArgs { /// Working directory. - #[arg(long, env = "SOCKET_CWD", default_value = ".")] + #[arg(help_heading = GLOBAL_OPTIONS, long, env = "SOCKET_CWD", default_value = ".")] pub cwd: PathBuf, /// Path to patch manifest file (resolved relative to --cwd). #[arg( + help_heading = GLOBAL_OPTIONS, long = "manifest-path", env = "SOCKET_MANIFEST_PATH", default_value = DEFAULT_PATCH_MANIFEST_PATH, )] pub manifest_path: String, - /// Socket API URL (authenticated endpoint) [default: - /// https://api.socket.dev]. No clap default: `None` lets the core - /// resolver fall through env and the socket-cli config file before - /// applying `DEFAULT_SOCKET_API_URL`. - #[arg(long = "api-url", env = "SOCKET_API_URL")] + /// Socket API URL (authenticated endpoint). Falls back to the socket-cli + /// config file, then https://api.socket.dev. + // + // No clap default: `None` lets the core resolver fall through env and the + // socket-cli config file before applying `DEFAULT_SOCKET_API_URL`. + #[arg(help_heading = GLOBAL_OPTIONS, long = "api-url", env = "SOCKET_API_URL")] pub api_url: Option, /// Socket API token. Absence selects the public patch proxy. - #[arg(long = "api-token", env = "SOCKET_API_TOKEN")] + #[arg(help_heading = GLOBAL_OPTIONS, long = "api-token", env = "SOCKET_API_TOKEN")] pub api_token: Option, /// Organization slug. Auto-resolved when omitted and a token is set. - #[arg(long = "org", short = 'o', env = "SOCKET_ORG_SLUG")] + #[arg(help_heading = GLOBAL_OPTIONS, long = "org", short = 'o', env = "SOCKET_ORG_SLUG")] pub org: Option, - /// Public proxy URL used when no API token is set [default: - /// https://patches-api.socket.dev]. No clap default, matching - /// `api_url` — resolution happens in `get_api_client_with_overrides`. - #[arg(long = "proxy-url", env = "SOCKET_PROXY_URL")] + /// Public patch proxy URL used when no API token is set + /// [default: https://patches-api.socket.dev]. + // + // No clap default, matching `api_url` — resolution happens in + // `get_api_client_with_overrides`. + #[arg(help_heading = GLOBAL_OPTIONS, long = "proxy-url", env = "SOCKET_PROXY_URL")] pub proxy_url: Option, /// Restrict to these ecosystems (comma-separated). Names that are not /// supported ecosystems are rejected. #[arg( + help_heading = GLOBAL_OPTIONS, long = "ecosystems", short = 'e', env = "SOCKET_ECOSYSTEMS", @@ -133,6 +156,7 @@ pub struct GlobalArgs { /// `diff` (default) fetches the smallest delta archive; `file` falls back /// to legacy per-file blobs. #[arg( + help_heading = GLOBAL_OPTIONS, long = "download-mode", env = "SOCKET_DOWNLOAD_MODE", default_value = "diff" @@ -143,9 +167,10 @@ pub struct GlobalArgs { /// (default) downloads the prebuilt archive from the patch.socket.dev /// vendoring service and silently falls back to a local build on any miss; /// `service` requires the service and fails closed; `build` always builds - /// locally (the pre-service behavior). Only `vendor` uses this; other - /// subcommands accept it silently. + /// locally. Only `vendor` and the vendored modes of `scan`/`get` use + /// this; other subcommands accept it silently. #[arg( + help_heading = GLOBAL_OPTIONS, long = "vendor-source", env = "SOCKET_VENDOR_SOURCE", default_value = "auto", @@ -153,24 +178,31 @@ pub struct GlobalArgs { )] pub vendor_source: String, - /// Base URL for the patch vendoring service's package-reference request - /// (the step-1 POST). Defaults to the active API base (`--api-url`) when + /// Base URL for the patch vendoring service. Defaults to the active API base (`--api-url`) when /// authenticated or the proxy base (`--proxy-url`) otherwise. Override to /// point `vendor` at staging / local dev independently of `--api-url`. - #[arg(long = "vendor-url", env = "SOCKET_VENDOR_URL")] + // A dev/testing knob: listed in `--help`, left out of the `-h` summary. + #[arg(help_heading = GLOBAL_OPTIONS, long = "vendor-url", env = "SOCKET_VENDOR_URL", hide_short_help = true)] pub vendor_url: Option, /// Override the host of the prebuilt-archive download URL the vendoring - /// service returns (the step-2 GET). When set, the CLI rewrites the + /// service returns. When set, the CLI rewrites the /// scheme + host (+ port) of the returned URL to this base, preserving the /// path. Mainly for local-dev / testing, where the host the server bakes /// into the URL is not the one to actually fetch from. - #[arg(long = "patch-server-url", env = "SOCKET_PATCH_SERVER_URL")] + // A dev/testing knob: listed in `--help`, left out of the `-h` summary. + #[arg( + help_heading = GLOBAL_OPTIONS, + long = "patch-server-url", + env = "SOCKET_PATCH_SERVER_URL", + hide_short_help = true + )] pub patch_server_url: Option, /// Strict airgap: never contact the network. Operations that need remote /// data fail loudly when this is set. #[arg( + help_heading = GLOBAL_OPTIONS, long, env = "SOCKET_OFFLINE", default_value_t = false, @@ -182,8 +214,10 @@ pub struct GlobalArgs { /// on-disk content matches neither the patch's beforeHash nor its /// afterHash is overwritten with the full verified patched content and /// surfaced as a stderr warning (`content_mismatch_overwritten`); this - /// flag restores the fail-closed behavior. `--force` overrides it. + /// flag restores the fail-closed behavior. On commands that have + /// `--force`, `--force` overrides it. #[arg( + help_heading = GLOBAL_OPTIONS, long, env = "SOCKET_STRICT", default_value_t = false, @@ -193,6 +227,7 @@ pub struct GlobalArgs { /// Operate on globally-installed packages. #[arg( + help_heading = GLOBAL_OPTIONS, long = "global", short = 'g', env = "SOCKET_GLOBAL", @@ -202,11 +237,12 @@ pub struct GlobalArgs { pub global: bool, /// Override the path used to discover globally-installed packages. - #[arg(long = "global-prefix", env = "SOCKET_GLOBAL_PREFIX")] + #[arg(help_heading = GLOBAL_OPTIONS, long = "global-prefix", env = "SOCKET_GLOBAL_PREFIX")] pub global_prefix: Option, /// Emit machine-readable JSON output. #[arg( + help_heading = GLOBAL_OPTIONS, long = "json", short = 'j', env = "SOCKET_JSON", @@ -217,6 +253,7 @@ pub struct GlobalArgs { /// Show extra detail in human-readable output. #[arg( + help_heading = GLOBAL_OPTIONS, long = "verbose", short = 'v', env = "SOCKET_VERBOSE", @@ -227,6 +264,7 @@ pub struct GlobalArgs { /// Suppress non-error output. #[arg( + help_heading = GLOBAL_OPTIONS, long = "silent", short = 's', env = "SOCKET_SILENT", @@ -237,6 +275,7 @@ pub struct GlobalArgs { /// Preview the operation without making any mutations. #[arg( + help_heading = GLOBAL_OPTIONS, long = "dry-run", env = "SOCKET_DRY_RUN", default_value_t = false, @@ -246,6 +285,7 @@ pub struct GlobalArgs { /// Skip interactive prompts. #[arg( + help_heading = GLOBAL_OPTIONS, long = "yes", short = 'y', env = "SOCKET_YES", @@ -254,21 +294,21 @@ pub struct GlobalArgs { )] pub yes: bool, - /// Seconds to wait for `<.socket>/apply.lock` before giving up. - /// Default (`None`) and `0` both mean a single non-blocking try - /// — failing immediately if another process holds the lock. A - /// positive value retries with a 100 ms backoff until the lock - /// frees or the budget elapses. Only meaningful for the lock- - /// contending subcommands (`apply`, `rollback`, `repair`, `remove`, - /// `vendor`, `setup --exclude`'s manifest write, and the hosted / - /// vendored modes of `scan`/`get`); other commands accept it - /// silently. Every holder removes the lock file on exit, so a - /// leftover from a crashed run never contends. - #[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] + /// Seconds to wait for the `.socket/apply.lock` lock before giving up. + /// By default (or with `0`) the lock is tried once, failing immediately + /// if another process holds it. A positive value retries with a 100 ms + /// backoff until the lock frees or the budget elapses. Only meaningful + /// for the commands that take the lock (`apply`, `rollback`, `repair`, + /// `remove`, `vendor`, `get` and `scan` when they record, apply, + /// vendor or redirect patches, and `setup --exclude`'s manifest write); + /// other commands accept it silently. Every holder removes the lock file on exit, so a leftover + /// from a crashed run never contends. + #[arg(help_heading = GLOBAL_OPTIONS, long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")] pub lock_timeout: Option, /// Emit verbose debug logs to stderr. #[arg( + help_heading = GLOBAL_OPTIONS, long = "debug", env = "SOCKET_DEBUG", default_value_t = false, @@ -278,6 +318,7 @@ pub struct GlobalArgs { /// Disable anonymous usage telemetry. #[arg( + help_heading = GLOBAL_OPTIONS, long = "no-telemetry", env = "SOCKET_TELEMETRY_DISABLED", default_value_t = false, @@ -285,14 +326,16 @@ pub struct GlobalArgs { )] pub no_telemetry: bool, - /// Hosted mode (`scan --mode hosted`): do NOT auto-configure + /// Hosted mode (`scan`/`get --mode hosted`): do NOT auto-configure /// `trustLockfile: true` in pnpm-workspace.yaml after a pnpm-lock.yaml /// (lockfileVersion >= 9) is repointed at the hosted patch server. /// pnpm >= 11 rejects the repointed lock without that trust grant, so /// opting out means every install needs `pnpm install --trust-lockfile` - /// instead (the run's warning spells out both recoveries). Only `scan` - /// reads this; other subcommands accept it silently. + /// instead (the run's warning spells out both recoveries). Only + /// hosted-mode `scan` and `get` read this; other subcommands accept it + /// silently. #[arg( + help_heading = GLOBAL_OPTIONS, long = "no-trust-lockfile-config", env = "SOCKET_NO_TRUST_LOCKFILE_CONFIG", default_value_t = false, @@ -586,6 +629,28 @@ mod tests { use super::*; use clap::Parser; + #[test] + fn ecosystem_rejection_messages() { + assert_eq!( + unsupported_ecosystem_message("rubygems", "npm, pypi"), + "unsupported ecosystem `rubygems` (supported: npm, pypi)" + ); + assert_eq!( + unsupported_ecosystem_message("", "npm, pypi"), + "empty ecosystem name in the list (supported: npm, pypi)" + ); + assert_eq!( + unsupported_ecosystem_message(" ", "npm"), + "empty ecosystem name in the list (supported: npm)" + ); + let err = parse_supported_ecosystem("bogus").unwrap_err(); + assert!( + err.starts_with("unsupported ecosystem `bogus` (supported: npm"), + "{err}" + ); + assert!(!err.contains("in this build"), "{err}"); + } + /// Minimal harness so we can exercise clap's parse + env-var resolution of /// `GlobalArgs` exactly as a real subcommand would (it is `flatten`ed). #[derive(Parser, Debug)] diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index bedeef21..57bf4130 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -30,6 +30,7 @@ use crate::json_envelope::{ AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, RunWarning, Status, VexSummary, }; +use crate::ui::{plural, StatusLine}; /// Files whose pre-apply content matched NEITHER hash and were (or would /// be) overwritten with the verified patched content — the promoted @@ -55,14 +56,42 @@ fn warn_mismatch_overwrites(result: &ApplyResult, common: &GlobalArgs) { } for file in mismatch_overwritten_files(result) { eprintln!( - "Warning (content_mismatch_overwritten): {} {file} did not match the patch's \ - expected original content; applied the full verified patched content instead \ - (pass --strict to fail on mismatches)", - normalize_purl(&result.package_key) + "{}", + format_mismatch_warning(&normalize_purl(&result.package_key), &file, common.dry_run) ); } } +/// The human stderr line for one mismatch-overwritten file. A dry run +/// wrote nothing, so it says what *would* happen. +fn format_mismatch_warning(purl: &str, file: &str, dry_run: bool) -> String { + let what = if dry_run { "would apply" } else { "applied" }; + format!( + "Warning (content_mismatch_overwritten): {purl} {file} did not match the patch's \ + expected original content; {what} the full verified patched content instead \ + (pass --strict to fail on mismatches)" + ) +} + +/// The JSON event detail for one mismatch-overwritten file (tense follows +/// `dry_run`, like [`format_mismatch_warning`]). +fn mismatch_event_detail(file: &str, dry_run: bool) -> String { + let what = if dry_run { "would be" } else { "was" }; + format!( + "{file} did not match the patch's expected original content; the full verified \ + patched content {what} applied" + ) +} + +/// `1 mismatched file` / `2 mismatched files`, with the verb agreeing. +fn mismatched_files_fail(n: usize) -> String { + if n == 1 { + "1 mismatched file will fail to apply".to_string() + } else { + format!("{n} mismatched files will fail to apply") + } +} + /// The default mismatch policy applies the FULL patched content for /// mismatched files — and the full content lives in the afterHash blob, /// which the default `--download-mode diff` may not have staged. Probe the @@ -90,39 +119,66 @@ async fn ensure_blobs_for_mismatches( if needed.is_empty() { return; } + let quiet = args.common.silent || args.common.json; if args.common.offline { - if !args.common.silent && !args.common.json { + if !quiet { eprintln!( - "Warning: {} mismatched file(s) need their full patched blob, but --offline \ - prevents fetching; those files will fail to apply", - needed.len() + "Warning: {} {} the full patched blob, but --offline prevents fetching; {}", + plural( + needed.len(), + "mismatched file needs", + "mismatched files need" + ), + if needed.len() == 1 { "its" } else { "their" }, + if needed.len() == 1 { + "that file will fail to apply" + } else { + "those files will fail to apply" + } ); } return; } - if !args.common.silent && !args.common.json { - eprintln!( - "Downloading {} full patched blob(s) for mismatched file(s)...", - needed.len() - ); - } // Apply is read-only against `.socket/`: when the stage step returned // direct `.socket/` paths (everything had a local source), the on-demand // blobs must go to a transient overlay, never `.socket/blobs/`. let Some(blobs_path) = staged.writable_blobs().await else { - if !args.common.silent && !args.common.json { + if !quiet { eprintln!( - "Warning: could not stage a transient blob directory; {} mismatched file(s) \ - will fail to apply", - needed.len() + "Warning: could not stage a transient blob directory; {}", + mismatched_files_fail(needed.len()) ); } return; }; - let _ = socket_patch_core::api::blob_fetcher::fetch_blobs_by_hash( + let mut status = StatusLine::stderr(args.common.json, args.common.silent); + status.set(format!( + "Downloading {} for mismatched files...", + plural(needed.len(), "full patched blob", "full patched blobs") + )); + let fetched = socket_patch_core::api::blob_fetcher::fetch_blobs_by_hash( &needed, blobs_path, client, None, ) .await; + status.finish_with(format_mismatch_fetch_result( + fetched.downloaded, + needed.len(), + )); +} + +/// The result line after fetching full blobs for mismatched files. +fn format_mismatch_fetch_result(downloaded: usize, needed: usize) -> String { + if downloaded == needed { + format!( + "Downloaded {} for mismatched files", + plural(needed, "full patched blob", "full patched blobs") + ) + } else { + format!( + "Downloaded {downloaded} of {} for mismatched files", + plural(needed, "full patched blob", "full patched blobs") + ) + } } /// Probe the crawled packages for `beforeHash` mismatches whose @@ -279,6 +335,29 @@ pub struct ApplyArgs { /// whole command exit non-zero even when patches applied cleanly. #[command(flatten)] pub vex: VexEmbedArgs, + + /// Set when `get` / `scan --apply/--sync` runs this apply as its last + /// step (`None` for the `apply` command itself). Not a CLI flag. + #[arg(skip)] + pub nested: Option, +} + +/// What a nested apply knows about the command that runs it. +#[derive(Clone, Copy, Debug, Default)] +pub struct NestedApply { + /// The caller runs under `--json`. The nested run itself is never JSON + /// (one envelope per command), but the caller's stdout is, so the + /// nested run's human error lines stay off stderr too. + pub caller_json: bool, +} + +impl ApplyArgs { + /// Whether human-readable error lines go to stderr: never under + /// `--json` (the envelope is the channel), always otherwise — errors + /// are exempt from `--silent`. + fn prints_errors(&self) -> bool { + !self.common.json && !self.nested.is_some_and(|n| n.caller_json) + } } // ── local-go redirect helpers ──────────────────────────────────────────────── @@ -371,7 +450,14 @@ async fn reconcile_local_go(common: &GlobalArgs, target_manifest_purls: &HashSet } else { "Removed" }; - println!("{verb} {} stale go patch redirect(s):", removed.len()); + println!( + "{verb} {}:", + plural( + removed.len(), + "stale Go patch redirect", + "stale Go patch redirects" + ) + ); for purl in &removed { println!(" {purl}"); } @@ -393,7 +479,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { Err(e) => { let msg = format!( "Patch redirect check could not read the manifest ({e}); \ - treating as drift (fail-closed)." + treating it as drift (fail-closed)." ); if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -402,7 +488,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } else { // Errors print even under --silent ("errors only", never // "nothing"): exit 1 with no message would be undiagnosable. - eprintln!("{msg}"); + eprintln!("Error: {msg}"); } return 1; } @@ -455,7 +541,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { if args.common.json { println!("{}", Envelope::new(Command::Apply).to_pretty_json()); } else if !args.common.silent { - println!("Patch redirects are in sync ({checked} checked)."); + println!("{}", format_check_in_sync(checked)); } 0 } else { @@ -472,7 +558,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } else { // Drift IS the error the exit code signals — it prints even // under --silent ("errors only", never "nothing"). - eprintln!("Patch redirects are OUT OF SYNC:"); + eprintln!("Error: Patch redirects are OUT OF SYNC:"); for (_, _, detail) in &drifts { eprintln!(" {detail}"); } @@ -482,9 +568,19 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } } -/// True when every file the engine verified for this package is already -/// at its `afterHash` — i.e. the patch is a complete no-op on disk. -/// +/// The `apply --check` success line. `--check` audits Go redirects only, +/// so a project with none says so instead of a vacuous "in sync". +fn format_check_in_sync(checked: usize) -> String { + if checked == 0 { + "No Go patch redirects to check.".to_string() + } else { + format!( + "Patch redirects are in sync ({} checked).", + plural(checked, "redirect", "redirects") + ) + } +} + /// Sentinel `package_path` for a result synthesized because the purl is /// owned by `socket-patch vendor` (recorded in `.socket/vendor/state.json`). /// `result_to_event` routes it to `Skipped`/`vendored` by exact equality. @@ -826,6 +922,8 @@ pub(crate) async fn run_locked( unmatched, run_warnings, fallback_skips, + targeted, + show_summary, }) => { let patched_count = results .iter() @@ -853,13 +951,53 @@ pub(crate) async fn run_locked( // the machine channel — same gating as scan's run warnings. if !args.common.json && !args.common.silent { for w in &run_warnings { - eprintln!("Warning ({}): {}", w.code, w.detail); + // Sources-unavailable codes restate the staging layer's + // own `Error:` diagnostic (already printed, even under + // --silent); they exist for the JSON envelope. + if !is_stage_failure_code(&w.code) { + eprintln!("Warning ({}): {}", w.code, w.detail); + } } for skip in &fallback_skips { eprintln!("Warning (gem_fallback_home_skipped): {}", skip.detail()); } } + // Human per-package report BEFORE the embedded VEX runs, so + // the VEX step's own notes follow the report instead of + // landing in the middle of it. Only the JSON envelope needs + // the VEX result first. + if !args.common.json && !args.common.silent { + let cwd = std::fs::canonicalize(&args.common.cwd) + .unwrap_or_else(|_| args.common.cwd.clone()); + let block = format_results_block(&results, args.common.dry_run, &cwd); + let mut block = block.iter().peekable(); + // A nested apply's caller already ended its stdout with a + // blank line (its listing or table), so the block's leading + // separator goes to stderr: one blank line on a pipe, the + // same spacing on a terminal. + if args.nested.is_some() && block.next_if(|l| l.is_empty()).is_some() { + eprintln!(); + } + for line in block { + println!("{line}"); + } + if args.common.verbose && !results.is_empty() { + print_verbose_verification(&results); + } + if show_summary { + let tally = tally_results(&results); + println!(); + if args.common.dry_run { + for line in format_dry_run_summary(&tally, unmatched.len()) { + println!("{line}"); + } + } else { + println!("{}", format_summary_line(&tally, targeted, unmatched.len())); + } + } + } + // Embedded VEX: only on a successful apply and only when // `--vex ` was passed. Re-read the manifest fresh so // verification observes the just-applied on-disk state. The @@ -896,10 +1034,7 @@ pub(crate) async fn run_locked( PatchEvent::new(PatchAction::Skipped, result.package_key.clone()) .with_reason( "content_mismatch_overwritten", - format!( - "{file} did not match the patch's expected original \ - content; the full verified patched content was applied" - ), + mismatch_event_detail(&file, args.common.dry_run), ), ); } @@ -933,8 +1068,9 @@ pub(crate) async fn run_locked( ); } // Run-level advisories (the gem config-root containment - // skip): the envelope's `warnings[]` is their machine - // channel — stderr is suppressed under --json. + // skip, the sources-unavailable reason): the envelope's + // `warnings[]` is their machine channel — stderr is + // suppressed under --json. env.warnings.extend(run_warnings.iter().cloned()); if !success { env.mark_partial_failure(); @@ -963,85 +1099,6 @@ pub(crate) async fn run_locked( None => {} } println!("{}", env.to_pretty_json()); - } else if !args.common.silent && !results.is_empty() { - // Vendor-owned synthesized results are `Skipped`/`vendored` - // in the JSON envelope — not appliable work — so keep them - // out of the human counts too ("N package(s) can be - // patched" must not count them). - let patched: Vec<_> = results - .iter() - .filter(|r| r.success && r.package_path != VENDOR_OWNED_MARKER) - .collect(); - let already_patched: Vec<_> = results - .iter() - .filter(|r| all_files_already_patched(r)) - .collect(); - - if args.common.dry_run { - // An already-patched package is `Skipped` in the JSON - // envelope, not `Verified`. Mirror that split here so - // "can be patched" excludes the no-ops instead of - // double-counting them against "already patched". - let can_be_patched = patched.len().saturating_sub(already_patched.len()); - println!("\nPatch verification complete:"); - println!(" {} package(s) can be patched", can_be_patched); - if !already_patched.is_empty() { - println!(" {} package(s) already patched", already_patched.len()); - } - } else { - println!("\nPatched packages:"); - for result in &patched { - if !result.files_patched.is_empty() { - // Summarize the per-file strategy used by this - // package: if everything came from the same - // source, show just that tag; otherwise list - // distinct sources. - let mut tags: Vec<&'static str> = - result.applied_via.values().map(|v| v.as_tag()).collect(); - tags.sort_unstable(); - tags.dedup(); - let suffix = if tags.is_empty() { - String::new() - } else { - format!(" (via {})", tags.join("+")) - }; - println!(" {}{}", normalize_purl(&result.package_key), suffix); - } else if all_files_already_patched(result) { - println!( - " {} (already patched)", - normalize_purl(&result.package_key) - ); - } - } - } - - if args.common.verbose { - println!("\nDetailed verification:"); - for result in &results { - println!(" {}:", result.package_key); - for f in &result.files_verified { - let status_str = match f.status { - VerifyStatus::Ready => "ready", - VerifyStatus::AlreadyPatched => "already patched", - VerifyStatus::HashMismatch => "hash mismatch", - VerifyStatus::NotFound => "not found", - }; - println!(" {} [{}]", f.file, status_str); - if let Some(ref msg) = f.message { - println!(" message: {msg}"); - } - if let Some(ref h) = f.current_hash { - println!(" current: {h}"); - } - if let Some(ref h) = f.expected_hash { - println!(" expected: {h}"); - } - if let Some(ref h) = f.target_hash { - println!(" target: {h}"); - } - } - } - } } // Human-readable VEX status (JSON mode already folded the @@ -1051,13 +1108,14 @@ pub(crate) async fn run_locked( Some(Ok(summary)) => { if !args.common.silent { println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, - args.vex - .vex - .as_ref() - .expect("vex_result is Some only when --vex was given") - .display(), + "{}", + crate::commands::vex::format_vex_written( + summary.statements, + args.vex + .vex + .as_ref() + .expect("vex_result is Some only when --vex was given"), + ) ); } } @@ -1068,8 +1126,18 @@ pub(crate) async fn run_locked( eprintln!("Error: VEX generation failed: {}", e.message); } None => { - if !args.common.silent && args.common.dry_run && args.vex.vex.is_some() { - println!("Skipping VEX generation (--dry-run: nothing was applied)."); + // Only a dry run that itself succeeded skips VEX + // *because* of --dry-run; a failed one would have + // skipped it anyway. + if !args.common.silent + && success + && args.common.dry_run + && args.vex.vex.is_some() + { + println!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("applied") + ); } } } @@ -1133,7 +1201,7 @@ async fn report_apply_failure( env.mark_error(EnvelopeError::new("apply_failed", error.to_string())); println!("{}", env.to_pretty_json()); } else { - eprintln!("Error: {error}"); + eprintln!("Error: {}", super::rollback::capitalize_first(error)); } 1 } @@ -1217,6 +1285,248 @@ struct ApplyOutcome { /// (best-effort class): one non-fatal `Skipped` event each in the /// envelope, one gated stderr line each on the human path. fallback_skips: Vec, + /// In-scope (`--ecosystems`-filtered) manifest patches: the human + /// summary's denominator. + targeted: usize, + /// Whether the run got far enough to print the human summary (not on + /// the empty-scope no-op or the sources-unavailable bail, which print + /// their own one-line outcome). + show_summary: bool, +} + +/// Run-warning code for the `--offline` sources-unavailable bail. +const OFFLINE_MISSING_SOURCES: &str = "offline_missing_sources"; +/// Run-warning code for the download-failed sources-unavailable bail. +const SOURCES_DOWNLOAD_FAILED: &str = "sources_download_failed"; + +/// The sources-unavailable codes: the staging layer already printed their +/// `Error:` line on the human path, so `run` keeps them for JSON only. +fn is_stage_failure_code(code: &str) -> bool { + code == OFFLINE_MISSING_SOURCES || code == SOURCES_DOWNLOAD_FAILED +} + +/// Why nothing could be applied when the patch sources are unavailable — +/// the `--json` envelope's only explanation for its empty, failing run +/// (the staging layer's stderr diagnostic is muted under `--json`). +fn stage_failure_warning(offline: bool) -> RunWarning { + if offline { + RunWarning { + code: OFFLINE_MISSING_SOURCES.to_string(), + detail: "one or more patches have no local source and --offline is set; run \ + `socket-patch repair` to download the missing artifacts" + .to_string(), + } + } else { + RunWarning { + code: SOURCES_DOWNLOAD_FAILED.to_string(), + detail: "some patch artifacts could not be downloaded, so no patches were applied" + .to_string(), + } + } +} + +/// Per-package counts for the human summary, keyed by manifest purl — so +/// two physical copies of one package count once and the "applied" +/// numerator can never exceed the targeted denominator. Vendor-owned +/// synthesized results (not appliable work) are left out. +#[derive(Debug, Default, PartialEq, Eq)] +struct ApplyTally { + /// Some copy had files patched (wet run). + applied: usize, + /// Not applied, and some copy was already fully patched. + already: usize, + /// Some copy could be patched (dry run: not already patched). + can_patch: usize, + /// Some copy failed. + failed: usize, + /// Owned by `socket-patch vendor` (its committed artifact is the + /// patch; apply does no work for it). + vendored: usize, +} + +fn tally_results(results: &[ApplyResult]) -> ApplyTally { + let mut by_purl: std::collections::BTreeMap<&str, Vec<&ApplyResult>> = + std::collections::BTreeMap::new(); + let mut vendored: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for r in results { + if r.package_path == VENDOR_OWNED_MARKER { + vendored.insert(r.package_key.as_str()); + continue; + } + by_purl.entry(r.package_key.as_str()).or_default().push(r); + } + let mut tally = ApplyTally { + vendored: vendored + .iter() + .filter(|k| !by_purl.contains_key(*k)) + .count(), + ..ApplyTally::default() + }; + for copies in by_purl.values() { + let applied = copies + .iter() + .any(|r| r.success && !r.files_patched.is_empty()); + let can_patch = copies + .iter() + .any(|r| r.success && !all_files_already_patched(r)); + let already = copies.iter().any(|r| all_files_already_patched(r)); + if applied { + tally.applied += 1; + } else if already && !can_patch { + tally.already += 1; + } + if can_patch { + tally.can_patch += 1; + } + if copies.iter().any(|r| !r.success) { + tally.failed += 1; + } + } + tally +} + +/// `Summary: 1 of 2 targeted patches applied, ...` (wet runs). The +/// failed and vendored buckets appear only when non-empty, so the counts +/// account for every targeted patch without cluttering the common case. +fn format_summary_line(tally: &ApplyTally, targeted: usize, not_found: usize) -> String { + let mut parts = vec![ + format!( + "{} of {} applied", + tally.applied, + plural(targeted, "targeted patch", "targeted patches") + ), + format!("{} already patched", tally.already), + ]; + if tally.vendored > 0 { + parts.push(format!("{} vendored", tally.vendored)); + } + if tally.failed > 0 { + parts.push(format!("{} failed", tally.failed)); + } + parts.push(format!("{not_found} not found on disk")); + format!("Summary: {}", parts.join(", ")) +} + +/// The dry-run summary block: what a wet run would do, per package. +fn format_dry_run_summary(tally: &ApplyTally, not_found: usize) -> Vec { + let mut lines = vec![ + "Patch verification complete:".to_string(), + format!( + " {} can be patched", + plural(tally.can_patch, "package", "packages") + ), + ]; + if tally.already > 0 { + lines.push(format!( + " {} already patched", + plural(tally.already, "package", "packages") + )); + } + if tally.failed > 0 { + lines.push(format!( + " {} cannot be patched", + plural(tally.failed, "package", "packages") + )); + } + if not_found > 0 { + lines.push(format!( + " {} not found on disk", + plural(not_found, "package", "packages") + )); + } + lines +} + +/// One `Patched packages:` line. `copy` names the physical copy when a +/// package has more than one (otherwise the lines are indistinguishable). +fn format_patched_line(purl: &str, copy: Option<&str>, detail: &str) -> String { + match copy { + Some(copy) => format!(" {purl} ({copy}, {detail})"), + None => format!(" {purl} ({detail})"), + } +} + +/// The wet run's `Patched packages:` block (empty when nothing would be +/// listed — no header over an empty list). Dry runs report through +/// [`format_dry_run_summary`] instead. +fn format_results_block(results: &[ApplyResult], dry_run: bool, cwd: &Path) -> Vec { + if dry_run { + return Vec::new(); + } + let mut copies: HashMap<&str, usize> = HashMap::new(); + for r in results { + *copies.entry(r.package_key.as_str()).or_default() += 1; + } + let mut lines: Vec = Vec::new(); + for result in results + .iter() + .filter(|r| r.success && r.package_path != VENDOR_OWNED_MARKER) + { + let detail = if !result.files_patched.is_empty() { + // Summarize the per-file strategy used by this package: if + // everything came from the same source, show just that tag; + // otherwise list distinct sources. + let mut tags: Vec<&'static str> = + result.applied_via.values().map(|v| v.as_tag()).collect(); + tags.sort_unstable(); + tags.dedup(); + if tags.is_empty() { + "patched".to_string() + } else { + format!("via {}", tags.join("+")) + } + } else if all_files_already_patched(result) { + "already patched".to_string() + } else { + continue; + }; + let copy = (copies + .get(result.package_key.as_str()) + .copied() + .unwrap_or(0) + > 1) + .then(|| super::rollback::display_copy_path(&result.package_path, cwd)); + lines.push(format_patched_line( + &normalize_purl(&result.package_key), + copy.as_deref(), + &detail, + )); + } + if lines.is_empty() { + return lines; + } + let mut block = vec![String::new(), "Patched packages:".to_string()]; + block.extend(lines); + block +} + +/// `--verbose`: every verified file with its status and hashes. +fn print_verbose_verification(results: &[ApplyResult]) { + println!("\nDetailed verification:"); + for result in results { + println!(" {}:", result.package_key); + for f in &result.files_verified { + let status_str = match f.status { + VerifyStatus::Ready => "ready", + VerifyStatus::AlreadyPatched => "already patched", + VerifyStatus::HashMismatch => "hash mismatch", + VerifyStatus::NotFound => "not found", + }; + println!(" {} [{}]", f.file, status_str); + if let Some(ref msg) = f.message { + println!(" message: {msg}"); + } + if let Some(ref h) = f.current_hash { + println!(" current: {h}"); + } + if let Some(ref h) = f.expected_hash { + println!(" expected: {h}"); + } + if let Some(ref h) = f.target_hash { + println!(" target: {h}"); + } + } + } } /// One gem-env fallback-home copy the fan-out skipped best-effort (a @@ -1283,8 +1593,10 @@ async fn apply_patches_inner( success: false, results: Vec::new(), unmatched: Vec::new(), - run_warnings: Vec::new(), + run_warnings: vec![stage_failure_warning(args.common.offline)], fallback_skips: Vec::new(), + targeted: target_manifest_purls.len(), + show_summary: false, }) } }; @@ -1315,6 +1627,8 @@ async fn apply_patches_inner( unmatched: Vec::new(), run_warnings: Vec::new(), fallback_skips: Vec::new(), + targeted: 0, + show_summary: false, }); } @@ -1398,19 +1712,17 @@ async fn apply_patches_inner( &matched_manifest_purls, &vendored_bases, ); - // This diagnostic flips the exit code, so it prints even under - // --silent ("errors only", never nothing — the hooked `apply - // --silent` used to exit 1 mutely here); `--json` mutes stderr and - // the envelope's `package_not_installed` events are the channel. - if !unmatched.is_empty() && !args.common.json { - eprintln!("Warning: No packages found that match available patches"); - eprintln!( - " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.", - unmatched.len() - ); - eprintln!( - " Check that packages are installed and --cwd points to the right directory." - ); + let mut unmatched = unmatched; + unmatched.sort(); + // This diagnostic flips the exit code, so it is an error — and it + // prints even under --silent ("errors only", never nothing — the + // hooked `apply --silent` used to exit 1 mutely here); `--json` + // mutes stderr and the envelope's `package_not_installed` events + // are the channel. + if !unmatched.is_empty() && args.prints_errors() { + for line in format_none_installed_error(&unmatched) { + eprintln!("{line}"); + } } return Ok(ApplyOutcome { success: unmatched.is_empty(), @@ -1418,6 +1730,8 @@ async fn apply_patches_inner( unmatched, run_warnings, fallback_skips, + targeted: target_manifest_purls.len(), + show_summary: true, }); } @@ -1454,7 +1768,12 @@ async fn apply_patches_inner( let mut applied_base_purls: HashSet = HashSet::new(); - for (purl, pkg_paths) in &all_packages { + // PURL order, so the per-package Error/Warning lines, the results and + // the `Patched packages:` block read the same on every run (the map is + // a `HashMap`). + let mut ordered_packages: Vec<_> = all_packages.iter().collect(); + ordered_packages.sort_unstable_by(|a, b| a.0.cmp(b.0)); + for (purl, pkg_paths) in ordered_packages { // The paths carry every resolved physical copy. Release-variant // ecosystems install one directory per `package@version` (the // variants are jars/wheels inside it) — EXCEPT gem, where bundler's @@ -1642,11 +1961,14 @@ async fn apply_patches_inner( // would leave a `failed` event in the envelope while the // command still reported `success` / exit 0. has_errors = true; - if !args.common.silent && !args.common.json { + // Errors print even under --silent. + if args.prints_errors() { eprintln!( - "Failed to patch {}: {}", - variant_purl, - result.error.as_deref().unwrap_or("unknown error") + "{}", + format_patch_failure( + variant_purl, + result.error.as_deref().unwrap_or("unknown error") + ) ); } results.push(result); @@ -1681,12 +2003,16 @@ async fn apply_patches_inner( // variant fails loudly instead of silently staying // vulnerable behind a sibling copy's success. has_errors = true; - if !attempted && !args.common.silent && !args.common.json { + if !attempted && args.prints_errors() { // No variant matched the installed distribution at all — // the package on disk isn't any known release variant. // (Attempted-but-failed variants already printed their own - // per-variant failure line above.) - eprintln!("Failed to patch {base_purl}: no matching variant found"); + // per-variant failure line above.) Errors print even + // under --silent. + eprintln!( + "{}", + format_patch_failure(&base_purl, "no matching variant found") + ); } } } @@ -1742,11 +2068,14 @@ async fn apply_patches_inner( warn_mismatch_overwrites(&result, &args.common); if !result.success { has_errors = true; - if !args.common.silent && !args.common.json { + // Errors print even under --silent. + if args.prints_errors() { eprintln!( - "Failed to patch {}: {}", - purl, - result.error.as_deref().unwrap_or("unknown error") + "{}", + format_patch_failure( + purl, + result.error.as_deref().unwrap_or("unknown error") + ) ); } } @@ -1757,50 +2086,36 @@ async fn apply_patches_inner( } // Check if targeted manifest entries had no matches. - let unmatched = unmatched_purls( + let mut unmatched = unmatched_purls( &target_manifest_purls, &matched_manifest_purls, &vendored_bases, ); + unmatched.sort(); - if !unmatched.is_empty() && !args.common.silent && !args.common.json { + let none_matched = !target_manifest_purls.is_empty() + && matched_manifest_purls.is_empty() + && !all_packages.is_empty(); + if none_matched { + // Nothing matched: this fails the run, so it is an error — and + // errors print even under --silent. + has_errors = true; + if args.prints_errors() { + for line in format_none_installed_error(&unmatched) { + eprintln!("{line}"); + } + } + } else if !unmatched.is_empty() && !args.common.silent && !args.common.json { eprintln!( - "\nWarning: {} manifest patch(es) had no matching installed package:", - unmatched.len() + "Warning: {} had no matching installed package:", + plural(unmatched.len(), "manifest patch", "manifest patches") ); for purl in &unmatched { eprintln!(" - {}", normalize_purl(purl)); } } - if !target_manifest_purls.is_empty() - && matched_manifest_purls.is_empty() - && !all_packages.is_empty() - { - if !args.common.silent && !args.common.json { - eprintln!("Warning: None of the targeted manifest patches matched installed packages."); - } - has_errors = true; - } - - // Post-apply summary - if !args.common.silent && !args.common.json { - let applied_count = results - .iter() - .filter(|r| r.success && !r.files_patched.is_empty()) - .count(); - let already_count = results - .iter() - .filter(|r| all_files_already_patched(r)) - .count(); - println!( - "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk", - applied_count, - target_manifest_purls.len(), - already_count, - unmatched.len() - ); - } + // The human summary is printed by `run`, after the per-package list. // Note: `apply` deliberately does NOT garbage-collect unused blobs in // `.socket/`. GC is the responsibility of `socket-patch repair` / @@ -1814,9 +2129,41 @@ async fn apply_patches_inner( unmatched, run_warnings, fallback_skips, + targeted: target_manifest_purls.len(), + show_summary: true, }) } +/// `Error: Failed to patch : ` (stderr, even under --silent). +fn format_patch_failure(purl: &str, why: &str) -> String { + format!("Error: Failed to patch {purl}: {why}") +} + +/// The failing "no targeted patch matched an installed package" report: +/// the error line, the unmatched purls, and the usual remedy. +fn format_none_installed_error(unmatched: &[String]) -> Vec { + let mut lines = vec![if unmatched.is_empty() { + "Error: None of the targeted manifest patches matched an installed package.".to_string() + } else if unmatched.len() == 1 { + "Error: The targeted manifest patch matched no installed package:".to_string() + } else { + format!( + "Error: None of the {} targeted manifest patches matched an installed package:", + unmatched.len() + ) + }]; + lines.extend( + unmatched + .iter() + .map(|p| format!(" - {}", normalize_purl(p))), + ); + lines.push( + "Check that the packages are installed and --cwd points to the right directory." + .to_string(), + ); + lines +} + #[cfg(test)] mod tests { //! Tests for `result_to_event` — the per-package → per-patch event @@ -2498,4 +2845,286 @@ mod tests { serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap(); assert_eq!(v["action"], "applied"); } + + // ── human output formatters ────────────────────────────────────────── + + fn copy_at(path: &str, status: VerifyStatus, patched: bool) -> ApplyResult { + let mut r = sample_applied(status); + r.package_key = "pkg:npm/nuxt@4.5.0".to_string(); + r.package_path = path.to_string(); + if !patched { + r.files_patched.clear(); + r.applied_via.clear(); + } + r + } + + #[test] + fn summary_line_singular_and_plural() { + let one = ApplyTally { + applied: 1, + ..ApplyTally::default() + }; + assert_eq!( + format_summary_line(&one, 1, 0), + "Summary: 1 of 1 targeted patch applied, 0 already patched, 0 not found on disk" + ); + let mixed = ApplyTally { + applied: 1, + already: 2, + ..ApplyTally::default() + }; + assert_eq!( + format_summary_line(&mixed, 4, 1), + "Summary: 1 of 4 targeted patches applied, 2 already patched, 1 not found on disk" + ); + assert_eq!( + format_summary_line(&ApplyTally::default(), 0, 0), + "Summary: 0 of 0 targeted patches applied, 0 already patched, 0 not found on disk" + ); + let failed = ApplyTally { + failed: 1, + ..ApplyTally::default() + }; + assert_eq!( + format_summary_line(&failed, 1, 0), + "Summary: 0 of 1 targeted patch applied, 0 already patched, 1 failed, 0 not found on disk" + ); + } + + #[test] + fn tally_counts_each_manifest_purl_once_across_copies() { + // Two physical copies of one purl, both patched: 1 applied, never + // "2/1 targeted patches applied". + let results = vec![ + copy_at("/p/node_modules/nuxt", VerifyStatus::Ready, true), + copy_at( + "/p/node_modules/vite/node_modules/nuxt", + VerifyStatus::Ready, + true, + ), + ]; + let t = tally_results(&results); + assert_eq!(t.applied, 1); + assert_eq!(t.already, 0); + assert_eq!(t.failed, 0); + assert!(format_summary_line(&t, 1, 0).starts_with("Summary: 1 of 1 targeted patch ")); + } + + #[test] + fn tally_already_patched_failed_and_vendored() { + let already = copy_at("/p/a", VerifyStatus::AlreadyPatched, false); + let mut failed = sample_applied(VerifyStatus::Ready); + failed.package_key = "pkg:npm/broken@1.0.0".into(); + failed.success = false; + failed.files_patched.clear(); + let mut vendored = sample_applied(VerifyStatus::Ready); + vendored.package_key = "pkg:npm/vend@1.0.0".into(); + vendored.package_path = VENDOR_OWNED_MARKER.into(); + let t = tally_results(&[already, failed, vendored]); + assert_eq!( + t, + ApplyTally { + applied: 0, + already: 1, + can_patch: 0, + failed: 1, + vendored: 1, + } + ); + assert_eq!( + format_summary_line(&t, 3, 0), + "Summary: 0 of 3 targeted patches applied, 1 already patched, 1 vendored, 1 failed, \ + 0 not found on disk" + ); + assert_eq!(tally_results(&[]), ApplyTally::default()); + } + + #[test] + fn dry_run_summary_lists_every_nonzero_bucket() { + let t = ApplyTally { + can_patch: 1, + already: 2, + failed: 1, + applied: 0, + vendored: 0, + }; + assert_eq!( + format_dry_run_summary(&t, 3), + vec![ + "Patch verification complete:", + " 1 package can be patched", + " 2 packages already patched", + " 1 package cannot be patched", + " 3 packages not found on disk", + ] + ); + // Zero buckets other than "can be patched" are omitted. + assert_eq!( + format_dry_run_summary(&ApplyTally::default(), 0), + vec![ + "Patch verification complete:", + " 0 packages can be patched" + ] + ); + } + + #[test] + fn results_block_omits_header_when_nothing_to_list() { + let mut failed = sample_applied(VerifyStatus::Ready); + failed.success = false; + failed.files_patched.clear(); + assert!(format_results_block(&[failed], false, Path::new("/p")).is_empty()); + assert!(format_results_block(&[], false, Path::new("/p")).is_empty()); + // Dry runs report through the verification block instead. + let ok = sample_applied(VerifyStatus::Ready); + assert!(format_results_block(&[ok], true, Path::new("/p")).is_empty()); + } + + #[test] + fn results_block_single_copy_has_no_path() { + let ok = sample_applied(VerifyStatus::Ready); + let already = { + let mut r = sample_applied(VerifyStatus::AlreadyPatched); + r.package_key = "pkg:npm/other@2.0.0".into(); + r.files_patched.clear(); + r.applied_via.clear(); + r + }; + assert_eq!( + format_results_block(&[ok, already], false, Path::new("/tmp")), + vec![ + "", + "Patched packages:", + " pkg:npm/minimist@1.2.2 (via diff)", + " pkg:npm/other@2.0.0 (already patched)", + ] + ); + } + + #[test] + fn results_block_names_each_copy_of_a_duplicated_purl() { + let results = vec![ + copy_at("/p/node_modules/nuxt", VerifyStatus::Ready, true), + copy_at( + "/p/node_modules/vite/node_modules/nuxt", + VerifyStatus::Ready, + true, + ), + ]; + assert_eq!( + format_results_block(&results, false, Path::new("/p")), + vec![ + "", + "Patched packages:", + " pkg:npm/nuxt@4.5.0 (node_modules/nuxt, via diff)", + " pkg:npm/nuxt@4.5.0 (node_modules/vite/node_modules/nuxt, via diff)", + ] + ); + } + + #[test] + fn patched_line_shapes() { + assert_eq!( + format_patched_line("pkg:npm/a@1", None, "via blob+diff"), + " pkg:npm/a@1 (via blob+diff)" + ); + assert_eq!( + format_patched_line("pkg:npm/a@1", Some("node_modules/a"), "already patched"), + " pkg:npm/a@1 (node_modules/a, already patched)" + ); + } + + #[test] + fn mismatch_messages_follow_dry_run_tense() { + assert_eq!( + format_mismatch_warning("pkg:npm/nuxt@4.5.0", "dist/index.mjs", false), + "Warning (content_mismatch_overwritten): pkg:npm/nuxt@4.5.0 dist/index.mjs did \ + not match the patch's expected original content; applied the full verified \ + patched content instead (pass --strict to fail on mismatches)" + ); + assert!(format_mismatch_warning("p", "f", true) + .contains("; would apply the full verified patched content instead")); + assert_eq!( + mismatch_event_detail("f.js", false), + "f.js did not match the patch's expected original content; the full verified \ + patched content was applied" + ); + assert!(mismatch_event_detail("f.js", true).ends_with("content would be applied")); + } + + #[test] + fn mismatch_fetch_and_fail_counts() { + assert_eq!( + format_mismatch_fetch_result(1, 1), + "Downloaded 1 full patched blob for mismatched files" + ); + assert_eq!( + format_mismatch_fetch_result(3, 3), + "Downloaded 3 full patched blobs for mismatched files" + ); + assert_eq!( + format_mismatch_fetch_result(1, 2), + "Downloaded 1 of 2 full patched blobs for mismatched files" + ); + assert_eq!( + mismatched_files_fail(1), + "1 mismatched file will fail to apply" + ); + assert_eq!( + mismatched_files_fail(2), + "2 mismatched files will fail to apply" + ); + } + + #[test] + fn check_in_sync_line() { + assert_eq!(format_check_in_sync(0), "No Go patch redirects to check."); + assert_eq!( + format_check_in_sync(1), + "Patch redirects are in sync (1 redirect checked)." + ); + assert_eq!( + format_check_in_sync(3), + "Patch redirects are in sync (3 redirects checked)." + ); + } + + #[test] + fn failure_and_none_installed_errors() { + assert_eq!( + format_patch_failure("pkg:npm/a@1", "File not found"), + "Error: Failed to patch pkg:npm/a@1: File not found" + ); + assert_eq!( + format_none_installed_error(&["pkg:npm/a@1".to_string()]), + vec![ + "Error: The targeted manifest patch matched no installed package:", + " - pkg:npm/a@1", + "Check that the packages are installed and --cwd points to the right directory.", + ] + ); + let two = format_none_installed_error(&["pkg:npm/a@1".into(), "pkg:npm/b@2".into()]); + assert_eq!( + two[0], + "Error: None of the 2 targeted manifest patches matched an installed package:" + ); + assert_eq!(two.len(), 4); + assert_eq!( + format_none_installed_error(&[])[0], + "Error: None of the targeted manifest patches matched an installed package." + ); + } + + #[test] + fn stage_failure_warning_names_the_cause() { + let off = stage_failure_warning(true); + assert_eq!(off.code, "offline_missing_sources"); + assert!(off.detail.contains("--offline"), "{}", off.detail); + assert!(is_stage_failure_code(&off.code)); + let dl = stage_failure_warning(false); + assert_eq!(dl.code, "sources_download_failed"); + assert!(is_stage_failure_code(&dl.code)); + assert!(!is_stage_failure_code("gem_config_path_ignored")); + } } diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index f8375611..3179bab5 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -11,8 +11,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use socket_patch_core::api::blob_fetcher::{ - fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives, - get_missing_blobs, DownloadMode, + fetch_missing_blobs, fetch_missing_sources, get_missing_archives, get_missing_blobs, + DownloadMode, FetchMissingBlobsResult, }; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; @@ -22,6 +22,7 @@ use tempfile::TempDir; use super::get::base64_decode; use crate::args::GlobalArgs; use crate::commands::bun_preflight::LedgerLoad; +use crate::ui::{plural, StatusLine}; /// Resolved artifact locations for the patch pipeline. Holds the overlay /// `TempDir` alive — sources become invalid when this is dropped. @@ -75,7 +76,7 @@ pub(crate) enum StageOutcome { /// The disk stager's remedy: `repair` fills the persistent `.socket/` /// cache `apply` reads from. -const APPLY_OFFLINE_REMEDY: &str = "Run \"socket-patch repair\" to download missing artifacts."; +const APPLY_OFFLINE_REMEDY: &str = "Run `socket-patch repair` to download missing artifacts."; /// The memory stager's remedy. Vendored content is fetched into memory and /// never lands under `.socket/`; sending a vendored project to `repair` @@ -94,19 +95,107 @@ fn report_offline_missing(common: &GlobalArgs, purls: &[&str], remedy: &str) { if common.json { return; } - eprintln!( - "Error: {} patch(es) have no local source and --offline is set:", - purls.len() + let n = purls.len(); + let (count, verb) = ( + plural(n, "patch", "patches"), + if n == 1 { "has" } else { "have" }, ); - for purl in purls.iter().take(5) { - eprintln!(" - {}", purl); - } - if purls.len() > 5 { - eprintln!(" ... and {} more", purls.len() - 5); + eprintln!("Error: {count} {verb} no local source and --offline is set:"); + for line in format_purl_list(purls, 5) { + eprintln!("{line}"); } eprintln!("{remedy}"); } +/// ` - ` for the first `max` purls, then ` ... and N more`. +fn format_purl_list(purls: &[&str], max: usize) -> Vec { + let mut lines: Vec = purls.iter().take(max).map(|p| format!(" - {p}")).collect(); + if purls.len() > max { + lines.push(format!(" ... and {} more", purls.len() - max)); + } + lines +} + +/// Singular and plural names of one kind of downloaded artifact. +type Noun = (&'static str, &'static str); +const BLOB: Noun = ("blob", "blobs"); +const DIFF_ARCHIVE: Noun = ("diff archive", "diff archives"); + +/// What a fetch did, one line per non-zero outcome (`Downloaded 2 diff +/// archives`, `1 blob already present locally`), plus the failures (up to +/// five, then `... and N more`) when `with_failures`. The core formatter +/// always says "blob(s)", whatever was fetched. +fn format_fetch_summary( + result: &FetchMissingBlobsResult, + (one, many): Noun, + with_failures: bool, +) -> Vec { + if result.total == 0 { + return vec![format!("All {many} are present locally.")]; + } + let mut lines = Vec::new(); + if result.downloaded > 0 { + lines.push(format!( + "Downloaded {}", + plural(result.downloaded, one, many) + )); + } + if result.skipped > 0 { + lines.push(format!( + "{} already present locally", + plural(result.skipped, one, many) + )); + } + if with_failures && result.failed > 0 { + lines.extend(format_fetch_failures(result, (one, many))); + } + lines +} + +/// `Failed to download N :` and the per-item reasons. +fn format_fetch_failures(result: &FetchMissingBlobsResult, (one, many): Noun) -> Vec { + let mut lines = vec![format!( + "Failed to download {}:", + plural(result.failed, one, many) + )]; + let failed: Vec<_> = result.results.iter().filter(|r| !r.success).collect(); + for r in failed.iter().take(5) { + // Chars, not bytes: the hash is an unvalidated manifest string. + let short: String = r.hash.chars().take(12).collect(); + let err = r.error.as_deref().unwrap_or("unknown error"); + lines.push(format!(" - {short}...: {err}")); + } + if failed.len() > 5 { + lines.push(format!(" ... and {} more", failed.len() - 5)); + } + lines +} + +/// Announce the per-file blob top-up that follows a diff-mode fetch. It +/// runs even when every diff archive arrived — a diff cannot patch a file +/// whose bytes differ from `beforeHash`, and the pipeline then falls back +/// to the blob — so it is worded as a complement, not a failure, unless +/// some archives really were unavailable. +/// The disk stager's status line while it downloads what `.socket/` lacks. +const DOWNLOADING_ARTIFACTS: &str = "Downloading missing patch artifacts..."; + +/// The in-memory stager's status line while it fetches patch views. +fn format_fetching_content(n: usize) -> String { + format!("Fetching content for {}...", plural(n, "patch", "patches")) +} + +fn format_blob_fallback(diff_failed: usize, blobs: usize) -> String { + let blobs = plural(blobs, "per-file blob", "per-file blobs"); + if diff_failed == 0 { + format!("Also fetching {blobs} (used where a diff does not apply)...") + } else { + format!( + "{} unavailable; fetching {blobs} instead...", + plural(diff_failed, "diff archive", "diff archives") + ) + } +} + /// The manifest PURLs with no usable local source. A patch is "locally /// applicable" iff at least one of: /// - every `after_hash` blob it references is on disk, OR @@ -256,18 +345,27 @@ pub(crate) async fn stage_patch_sources( overlay_dir(&socket_diffs_path, &staged.diffs).await; overlay_dir(&socket_packages_path, &staged.packages).await; - if !quiet { - println!( - "Downloading missing patch artifacts (mode: {})...", - download_mode.as_tag() - ); - } + // Progress: a transient status line on stderr (stdout is data); the + // result lines below are what stays on screen. + let mut status = StatusLine::stderr(common.json, common.silent); + status.set(DOWNLOADING_ARTIFACTS); let sources = staged.as_patch_sources(); let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, client, None).await; - + status.finish(); + + // In diff mode an unavailable archive is routine (the blob top-up + // below covers it), so its failure detail is held back and printed + // only if the patch really ends up with no source. + let primary_noun = match download_mode { + DownloadMode::File => BLOB, + DownloadMode::Diff => DIFF_ARCHIVE, + }; + let defer_failures = download_mode != DownloadMode::File; if !quiet { - println!("{}", format_fetch_result(&fetch_result)); + for line in format_fetch_summary(&fetch_result, primary_noun, !defer_failures) { + eprintln!("{line}"); + } } // For non-file modes, automatically fetch any still-missing file blobs as @@ -277,15 +375,16 @@ pub(crate) async fn stage_patch_sources( if download_mode != DownloadMode::File { let still_missing_blobs = get_missing_blobs(manifest, &staged.blobs).await; if !still_missing_blobs.is_empty() { - if !quiet { - println!( - "Falling back to per-file blob downloads for {} blob(s)...", - still_missing_blobs.len() - ); - } + status.set(format_blob_fallback( + fetch_result.failed, + still_missing_blobs.len(), + )); let blob_result = fetch_missing_blobs(manifest, &staged.blobs, client, None).await; + status.finish(); if !quiet { - println!("{}", format_fetch_result(&blob_result)); + for line in format_fetch_summary(&blob_result, BLOB, true) { + eprintln!("{line}"); + } } blob_fetch_failed = blob_result.failed > 0; } @@ -311,7 +410,14 @@ pub(crate) async fn stage_patch_sources( // An error, not progress chatter: prints even under --silent // (same rule as report_offline_missing above). if !common.json { - eprintln!("Some artifacts could not be downloaded. Cannot apply patches."); + eprintln!( + "Error: Some patch artifacts could not be downloaded; cannot apply patches." + ); + if defer_failures && fetch_result.failed > 0 { + for line in format_fetch_failures(&fetch_result, primary_noun) { + eprintln!("{line}"); + } + } } return Ok(StageOutcome::Unavailable); } @@ -385,7 +491,6 @@ pub(crate) async fn stage_vendor_sources_in_memory( seed: HashMap>, client: Option<&ApiClient>, ) -> MemStageOutcome { - let quiet = common.silent || common.json; let blobs = socket_dir.join("blobs"); let diffs = socket_dir.join("diffs"); let packages = socket_dir.join("packages"); @@ -446,12 +551,8 @@ pub(crate) async fn stage_vendor_sources_in_memory( return MemStageOutcome::Unavailable; } - if !quiet { - println!( - "Fetching {} patch(es)' content (kept in memory)...", - to_fetch.len() - ); - } + let mut status = StatusLine::stderr(common.json, common.silent); + status.set(format_fetching_content(to_fetch.len())); let built; let client = match client { @@ -464,7 +565,15 @@ pub(crate) async fn stage_vendor_sources_in_memory( } }; let mut failed: Vec<&str> = Vec::new(); - for (purl, uuid) in &to_fetch { + for (i, (purl, uuid)) in to_fetch.iter().enumerate() { + if to_fetch.len() > 1 { + status.set(format!( + "{} ({}/{})", + format_fetching_content(to_fetch.len()), + i + 1, + to_fetch.len() + )); + } match client.fetch_patch(uuid).await { Ok(Some(patch)) => { let mut complete = true; @@ -474,7 +583,9 @@ pub(crate) async fn stage_vendor_sources_in_memory( // under --silent (same rule as // report_offline_missing above). if !common.json { - eprintln!(" [error] {purl}: no blob content served for {file}"); + status.println(format!( + " [error] {purl}: no blob content served for {file}" + )); } complete = false; break; @@ -502,6 +613,7 @@ pub(crate) async fn stage_vendor_sources_in_memory( _ => failed.push(purl), } } + status.finish(); if !failed.is_empty() { // An error, not progress chatter: the vendor caller only marks // the envelope (printed exclusively under --json), so muting @@ -510,11 +622,11 @@ pub(crate) async fn stage_vendor_sources_in_memory( // for the disk stager's arms above. if !common.json { eprintln!( - "Error: could not fetch patch content for {} patch(es):", - failed.len() + "Error: Could not fetch patch content for {}:", + plural(failed.len(), "patch", "patches") ); - for purl in failed.iter().take(5) { - eprintln!(" - {}", purl); + for line in format_purl_list(&failed, 5) { + eprintln!("{line}"); } } return MemStageOutcome::Unavailable; @@ -532,6 +644,22 @@ pub(crate) async fn stage_vendor_sources_in_memory( #[cfg(test)] mod tests { use super::*; + + #[test] + fn progress_lines_name_no_internal_tags() { + assert_eq!( + DOWNLOADING_ARTIFACTS, + "Downloading missing patch artifacts..." + ); + assert_eq!( + format_fetching_content(1), + "Fetching content for 1 patch..." + ); + assert_eq!( + format_fetching_content(3), + "Fetching content for 3 patches..." + ); + } use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; const UUID: &str = "11111111-1111-4111-8111-111111111111"; @@ -1044,3 +1172,118 @@ mod tests { ); } } + +/// Exact-string tests for the staging progress / error lines. +#[cfg(test)] +mod ui_format_tests { + use super::*; + use socket_patch_core::api::blob_fetcher::BlobFetchResult; + + fn result( + downloaded: usize, + skipped: usize, + failures: &[(&str, &str)], + ) -> FetchMissingBlobsResult { + let mut results: Vec = failures + .iter() + .map(|(hash, err)| BlobFetchResult { + hash: hash.to_string(), + success: false, + error: Some(err.to_string()), + }) + .collect(); + results.push(BlobFetchResult { + hash: "ok".into(), + success: true, + error: None, + }); + FetchMissingBlobsResult { + total: downloaded + skipped + failures.len(), + downloaded, + failed: failures.len(), + skipped, + results, + } + } + + #[test] + fn fetch_summary_uses_the_right_noun_and_plurals() { + assert_eq!( + format_fetch_summary(&result(0, 0, &[]), BLOB, true), + vec!["All blobs are present locally."] + ); + assert_eq!( + format_fetch_summary(&result(1, 0, &[]), DIFF_ARCHIVE, true), + vec!["Downloaded 1 diff archive"] + ); + assert_eq!( + format_fetch_summary(&result(7, 1, &[]), BLOB, true), + vec!["Downloaded 7 blobs", "1 blob already present locally"] + ); + } + + #[test] + fn fetch_summary_failures_are_optional_and_capped() { + let fails: Vec<(String, String)> = (0..7) + .map(|i| (format!("{i}{}", "a".repeat(20)), "404".to_string())) + .collect(); + let refs: Vec<(&str, &str)> = fails + .iter() + .map(|(h, e)| (h.as_str(), e.as_str())) + .collect(); + let r = result(1, 0, &refs); + assert_eq!( + format_fetch_summary(&r, DIFF_ARCHIVE, false), + vec!["Downloaded 1 diff archive"] + ); + let lines = format_fetch_summary(&r, DIFF_ARCHIVE, true); + assert_eq!(lines[1], "Failed to download 7 diff archives:"); + assert_eq!(lines[2], " - 0aaaaaaaaaaa...: 404"); + assert_eq!(lines.last().unwrap(), " ... and 2 more"); + assert_eq!(lines.len(), 1 + 1 + 5 + 1); + // A multibyte hash is cut by chars, never mid-byte. + let r = result(0, 0, &[("é".repeat(20).as_str(), "boom")]); + assert_eq!( + format_fetch_failures(&r, BLOB), + vec![ + "Failed to download 1 blob:".to_string(), + format!(" - {}...: boom", "é".repeat(12)) + ] + ); + } + + #[test] + fn blob_fallback_wording() { + assert_eq!( + format_blob_fallback(0, 1), + "Also fetching 1 per-file blob (used where a diff does not apply)..." + ); + assert_eq!( + format_blob_fallback(0, 7), + "Also fetching 7 per-file blobs (used where a diff does not apply)..." + ); + assert_eq!( + format_blob_fallback(1, 3), + "1 diff archive unavailable; fetching 3 per-file blobs instead..." + ); + assert_eq!( + format_blob_fallback(2, 1), + "2 diff archives unavailable; fetching 1 per-file blob instead..." + ); + } + + #[test] + fn purl_list_caps_at_max_with_remainder() { + assert!(format_purl_list(&[], 5).is_empty()); + assert_eq!( + format_purl_list(&["pkg:npm/a@1"], 5), + vec![" - pkg:npm/a@1"] + ); + let many = ["a", "b", "c", "d", "e", "f", "g"]; + let lines = format_purl_list(&many, 5); + assert_eq!(lines.len(), 6); + assert_eq!(lines[4], " - e"); + assert_eq!(lines[5], " ... and 2 more"); + assert_eq!(format_purl_list(&many[..5], 5).len(), 5); + } +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 17c7945b..2e32ee27 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -2,6 +2,7 @@ use clap::Args; use regex::Regex; use socket_patch_core::api::client::{ build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, ApiClient, + ApiError, }; use socket_patch_core::api::ranking::{cmp_search_results, severity_order}; use socket_patch_core::api::types::{ @@ -14,7 +15,7 @@ use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, }; use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; -use socket_patch_core::patch::apply_lock::{self, LockError, LockGuard}; +use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use socket_patch_core::utils::purl::{ canonical_purl, is_purl, normalize_purl, strip_purl_qualifiers, @@ -34,7 +35,7 @@ use crate::commands::lock_cli::lock_failure; use crate::ecosystem_dispatch::{ crawl_all_ecosystems, find_packages_for_rollback, partition_purls, }; -use crate::output::{confirm, print_json, select_one, SelectError}; +use crate::ui::{print_json, select_one, SelectError}; /// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as /// the telemetry `ecosystem` field. Returns an empty string when the @@ -186,21 +187,6 @@ fn merge_metadata(record: &mut serde_json::Value, meta: serde_json::Value) { } } -/// Truncate `s` to at most `limit` displayed characters, appending an -/// ellipsis when it was longer (so the result is never wider than -/// `limit`). Operates on `char` boundaries, NOT bytes: a byte-index slice -/// like `&s[..n]` panics when `n` lands in the middle of a multi-byte -/// UTF-8 sequence, and patch descriptions come straight from the API and -/// routinely contain non-ASCII text. -pub(crate) fn truncate_with_ellipsis(s: &str, limit: usize) -> String { - if s.chars().count() <= limit { - s.to_string() - } else { - let head: String = s.chars().take(limit.saturating_sub(3)).collect(); - format!("{head}...") - } -} - /// Short, display-only prefix of a UUID for log lines. Returns /// the first 8 bytes when they fall on a char boundary, otherwise the /// whole string. A naive `&uuid[..8]` panics on a malformed/short UUID in @@ -258,7 +244,12 @@ fn report_error(json: bool, message: impl std::fmt::Display) { /// early-return guard. The message/code mapping is /// [`crate::commands::lock_cli::lock_failure`]'s, so the waited clause and /// the I/O rendering cannot drift from `apply`'s. -fn report_lock_failure(json: bool, err: &LockError, timeout: Duration) -> serde_json::Value { +fn report_lock_failure( + json: bool, + socket_dir: &Path, + err: &LockError, + timeout: Duration, +) -> serde_json::Value { let (code, message) = lock_failure(err, timeout); let envelope = serde_json::json!({ "status": "error", @@ -268,7 +259,10 @@ fn report_lock_failure(json: bool, err: &LockError, timeout: Duration) -> serde_ if json { print_json(&envelope); } else { - eprintln!("Error: {message}"); + eprint!( + "{}", + crate::commands::lock_cli::format_lock_error(socket_dir, err, timeout) + ); } envelope } @@ -492,13 +486,12 @@ pub struct GetArgs { #[arg(short = 'p', long = "package", default_value_t = false)] pub package: bool, - /// Download patch without applying it. - /// - /// `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_SAVE_ONLY=1` (or an - /// exported-but-empty `SOCKET_SAVE_ONLY=`) aborted every `get` - /// invocation. + /// Download the patch and record it in the manifest without applying it. + // `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_SAVE_ONLY=1` (or an + // exported-but-empty `SOCKET_SAVE_ONLY=`) aborted every `get` + // invocation. #[arg( long = "save-only", alias = "no-apply", @@ -508,27 +501,33 @@ pub struct GetArgs { )] pub save_only: bool, - /// Apply patch immediately without saving to .socket folder. - /// - /// `value_parser = parse_bool_flag`: same env-crash fix as `--save-only` - /// above — and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, - /// which already parses boolishly; the two must not diverge. + /// 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 env-crash fix as `--save-only` + // above — and `SOCKET_ONE_OFF` is shared with `rollback --one-off`, + // which already parses boolishly; 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/distribution variant of a - /// matched package, not just the one(s) matching the locally- - /// installed distribution. Affects ecosystems with per-release - /// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems - /// (`platform`), and Maven (`classifier`). Off by default: only the - /// patch(es) for the installed dist are fetched. Also disables the - /// coarse installed-VERSION narrowing of CVE/GHSA fan-outs (see - /// `--mode`): every version's patch is fetched, installed or not. + /// Download patches for every release variant of a matched package, + /// not just the one matching the locally-installed distribution. + /// + /// Affects ecosystems with per-release variants: PyPI (wheel/sdist), + /// RubyGems (platform) and Maven (classifier). Also turns off the + /// installed-version filter for CVE/GHSA searches, so every version's + /// patch is fetched, installed or not. + // Variant keys: PyPI `artifact_id`, RubyGems `platform`, Maven + // `classifier`. Off by default: only the patch(es) for the installed + // dist are fetched. #[arg( long = "all-releases", env = "SOCKET_ALL_RELEASES", @@ -537,16 +536,17 @@ pub struct GetArgs { )] pub all_releases: bool, - /// How to consume the patch(es) — the same three modes as `scan`: - /// `agent` (default; record in `.socket/manifest.json` + blobs and - /// apply in place), `hosted` (rewrite lockfiles so the patched deps - /// resolve to Socket's hosted patch server; no manifest, no blobs — - /// state lives in the redirect ledger), or `vendored` (commit patched - /// artifacts under `.socket/vendor/` and rewire the lockfile; no - /// manifest, no blobs — the vendor ledger carries the records). - /// Hosted/vendored runs produce the same on-disk result as - /// `scan --mode hosted|vendored` selecting the same patch. No env - /// binding, matching `scan --mode`. + /// How to consume the patches: the same modes as `scan --mode` + /// (default: agent). + // agent = record in .socket/manifest.json + blobs and apply in place; + // hosted = rewrite lockfiles so the patched deps resolve to Socket's + // hosted patch server (no manifest, no blobs; state lives in the + // redirect ledger); vendored = commit patched artifacts under + // .socket/vendor/ and rewire the lockfile (no manifest, no blobs; the + // vendor ledger carries the records). Hosted/vendored runs produce the + // same on-disk result as `scan --mode hosted|vendored` selecting the + // same patch. The per-value help comes from `ScanMode`'s variant docs. + // No env binding, matching `scan --mode`. #[arg(long = "mode", value_enum)] pub mode: Option, } @@ -596,33 +596,479 @@ fn detect_identifier_type(identifier: &str) -> Option { } } -/// Render one patch as an interactive-selection option line: -/// ` [] (fixes: ) - `. -/// -/// Each advisory is summarized by its CVE ids joined with `", "` when it -/// has any, falling back to the advisory id itself (e.g. a GHSA with no -/// CVE assigned yet); the `(fixes: …)` segment is omitted entirely for a -/// patch with no vulnerabilities. The description is truncated to 60 -/// characters. -fn format_patch_option(p: &PatchSearchResult) -> String { - let vuln_summary: Vec = p - .vulnerabilities +/// Advisory labels for a patch: every advisory's CVE ids, or the advisory +/// id itself when it has no CVE assigned yet (a fresh GHSA). Sorted and +/// deduplicated, so the text never depends on `HashMap` iteration order. +fn vuln_labels(vulns: &HashMap) -> Vec { + let mut labels: Vec = vulns .iter() - .map(|(id, v)| { + .flat_map(|(id, v)| { if v.cves.is_empty() { - id.clone() + vec![id.clone()] } else { - v.cves.join(", ") + v.cves.clone() } }) .collect(); - let vulns = if vuln_summary.is_empty() { + labels.sort(); + labels.dedup(); + labels +} + +/// Render one patch as an interactive-selection option line: +/// ` [] (fixes: ) - `. +/// +/// The `(fixes: …)` segment is omitted for a patch with no +/// vulnerabilities, and ` - ` for an empty description (no +/// dangling dash). The tier is upper-cased to match the search listing. +/// The description is truncated to 60 characters. +fn format_patch_option(p: &PatchSearchResult) -> String { + let labels = vuln_labels(&p.vulnerabilities); + let vulns = if labels.is_empty() { + String::new() + } else { + format!(" (fixes: {})", labels.join(", ")) + }; + let desc = crate::ui::truncate(&p.description, 60); + let desc = if desc.is_empty() { String::new() } else { - format!(" (fixes: {})", vuln_summary.join(", ")) + format!(" - {desc}") + }; + format!("{} [{}]{vulns}{desc}", p.uuid, p.tier.to_uppercase()) +} + +/// One-line human summary of a patch: +/// ` [] : fixes ()`, or with +/// several advisories `fixes (highest: )` — a bare +/// `(HIGH)` after a list reads as the last id's severity. +/// +/// `patch_id` is omitted (with its colon) when `None`, the `fixes` part +/// when the patch has no advisories, and the severity when none is known. +/// The severity is colored when `color` is on. The purl is shown decoded +/// (`%40scope` → `@scope`). +fn format_patch_summary( + purl: &str, + tier: &str, + patch_id: Option<&str>, + vulns: &HashMap, + color: bool, +) -> String { + let mut line = format!("{} [{}]", normalize_purl(purl), tier.to_uppercase()); + if let Some(id) = patch_id { + line.push(' '); + line.push_str(short_uuid(id)); + } + let labels = vuln_labels(vulns); + if !labels.is_empty() { + let sep = if patch_id.is_some() { ": " } else { " " }; + line.push_str(&format!("{sep}fixes {}", labels.join(", "))); + if let Some(sev) = max_vuln_severity(vulns) { + let sev = crate::ui::severity(&sev.to_uppercase(), color); + if labels.len() > 1 { + line.push_str(&format!(" (highest: {sev})")); + } else { + line.push_str(&format!(" ({sev})")); + } + } + } + line +} + +/// Compare two strings the way a person sorts versions: runs of ASCII +/// digits compare as numbers (`4.17.2` < `4.17.10`), everything else +/// character by character. Ties on numeric value (`01` vs `1`) fall back +/// to plain string order so the result is a total order. +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { + use std::cmp::Ordering; + let (mut x, mut y) = (a.as_bytes(), b.as_bytes()); + loop { + match (x.first(), y.first()) { + (None, None) => return a.cmp(b), + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => { + let xl = x.iter().take_while(|c| c.is_ascii_digit()).count(); + let yl = y.iter().take_while(|c| c.is_ascii_digit()).count(); + let trim = |s: &[u8]| -> usize { s.iter().take_while(|&&c| c == b'0').count() }; + let (xn, yn) = (&x[trim(&x[..xl])..xl], &y[trim(&y[..yl])..yl]); + let ord = xn.len().cmp(&yn.len()).then_with(|| xn.cmp(yn)); + if ord != Ordering::Equal { + return ord; + } + x = &x[xl..]; + y = &y[yl..]; + } + (Some(c), Some(d)) => { + if c != d { + return c.cmp(d); + } + x = &x[1..]; + y = &y[1..]; + } + } + } +} + +/// The search listing printed before selection: grouped by PURL (in +/// natural version order) and best-first within each PURL — the same +/// order [`select_patches`] resolves in, so a package's first entry is the +/// one that will be applied. A `by-cve` / `by-ghsa` search can span +/// several packages, hence the grouping. Severities are colored when +/// `color` is on. Ends with a blank line. +fn format_search_results( + patches: &[&PatchSearchResult], + can_access_paid: bool, + color: bool, +) -> String { + let mut patches: Vec<&PatchSearchResult> = patches.to_vec(); + patches.sort_by(|a, b| natural_cmp(&a.purl, &b.purl).then_with(|| cmp_search_results(a, b))); + + let mut out = format!( + "Found {}:\n\n", + crate::ui::plural(patches.len(), "patch", "patches") + ); + for (i, patch) in patches.iter().enumerate() { + let tier_label = if patch.tier == "paid" { + " [PAID]" + } else { + " [FREE]" + }; + let access_label = if patch.tier == "paid" && !can_access_paid { + " (no access)" + } else { + "" + }; + out.push_str(&format!( + " {}. {}{tier_label}{access_label}\n", + i + 1, + normalize_purl(&patch.purl) + )); + out.push_str(&format!(" UUID: {}\n", patch.uuid)); + let desc = crate::ui::truncate(&patch.description, 80); + if !desc.is_empty() { + out.push_str(&format!(" Description: {desc}\n")); + } + let mut fixes: Vec<(String, String)> = patch + .vulnerabilities + .iter() + .map(|(id, vuln)| { + let ids = if vuln.cves.is_empty() { + id.to_string() + } else { + let mut cves = vuln.cves.clone(); + cves.sort(); + cves.join(", ") + }; + (ids, vuln.severity.clone()) + }) + .collect(); + fixes.sort(); + if !fixes.is_empty() { + let fixes: Vec = fixes + .iter() + .map(|(ids, sev)| { + if sev.is_empty() { + ids.clone() + } else { + format!("{ids} ({})", crate::ui::severity(sev, color)) + } + }) + .collect(); + out.push_str(&format!(" Fixes: {}\n", fixes.join(", "))); + } + out.push('\n'); + } + out +} + +/// The stderr line naming the package a package-name search went with +/// (only the best fuzzy match is searched). +fn format_best_match(purl: &str, matches: usize) -> String { + if matches > 1 { + format!( + "Best match: {} (of {} matching packages)", + normalize_purl(purl), + matches + ) + } else { + format!("Best match: {}", normalize_purl(purl)) + } +} + +/// The `--verbose` per-version detail behind [`format_skip_summary`]: one +/// `[skip]` line per purl (a free and a paid patch for the same version +/// would otherwise repeat it), in natural version order. +fn format_verbose_skips(skips: &[serde_json::Value]) -> Vec { + let mut by_purl: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new(); + for rec in skips { + let purl = rec["purl"].as_str().unwrap_or_default(); + let reason = match rec["errorCode"].as_str() { + Some("package_not_installed") | None => "version not installed", + Some(code) => code, + }; + by_purl.entry(purl).or_insert(reason); + } + let mut rows: Vec<(String, &str)> = by_purl + .into_iter() + .map(|(purl, reason)| (normalize_purl(purl).into_owned(), reason)) + .collect(); + rows.sort_by(|a, b| natural_cmp(&a.0, &b.0)); + rows.into_iter() + .map(|(purl, reason)| format!(" [skip] {purl} ({reason})")) + .collect() +} + +/// Whether [`select_patches`] has a choice to make that nobody made in +/// advance: a free user, several accessible patches for one purl, and no +/// `--yes`/`--json`. It then shows a menu (interactive stdin) or prints +/// the non-interactive note (unless `--silent`). +pub(crate) fn selection_has_choice( + candidates: &[PatchSearchResult], + can_access_paid: bool, + common: &GlobalArgs, +) -> bool { + if can_access_paid || common.yes || common.json { + return false; + } + let mut seen = std::collections::HashSet::new(); + candidates + .iter() + .filter(|p| p.tier == "free") + .any(|p| !seen.insert(p.purl.as_str())) +} + +/// Whether [`select_patches`] will put a menu in front of the user for +/// these candidates: [`selection_has_choice`] and an interactive stdin +/// (mirrors `select_one`). +fn selection_prompted( + candidates: &[PatchSearchResult], + can_access_paid: bool, + common: &GlobalArgs, +) -> bool { + use std::io::IsTerminal; + selection_has_choice(candidates, can_access_paid, common) && std::io::stdin().is_terminal() +} + +/// The "which patch will be installed" block printed before the prompt +/// when the listing above showed more patches than were selected (a paid +/// user's auto-pick, or narrowing): one [`format_patch_summary`] line per +/// selected patch. Ends with a blank line. +fn format_selected_patches(selected: &[PatchSearchResult], color: bool) -> String { + let mut out = String::from("Selected:\n"); + for p in selected { + out.push_str(&format!( + " {}\n", + format_patch_summary(&p.purl, &p.tier, Some(&p.uuid), &p.vulnerabilities, color) + )); + } + out.push('\n'); + out +} + +/// Number of distinct purls among skip records. +fn distinct_skip_purls(skips: &[serde_json::Value]) -> usize { + let purls: std::collections::BTreeSet<&str> = skips + .iter() + .map(|r| r["purl"].as_str().unwrap_or_default()) + .collect(); + purls.len() +} + +/// Summary lines for the patches the installed-version narrowing dropped, +/// one line per reason (instead of one `[skip]` line per version), in a +/// fixed order: not installed first, then each layout code alphabetically. +fn format_skip_summary(skips: &[serde_json::Value]) -> Vec { + let mut by_code: std::collections::BTreeMap<&str, Vec> = + std::collections::BTreeMap::new(); + for rec in skips { + let code = rec["errorCode"].as_str().unwrap_or("package_not_installed"); + by_code.entry(code).or_default().push(rec.clone()); + } + let mut lines = Vec::new(); + if let Some(recs) = by_code.remove("package_not_installed") { + let n = recs.len(); + let versions = distinct_skip_purls(&recs); + lines.push(format!( + "Skipped {} for {} not installed here (use --all-releases to include {}).", + crate::ui::plural(n, "patch", "patches"), + crate::ui::plural(versions, "package version", "package versions"), + if n == 1 { "it" } else { "them" }, + )); + } + for (code, recs) in by_code { + lines.push(format!( + "Skipped {} for {} ({code}; see the warning above).", + crate::ui::plural(recs.len(), "patch", "patches"), + crate::ui::plural( + distinct_skip_purls(&recs), + "package version", + "package versions" + ), + )); + } + lines +} + +/// The result line when the installed-version narrowing dropped EVERY +/// accessible patch. +fn format_all_narrowed(skips: &[serde_json::Value]) -> String { + // When every skip is a PnP layout refusal, "not installed" and the + // --all-releases advice would both be wrong: the packages were never + // judged (structurally invisible), and the escape hatch cannot make a + // PnP layout patchable — point at the layout warning instead. + let pnp_only = skips.iter().all(|rec| { + matches!( + rec["errorCode"].as_str(), + Some("yarn_pnp_unsupported" | "pnpm_pnp_unsupported") + ) + }); + if pnp_only { + return format!( + "Found {}, but this project's Plug'n'Play layout makes its npm packages \ + unpatchable here; see the layout warning above for the remedy.", + crate::ui::plural(skips.len(), "patch", "patches") + ); + } + match distinct_skip_purls(skips) { + 1 => "Patches exist for 1 package version, but it is not installed here. \ + Use --all-releases to fetch it anyway." + .to_string(), + n => format!( + "Patches exist for {n} package versions, but none of them are installed here. \ + Use --all-releases to fetch them anyway." + ), + } +} + +/// The confirmation question for `n` selected patches. +fn format_confirm_prompt(mode: super::scan::ScanMode, n: usize, save_only: bool) -> String { + let patches = crate::ui::plural(n, "patch", "patches"); + match mode { + super::scan::ScanMode::Agent if save_only => format!("Download {patches}?"), + super::scan::ScanMode::Agent => format!("Download and apply {patches}?"), + super::scan::ScanMode::Vendored => format!("Download and vendor {patches}?"), + super::scan::ScanMode::Hosted => format!( + "Redirect {} to the hosted patch server?", + crate::ui::plural(n, "package", "packages") + ), + } +} + +/// The `--dry-run` result line: `[dry-run] Would N patches. No +/// changes made.` +fn format_dry_run(action: &str, n: usize) -> String { + format!( + "[dry-run] Would {action} {}. No changes made.", + crate::ui::plural(n, "patch", "patches") + ) +} + +/// What a package-name search says when the crawl found nothing: scan's +/// empty-crawl line (get has no ecosystem or path filter to name). +fn no_packages_message(global: bool) -> String { + crate::commands::scan::render::no_packages_message(global, None, &[]) +} + +/// The human result for a patch the caller's plan cannot download. +/// `patch` names it (a purl, or the uuid when the purl is unknown). +fn format_paid_required(patch: &str) -> String { + format!( + "This patch requires a paid subscription to download.\n \ + Patch: {patch}\n \ + Upgrade at: https://socket.dev/pricing" + ) +} + +/// The summary after the multi-patch download loop. A run that changed +/// nothing says so instead of claiming the patches were "saved". +fn format_save_summary( + manifest_path: &Path, + added: usize, + updated: usize, + skipped: usize, + failed: usize, +) -> String { + let mut out = if added + updated > 0 { + format!("Patches saved to {}", manifest_path.display()) + } else { + format!("No changes to {}", manifest_path.display()) + }; + out.push_str(&format!("\n Added: {added}")); + for (label, n) in [ + ("Updated", updated), + ("Skipped", skipped), + ("Failed", failed), + ] { + if n > 0 { + out.push_str(&format!("\n {label}: {n}")); + } + } + out +} + +/// The summary after a single-uuid save. `what` is `"Patch"` or `"Patch +/// record"`. `ends_run` says an unchanged record really ends the run (the +/// agent path skips apply); the vendored path still runs its vendor step, +/// so it must not promise "nothing to update". +fn format_single_save( + what: &str, + action: &PatchAction, + manifest_path: &Path, + purl: &str, + ends_run: bool, +) -> String { + match action { + PatchAction::Added => format!("{what} saved to {}\n Added: 1", manifest_path.display()), + PatchAction::Updated { old_uuid } => format!( + "{what} saved to {}\n Updated: 1 (replacing {})", + manifest_path.display(), + short_uuid(old_uuid) + ), + PatchAction::Skipped => format!( + "{} already has this patch recorded in {}{}", + normalize_purl(purl), + manifest_path.display(), + if ends_run { + "; nothing to update." + } else { + "." + } + ), + } +} + +/// ` [skip] ()` for a record the download phase reuses, with +/// the purl decoded for display (`%40scope` reads as `@scope`). +fn format_record_skip(purl: &str, why: &str) -> String { + format!(" [skip] {} ({why})", normalize_purl(purl)) +} + +/// The closing error printed when the nested apply failed. Apply's own +/// per-package `Error: Failed to patch …` lines print above it, even +/// under `--silent`, so this line needs no "re-run" hint. +const APPLY_FAILED: &str = "Error: Some patches could not be applied."; + +/// Local shape check for an identifier forced with `--id` / `--cve` / +/// `--ghsa`, so a typo fails fast with a readable message instead of a raw +/// API 400 body. `None` when it is well-formed (or the type is not +/// shape-checked). +fn forced_identifier_error(identifier: &str, id_type: IdentifierType) -> Option { + let (ok, what, form) = match id_type { + IdentifierType::Uuid => ( + crate::looks_like_uuid(identifier), + "patch UUID", + "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ), + IdentifierType::Cve => (CVE_RE.is_match(identifier), "CVE ID", "CVE-YYYY-NNNN"), + IdentifierType::Ghsa => ( + GHSA_RE.is_match(identifier), + "GHSA ID", + "GHSA-xxxx-xxxx-xxxx", + ), + IdentifierType::Purl | IdentifierType::Package => return None, }; - let desc = truncate_with_ellipsis(&p.description, 60); - format!("{} [{}]{} - {}", p.uuid, p.tier, vulns, desc) + (!ok).then(|| format!("\"{identifier}\" is not a valid {what} (expected {form})")) } /// Select one patch per PURL from available patches. @@ -633,7 +1079,8 @@ fn format_patch_option(p: &PatchSearchResult) -> String { /// free critical patch outranks a paid low one. /// /// - Users with paid access: auto-select the top-ranked patch per PURL. -/// - Free users with one patch: auto-select it. +/// - Free users with one patch, or with `--yes`: auto-select the +/// top-ranked one. /// - Free users with multiple patches: interactive selection via dialoguer, /// with the options presented in ranked order so the best patch is both /// the highlighted default and what a non-TTY run auto-picks. @@ -648,7 +1095,7 @@ fn format_patch_option(p: &PatchSearchResult) -> String { pub(crate) fn select_patches( patches: &[PatchSearchResult], can_access_paid: bool, - is_json: bool, + common: &GlobalArgs, ) -> Result, i32> { // Group accessible patches by PURL let mut by_purl: HashMap> = HashMap::new(); @@ -677,7 +1124,10 @@ pub(crate) fn select_patches( // tier only breaks ties once merge status, severity and recency // have all tied. selected.push(group[0].clone()); - } else if group.len() == 1 { + } else if group.len() == 1 || (common.yes && !common.json) { + // One candidate, or `--yes` (which answers every prompt with its + // default — the menu's default is the top-ranked patch). JSON + // mode keeps its `selection_required` contract below. selected.push(group[0].clone()); } else { // Free user with multiple patches: interactive selection @@ -686,7 +1136,7 @@ pub(crate) fn select_patches( match select_one( &format!("Multiple patches available for {purl}. Select one:"), &options, - is_json, + common, ) { Ok(idx) => { selected.push(group[idx].clone()); @@ -917,8 +1367,8 @@ async fn filter_to_installed_releases( // Not installed: cannot determine the relevant release. Keep // every variant so the patch is still obtainable. warnings.push(format!( - "{base} is not installed locally; keeping all {} release variant(s).", - variants.len() + "{base} is not installed locally; keeping all {}.", + crate::ui::plural(variants.len(), "release variant", "release variants") )); kept.extend(variants); continue; @@ -954,8 +1404,8 @@ async fn filter_to_installed_releases( // back to broad rather than silently dropping a package the // user asked about. warnings.push(format!( - "No release variant of {base} matches the installed distribution; keeping all {} variant(s).", - variants.len() + "No release variant of {base} matches the installed distribution; keeping all {}.", + crate::ui::plural(variants.len(), "variant", "variants") )); kept.extend(variants); } else { @@ -1343,6 +1793,10 @@ async fn fetch_selected_patches( // Narrow multi-release selections to the installed distribution unless // --all-releases was passed (a no-op for non-variant ecosystems and // single-variant packages). The views it fetched serve the loop below. + // The narrowing queries the API: show that something is happening + // right after the confirm prompt. + let mut status = crate::ui::StatusLine::stderr(params.json, params.silent); + status.set("Preparing download..."); let (selected, warnings, views) = filter_to_installed_releases( selected, params.all_releases, @@ -1351,9 +1805,14 @@ async fn fetch_selected_patches( api_client, ) .await; + status.finish(); prefetched.extend(views); + // No leading blank line: the prompt's answer already ended its line. if matches!(store, RecordStore::Manifest(_)) && !quiet { - eprintln!("\nDownloading {} patch(es)...", selected.len()); + eprintln!( + "Downloading {}...", + crate::ui::plural(selected.len(), "patch", "patches") + ); } let mut batch = FetchBatch { @@ -1397,7 +1856,7 @@ async fn fetch_selected_patches( .and_then(|e| e.record.clone()) { if !quiet { - eprintln!(" [skip] {purl} (already vendored)"); + eprintln!("{}", format_record_skip(purl, "already vendored")); } batch.patches_json.push(serde_json::json!({ "purl": purl, @@ -1458,10 +1917,7 @@ async fn fetch_selected_patches( }; if action == PatchAction::Skipped { if !quiet { - eprintln!( - " [skip] {} (already in manifest)", - normalize_purl(&patch.purl) - ); + eprintln!("{}", format_record_skip(&patch.purl, "already in manifest")); } batch.patches_json.push(serde_json::json!({ "purl": patch.purl, @@ -1530,13 +1986,13 @@ async fn fetch_selected_patches( // panic the loop — `short_uuid` never does. eprintln!( " [{tag}] {} (replacing {})", - patch.purl, + normalize_purl(&patch.purl), short_uuid(old_uuid) ); } record["oldUuid"] = serde_json::json!(old_uuid); } else if !quiet { - eprintln!(" [{tag}] {}", patch.purl); + eprintln!(" [{tag}] {}", normalize_purl(&patch.purl)); } // Splice description / severity / vulnerability IDs into the record // so PR-comment bots, dashboards, and CLI consumers can render the @@ -1777,13 +2233,14 @@ fn nested_apply_args_from_params( /// its manifest write — one lock window for download → manifest write → /// apply (a same-process re-acquire would contend), released by apply once /// its last mutation is done. Returns whether apply exited 0. Callers print -/// their own "Applying patches..." line (they differ on stdout vs stderr). -/// The read-only cargo-redirect verifier stays off and embedded VEX is -/// opt-in on the top-level command only, never on this internal -/// invocation. +/// their own "Applying patches..." line. `json` is the caller's flag: a +/// JSON caller gets no human error lines, from this function or from the +/// nested apply (`common` itself is never JSON). The read-only cargo-redirect verifier stays off +/// and embedded VEX is opt-in on the top-level command only, never on this +/// internal invocation. async fn run_nested_apply( common: GlobalArgs, - quiet: bool, + json: bool, client: &ApiClient, lock: LockGuard, ) -> bool { @@ -1793,10 +2250,13 @@ async fn run_nested_apply( force: false, check: false, vex: Default::default(), + nested: Some(super::apply::NestedApply { caller_json: json }), }; let code = super::apply::run_locked(apply_args, manifest_path, client, lock).await; - if code != 0 && !quiet { - eprintln!("\nSome patches could not be applied."); + // An error, so exempt from --silent ("errors only": a failing exit must + // say why); JSON runs carry the failure in the envelope instead. + if code != 0 && !json { + eprintln!("{APPLY_FAILED}"); } code == 0 } @@ -1824,9 +2284,14 @@ pub async fn download_and_apply_patches_with( // drop removes `apply.lock` and prunes an otherwise-empty `.socket/`, so // a run that records nothing leaves no residue. The nested apply runs // under this SAME guard (one lock window; see `run_nested_apply`). - let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { + let guard = match crate::commands::lock_cli::acquire_with_status(&socket_dir, lock_timeout) { Ok(guard) => guard, - Err(e) => return (1, report_lock_failure(params.json, &e, lock_timeout)), + Err(e) => { + return ( + 1, + report_lock_failure(params.json, &socket_dir, &e, lock_timeout), + ) + } }; let mut manifest = match read_manifest(&manifest_path).await { @@ -1888,14 +2353,9 @@ pub async fn download_and_apply_patches_with( // The blobs this run just wrote have no record pointing at them: // unwind exactly those (a pre-existing record's blobs stay). unwind_new_blobs(&blobs_dir, &new_blobs).await; - let msg = format!("Error writing manifest: {e}"); - let err_json = serde_json::json!({ "status": "error", "error": &msg }); - if params.json { - print_json(&err_json); - } else { - eprintln!("{msg}"); - } - return (1, err_json); + let msg = format!("Failed to write manifest: {e}"); + report_error(params.json, &msg); + return (1, serde_json::json!({ "status": "error", "error": msg })); } } // The lock outlives the manifest write only when a nested apply follows @@ -1919,28 +2379,23 @@ pub async fn download_and_apply_patches_with( warn_on_vendored_uuid_drift(¶ms.cwd, quiet, &batch.patches_json, &mut warnings).await; if !quiet { - eprintln!("\nPatches saved to {}", manifest_path.display()); - eprintln!(" Added: {added}"); - if batch.skipped > 0 { - eprintln!(" Skipped: {}", batch.skipped); - } - if batch.failed > 0 { - eprintln!(" Failed: {}", batch.failed); - } - if updated > 0 { - eprintln!(" Updated: {updated}"); - } + eprintln!(); + eprintln!( + "{}", + format_save_summary(&manifest_path, added, updated, batch.skipped, batch.failed) + ); } // Auto-apply unless --save-only (the lock decision above). let mut apply_succeeded = false; if let Some(lock) = apply_lock { if !quiet { - eprintln!("\nApplying patches..."); + eprintln!(); + eprintln!("Applying patches..."); } apply_succeeded = run_nested_apply( nested_apply_args_from_params(params, run, &manifest_path), - quiet, + params.json, run.api_client, lock, ) @@ -2030,16 +2485,40 @@ pub async fn run(args: GetArgs) -> i32 { if args.common.offline { report_error( args.common.json, - "get requires network access to fetch patches and cannot run with \ + "Fetching patches needs network access, so `get` cannot run with \ --offline/SOCKET_OFFLINE (strict airgap)", ); return 1; } + // Determine identifier type + let id_type = if args.id { + IdentifierType::Uuid + } else if args.cve { + IdentifierType::Cve + } else if args.ghsa { + IdentifierType::Ghsa + } else if args.package { + IdentifierType::Package + } else { + detect_identifier_type(&args.identifier).unwrap_or(IdentifierType::Package) + }; + // A forced type is shape-checked locally, before any network call, so + // a typo reads as a plain message instead of a raw API 400 body. + if args.id || args.cve || args.ghsa { + if let Some(err) = forced_identifier_error(&args.identifier, id_type) { + report_error(args.common.json, err); + return 1; + } + } + apply_env_toggles(&args.common); // `--silent` is "errors only" (CLI_CONTRACT.md): every informational // print below is gated on this; errors and JSON envelopes are not. let quiet = args.common.json || args.common.silent; + if !quiet && id_type == IdentifierType::Package && !args.package { + eprintln!("Treating \"{}\" as a package name search", args.identifier); + } let overrides = args.common.api_client_overrides(); let (mut api_client, mut use_public_proxy) = get_api_client_with_overrides(overrides.clone()).await; @@ -2052,32 +2531,14 @@ pub async fn run(args: GetArgs) -> i32 { // incidence of stale-token fallbacks. let mut fallback_to_proxy = false; - // Determine identifier type - let id_type = if args.id { - IdentifierType::Uuid - } else if args.cve { - IdentifierType::Cve - } else if args.ghsa { - IdentifierType::Ghsa - } else if args.package { - IdentifierType::Package - } else { - match detect_identifier_type(&args.identifier) { - Some(t) => t, - None => { - if !quiet { - println!("Treating \"{}\" as a package name search", args.identifier); - } - IdentifierType::Package - } - } - }; + // Progress for the network/crawl phases below. Built after the client: + // building it may print core advisories straight to stderr, which + // would land on the end of a live line. + let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); // Handle UUID: fetch and download directly if id_type == IdentifierType::Uuid { - if !quiet { - println!("Fetching patch by UUID: {}", args.identifier); - } + status.set(format!("Fetching patch {}...", args.identifier)); let mut fetch_result = api_client.fetch_patch(&args.identifier).await; // 401/403 from the auth endpoint → swap to the public proxy // and retry once. Free patches still surface; paid patches @@ -2085,47 +2546,50 @@ pub async fn run(args: GetArgs) -> i32 { if !use_public_proxy { if let Err(ref e) = fetch_result { if is_fallback_candidate(e) { - eprintln!( - "Warning: authenticated API returned {e}; \ - falling back to public patch API proxy (free patches only)." - ); + // Errors-only under --silent; --json keeps it on stderr + // (same gate as scan's batch fallback). + if !args.common.silent { + status.println(format!( + "Warning: authenticated API returned {e}; \ + falling back to public patch API proxy (free patches only)." + )); + } + // Building the proxy client may print core's proxy + // notice straight to stderr: take the line down first. + status.finish(); api_client = build_proxy_fallback_client(&overrides); use_public_proxy = true; fallback_to_proxy = true; + status.set(format!("Fetching patch {}...", args.identifier)); fetch_result = api_client.fetch_patch(&args.identifier).await; } } } + status.finish(); match fetch_result { Ok(Some(patch)) => { if patch.tier == "paid" && use_public_proxy { - track_patch_fetch_failed( + return report_paid_required_uuid( + &args, + Some(&patch.purl), &patch.uuid, - "paid_required", fallback_to_proxy, telemetry_token.as_deref(), telemetry_org.as_deref(), ) .await; - if args.common.json { - print_json(&serde_json::json!({ - "status": "paid_required", - "found": 1, - "downloaded": 0, - "applied": 0, - "patches": [{ - "purl": patch.purl, - "uuid": patch.uuid, - "tier": "paid", - }], - })); - } else if !args.common.silent { - println!("\nThis patch requires a paid subscription to download."); - println!("\n Patch: {}", patch.purl); - println!(" Tier: paid"); - println!("\n Upgrade at: https://socket.dev/pricing\n"); - } - return 0; + } + if !quiet { + eprintln!( + "Found patch for {}", + format_patch_summary( + &patch.purl, + &patch.tier, + None, + &patch.vulnerabilities, + crate::ui::stderr_color(), + ) + ); } // Record the fetch BEFORE the save+apply step so the @@ -2174,6 +2638,20 @@ pub async fn run(args: GetArgs) -> i32 { } }; } + // The public proxy answers a paid patch with 403 rather than + // a tier=paid view: the same outcome as the branch above, not + // a raw "Forbidden" error. + Err(ApiError::Forbidden(_)) if use_public_proxy => { + return report_paid_required_uuid( + &args, + None, + &args.identifier, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + } Ok(None) => { track_patch_fetch_failed( &args.identifier, @@ -2209,9 +2687,10 @@ pub async fn run(args: GetArgs) -> i32 { // the matching endpoint, and surface errors via `report_fetch_failure`. let search_response: SearchResponse = match id_type { IdentifierType::Cve | IdentifierType::Ghsa | IdentifierType::Purl => { - if !quiet { - println!("Searching patches for {id_type}: {}", args.identifier); - } + status.set(format!( + "Searching patches for {id_type} {}...", + args.identifier + )); let result = match id_type { IdentifierType::Cve => api_client.search_patches_by_cve(&args.identifier).await, IdentifierType::Ghsa => api_client.search_patches_by_ghsa(&args.identifier).await, @@ -2220,6 +2699,7 @@ pub async fn run(args: GetArgs) -> i32 { } _ => unreachable!(), }; + status.finish(); match result { Ok(r) => r, Err(e) => { @@ -2236,28 +2716,24 @@ pub async fn run(args: GetArgs) -> i32 { } } IdentifierType::Package => { - if !quiet { - println!("Enumerating packages..."); - } + status.set("Enumerating packages..."); let (all_packages, _, _) = crawl_all_ecosystems(&crawler_options_for(&args.common)).await; if all_packages.is_empty() { + status.finish(); if args.common.json { print_json(&empty_result_json("no_packages")); } else if !args.common.silent { - if args.common.global { - println!("No global packages found."); - } else { - println!("No packages found. Run your package manager's install first."); - } + println!("{}", no_packages_message(args.common.global)); } return 0; } - if !quiet { - println!("Found {} packages", all_packages.len()); - } + status.finish_with(format!( + "Found {}", + crate::ui::plural(all_packages.len(), "package", "packages") + )); let matches = fuzzy_match_packages(&args.identifier, &all_packages, 20); @@ -2270,16 +2746,19 @@ pub async fn run(args: GetArgs) -> i32 { return 0; } + // Only the best match is searched: name it, so a fuzzy pick + // of the wrong package is visible. + let best_match = &matches[0]; if !quiet { - println!( - "Found {} matching package(s), checking for available patches...", - matches.len() - ); + eprintln!("{}", format_best_match(&best_match.purl, matches.len())); } - - // Search for patches for the best match. - let best_match = &matches[0]; - match api_client.search_patches_by_package(&best_match.purl).await { + status.set(format!( + "Searching patches for {}...", + normalize_purl(&best_match.purl) + )); + let result = api_client.search_patches_by_package(&best_match.purl).await; + status.finish(); + match result { Ok(r) => r, Err(e) => { return report_fetch_failure( @@ -2296,6 +2775,7 @@ pub async fn run(args: GetArgs) -> i32 { } _ => unreachable!(), }; + drop(status); if search_response.patches.is_empty() { if args.common.json { @@ -2306,12 +2786,7 @@ pub async fn run(args: GetArgs) -> i32 { return 0; } - if !quiet { - display_search_results( - &search_response.patches, - search_response.can_access_paid_patches, - ); - } + let color = crate::ui::stdout_color(); // Filter accessible patches let accessible: Vec<_> = search_response @@ -2335,8 +2810,18 @@ pub async fn run(args: GetArgs) -> i32 { })).collect::>(), })); } else if !args.common.silent { - println!("\nAll available patches require a paid subscription."); - println!("\n Upgrade at: https://socket.dev/pricing\n"); + let all: Vec<&PatchSearchResult> = search_response.patches.iter().collect(); + if id_type == IdentifierType::Package && !quiet { + // Separate the stderr `Best match` line above on a terminal; + // stdout itself starts with the result. + eprintln!(); + } + print!( + "{}", + format_search_results(&all, search_response.can_access_paid_patches, color) + ); + println!("All available patches require a paid subscription."); + println!(" Upgrade at: https://socket.dev/pricing"); } return 0; } @@ -2354,11 +2839,30 @@ pub async fn run(args: GetArgs) -> i32 { || args.save_only || id_type == IdentifierType::Package || (id_type == IdentifierType::Purl && purl_has_version(&args.identifier)); - let (accessible, narrow_skips, narrow_warnings) = if narrowing_exempt { - (accessible, Vec::new(), Vec::new()) + // The narrowing runs over EVERY result (one crawl), paid no-access ones + // included, so the listing can still show an installed package's paid + // fix as `[PAID] (no access)`; selection, the skip records and the + // JSON envelope only ever see the accessible share. + let (accessible, listed, narrow_skips, narrow_warnings) = if narrowing_exempt { + let listed: Vec = search_response.patches.clone(); + (accessible, listed, Vec::new(), Vec::new()) } else { - let narrowing = filter_to_installed_purls(&accessible, &args.common, mode).await; - (narrowing.kept, narrowing.skip_records, narrowing.warnings) + let narrowing = + filter_to_installed_purls(&search_response.patches, &args.common, mode).await; + let accessible_uuids: std::collections::HashSet<&str> = + accessible.iter().map(|p| p.uuid.as_str()).collect(); + let kept_accessible: Vec = narrowing + .kept + .iter() + .filter(|p| accessible_uuids.contains(p.uuid.as_str())) + .cloned() + .collect(); + let skips: Vec = narrowing + .skip_records + .into_iter() + .filter(|r| accessible_uuids.contains(r["uuid"].as_str().unwrap_or_default())) + .collect(); + (kept_accessible, narrowing.kept, skips, narrowing.warnings) }; // Layout refusals print even when informational output is quieted only // by --json (stderr; the envelope carries them too) — but --silent @@ -2368,18 +2872,6 @@ pub async fn run(args: GetArgs) -> i32 { eprintln!("Warning ({code}): {detail}"); } } - if !quiet { - for rec in &narrow_skips { - let reason = match rec["errorCode"].as_str() { - Some("package_not_installed") | None => "version not installed", - Some(code) => code, - }; - eprintln!( - " [skip] {} ({reason})", - rec["purl"].as_str().unwrap_or_default() - ); - } - } if accessible.is_empty() { // Every accessible patch was narrowed out. Additive status (never // `no_match`, which is pinned to the fuzzy package-name path): @@ -2395,63 +2887,99 @@ pub async fn run(args: GetArgs) -> i32 { fold_narrowing_into_result(&mut result, &[], &narrow_warnings); print_json(&result); } else if !args.common.silent { - // When EVERY skip is a PnP layout refusal, "not installed" and - // the --all-releases advice would both be wrong: the packages - // were never judged (structurally invisible), and the escape - // hatch cannot make a PnP layout patchable — point at the - // layout warning above instead. - let pnp_only = narrow_skips.iter().all(|rec| { - matches!( - rec["errorCode"].as_str(), - Some("yarn_pnp_unsupported" | "pnpm_pnp_unsupported") - ) - }); - if pnp_only { - println!( - "Found {} patch(es), but this project's Plug'n'Play layout makes its npm \ - packages unpatchable here — see the layout warning above for the remedy.", - narrow_skips.len() - ); - } else { - println!( - "Patches exist for {} package version(s), but none of those versions are \ - installed here. Use --all-releases to fetch them anyway.", - narrow_skips.len() - ); + println!("{}", format_all_narrowed(&narrow_skips)); + if !quiet && args.common.verbose { + for line in format_verbose_skips(&narrow_skips) { + eprintln!("{line}"); + } } } return 0; } + // The listing shows only what survived the narrowing (a CVE fan-out + // can span dozens of versions that are not installed here): the + // skipped ones are summarized in one line each instead, with the + // per-version detail after the summary under --verbose. + let listed: Vec<&PatchSearchResult> = listed.iter().collect(); + if !quiet { + if id_type == IdentifierType::Package || !narrow_warnings.is_empty() { + // Separate the stderr lines above (`Best match`, warnings) on a + // terminal; stdout itself starts with the result. + eprintln!(); + } + print!( + "{}", + format_search_results(&listed, search_response.can_access_paid_patches, color) + ); + let mut skip_lines = format_skip_summary(&narrow_skips); + if args.common.verbose { + skip_lines.extend(format_verbose_skips(&narrow_skips)); + } + for line in &skip_lines { + eprintln!("{line}"); + } + if !skip_lines.is_empty() { + eprintln!(); + } + } + // Smart patch selection: pick one patch per PURL. `accessible` is // non-empty here and every entry passes the selector's tier filter, so // the selection is never empty (one patch per purl group, or `Err`). let selected = match select_patches( &accessible, search_response.can_access_paid_patches, - args.common.json, + &args.common, ) { Ok(s) => s, Err(code) => return code, }; + // The candidates can hold several patches per package and the pick + // was made without the user (paid auto-pick, `--yes`, non-TTY): say + // which will be installed. A menu pick is not echoed back, and paid + // no-access entries (never candidates) do not count. + if !quiet + && accessible.len() > selected.len() + && !selection_prompted( + &accessible, + search_response.can_access_paid_patches, + &args.common, + ) + { + print!("{}", format_selected_patches(&selected, color)); + } + + // Agent-mode dry run: preview against the manifest, write nothing. + // (Hosted/vendored dry runs are handled inside their engines.) The + // per-release variant narrowing the wet run applies inside the + // download engine runs here too, so the preview names only the + // variants a wet run would fetch. + if args.common.dry_run && mode == super::scan::ScanMode::Agent { + let (selected, variant_warnings, _views) = filter_to_installed_releases( + &selected, + args.all_releases, + &crawler_options_for(&args.common), + quiet, + &api_client, + ) + .await; + let mut narrow_warnings = narrow_warnings; + narrow_warnings.extend( + variant_warnings + .into_iter() + .map(|w| ("release_narrowing".to_string(), w)), + ); + return agent_dry_run(&args, &selected, &narrow_skips, &narrow_warnings).await; + } + // Confirm before acting (default YES), with mode-appropriate wording. - // Hosted/vendored dry-runs skip the prompt — nothing mutates (scan's - // dry-run posture); agent mode keeps today's behavior. - let prompt = match mode { - super::scan::ScanMode::Agent => format!("Download {} patch(es)?", selected.len()), - super::scan::ScanMode::Vendored => { - format!("Download and vendor {} patch(es)?", selected.len()) - } - super::scan::ScanMode::Hosted => format!( - "Redirect {} package(s) to the hosted patch server?", - selected.len() - ), - }; - let skip_confirm = mode != super::scan::ScanMode::Agent && args.common.dry_run; - if !skip_confirm && !confirm(&prompt, true, args.common.yes, args.common.json) { + // Dry runs skip the prompt: nothing mutates, so nothing to confirm. + let prompt = format_confirm_prompt(mode, selected.len(), args.save_only); + if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if !quiet { - println!("Download cancelled."); + eprintln!("Cancelled; no changes made."); } return 0; } @@ -2534,54 +3062,125 @@ pub async fn run(args: GetArgs) -> i32 { code } -/// Print the patches a search turned up, grouped by PURL and best-first -/// within each PURL — the same order [`select_patches`] resolves in, so the -/// listing's first entry for a package is the one that will be applied. -/// A `by-cve` / `by-ghsa` search can span several packages, hence the PURL -/// grouping. -fn display_search_results(patches: &[PatchSearchResult], can_access_paid: bool) { - println!("\nFound patches:\n"); - - let mut patches: Vec<&PatchSearchResult> = patches.iter().collect(); - patches.sort_by(|a, b| a.purl.cmp(&b.purl).then_with(|| cmp_search_results(a, b))); - - for (i, patch) in patches.iter().enumerate() { - let tier_label = if patch.tier == "paid" { - " [PAID]" - } else { - " [FREE]" - }; - let access_label = if patch.tier == "paid" && !can_access_paid { - " (no access)" - } else { - "" - }; - - println!(" {}. {}{}{}", i + 1, patch.purl, tier_label, access_label); - println!(" UUID: {}", patch.uuid); - if !patch.description.is_empty() { - let desc = truncate_with_ellipsis(&patch.description, 80); - println!(" Description: {desc}"); +/// `paid_required` for the uuid path: the patch exists but the caller +/// (on the public proxy) cannot download it. A clean outcome, exit 0. +/// `purl` is `None` when the proxy refused with 403 before naming it. +async fn report_paid_required_uuid( + args: &GetArgs, + purl: Option<&str>, + patch_id: &str, + fallback_to_proxy: bool, + telemetry_token: Option<&str>, + telemetry_org: Option<&str>, +) -> i32 { + track_patch_fetch_failed( + patch_id, + "paid_required", + fallback_to_proxy, + telemetry_token, + telemetry_org, + ) + .await; + if args.common.json { + let mut record = serde_json::json!({ "uuid": patch_id, "tier": "paid" }); + if let Some(purl) = purl { + record["purl"] = serde_json::json!(purl); } + print_json(&serde_json::json!({ + "status": "paid_required", + "found": 1, + "downloaded": 0, + "applied": 0, + "patches": [record], + })); + } else if !args.common.silent { + let name = purl.map(|p| normalize_purl(p).into_owned()); + println!( + "{}", + format_paid_required(name.as_deref().unwrap_or(patch_id)) + ); + } + 0 +} - let vuln_ids: Vec<_> = patch.vulnerabilities.keys().collect(); - if !vuln_ids.is_empty() { - let vuln_summary: Vec = patch - .vulnerabilities - .iter() - .map(|(id, vuln)| { - let cves = if vuln.cves.is_empty() { - id.to_string() - } else { - vuln.cves.join(", ") - }; - format!("{cves} ({})", vuln.severity) - }) - .collect(); - println!(" Fixes: {}", vuln_summary.join(", ")); +/// Agent-mode `--dry-run`: classify each selected patch against the +/// manifest (read-only) and report what a wet run would do — no download, +/// no manifest or blob write, no apply, no prompt. JSON carries +/// `dryRun: true` and per-patch `would_add` / `would_update` (+`oldUuid`) +/// / `skipped` records, plus the narrowing skips. +async fn agent_dry_run( + args: &GetArgs, + selected: &[PatchSearchResult], + narrow_skips: &[serde_json::Value], + narrow_warnings: &[(String, String)], +) -> i32 { + // Fail closed like the wet run: a preview over an unreadable manifest + // would promise an outcome the wet run refuses. + let manifest = match read_manifest(&args.common.resolved_manifest_path()).await { + Ok(m) => m.unwrap_or_else(PatchManifest::new), + Err(e) => { + report_error(args.common.json, format!("Failed to read manifest: {e}")); + return 1; + } + }; + let mut records = Vec::new(); + let mut lines = Vec::new(); + let mut changing = 0usize; + let mut skipped = 0usize; + for p in selected { + let shown = normalize_purl(&p.purl); + match decide_patch_action(&manifest, &p.purl, &p.uuid) { + PatchAction::Added => { + changing += 1; + lines.push(format!(" [would-add] {shown}")); + records.push(serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_add", + })); + } + PatchAction::Updated { old_uuid } => { + changing += 1; + lines.push(format!( + " [would-update] {shown} (replacing {})", + short_uuid(&old_uuid) + )); + records.push(serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "would_update", + "oldUuid": old_uuid, + })); + } + PatchAction::Skipped => { + skipped += 1; + lines.push(format!(" [skip] {shown} (already in manifest)")); + records.push(serde_json::json!({ + "purl": p.purl, "uuid": p.uuid, "action": "skipped", + })); + } } - println!(); } + if args.common.json { + let mut result = serde_json::json!({ + "status": "success", + "dryRun": true, + "found": selected.len(), + "downloaded": 0, + "skipped": skipped, + "applied": 0, + "patches": records, + }); + fold_narrowing_into_result(&mut result, narrow_skips, narrow_warnings); + print_json(&result); + } else if !args.common.silent { + for line in &lines { + println!("{line}"); + } + let action = if args.save_only { + "download and record" + } else { + "download and apply" + }; + println!("{}", format_dry_run(action, changing)); + } + 0 } /// The manifest-record half of the agent single-uuid save, under the apply @@ -2675,7 +3274,7 @@ async fn save_patch_record( if let Err(e) = write_manifest(manifest_path, &manifest).await { // No record points at the blobs just written: unwind exactly those. unwind_new_blobs(&blobs_dir, &new_blobs).await; - report_error(args.common.json, format!("Error writing manifest: {e}")); + report_error(args.common.json, format!("Failed to write manifest: {e}")); return Err(1); } Ok(action) @@ -2692,13 +3291,18 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR let manifest_path = args.common.resolved_manifest_path(); let socket_dir = args.common.socket_dir(); let lock_timeout = Duration::from_secs(args.common.lock_timeout.unwrap_or(0)); + // A dry run previews against the manifest and writes nothing — not + // even the lock (which would create `.socket/`). + if args.common.dry_run { + return agent_dry_run(args, &[search_result_from_response(patch)], &[], &[]).await; + } // See `download_and_apply_patches_with`: the RMW runs under the lock, // which also creates `.socket/` and prunes it again when nothing lands; // an error return below drops the guard. - let guard = match apply_lock::acquire(&socket_dir, lock_timeout) { + let guard = match crate::commands::lock_cli::acquire_with_status(&socket_dir, lock_timeout) { Ok(guard) => guard, Err(e) => { - report_lock_failure(args.common.json, &e, lock_timeout); + report_lock_failure(args.common.json, &socket_dir, &e, lock_timeout); return 1; } }; @@ -2741,25 +3345,23 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR .await; } + // Progress narration goes to stderr, like the search path's. if !quiet { - println!("\nPatch saved to {}", manifest_path.display()); - match &action { - PatchAction::Added => println!(" Added: 1"), - PatchAction::Updated { old_uuid } => { - println!(" Updated: 1 (replacing {})", short_uuid(old_uuid)); - } - PatchAction::Skipped => println!(" Skipped: 1 (already exists)"), - } + eprintln!( + "{}", + format_single_save("Patch", &action, &manifest_path, &patch.purl, true) + ); } let mut apply_succeeded = false; if let Some(lock) = apply_lock { if !quiet { - println!("\nApplying patches..."); + eprintln!(); + eprintln!("Applying patches..."); } apply_succeeded = run_nested_apply( nested_apply_args(&args.common, &manifest_path, quiet), - quiet, + args.common.json, client, lock, ) @@ -2927,10 +3529,7 @@ async fn run_get_vendored( result["vendor"] = preview; print_json(&result); } else if !args.common.silent { - println!( - "[dry-run] Would download and vendor {} patch(es).", - selected.len() - ); + println!("{}", format_dry_run("download and vendor", selected.len())); super::scan::print_dry_run_refusals(&preview); } return 0; @@ -2990,7 +3589,10 @@ async fn run_get_vendored( }], })); } else { - eprintln!("Error ({code}): {detail}"); + eprintln!( + "{}", + crate::commands::scan::vendor_flow::format_vendor_step_error(code, detail) + ); } return 1; } @@ -3089,7 +3691,10 @@ async fn run_get_vendored( result["error"] = serde_json::json!({ "code": code, "message": message }); print_json(&result); } else { - eprintln!("Error ({code}): {message}"); + eprintln!( + "{}", + crate::commands::scan::vendor_flow::format_vendor_step_error(code, &message) + ); } 1 } @@ -3294,6 +3899,17 @@ mod tests { // --- select_patches --------------------------------------------------- + fn human_args() -> GlobalArgs { + GlobalArgs::default() + } + + fn json_args() -> GlobalArgs { + GlobalArgs { + json: true, + ..GlobalArgs::default() + } + } + fn mk_patch(uuid: &str, purl: &str, tier: &str, published_at: &str) -> PatchSearchResult { PatchSearchResult { uuid: uuid.into(), @@ -3331,7 +3947,7 @@ mod tests { #[test] fn select_free_user_one_free_patch_returns_it() { let patches = vec![mk_patch("u1", "pkg:npm/foo@1.0", "free", "2024-01-01")]; - let out = select_patches(&patches, false, false).expect("ok"); + let out = select_patches(&patches, false, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "u1"); } @@ -3351,7 +3967,7 @@ mod tests { "critical", ), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "old_crit"); } @@ -3371,7 +3987,7 @@ mod tests { "critical", ), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "free_crit"); assert_eq!(out[0].tier, "free"); @@ -3418,7 +4034,7 @@ mod tests { &["high", "high"], ), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "z_merged"); } @@ -3445,7 +4061,7 @@ mod tests { "critical", ), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "z_critical"); } @@ -3465,7 +4081,7 @@ mod tests { mk_patch_sev("a_older", "pkg:npm/foo@1.0", "paid", older, "high"), mk_patch_sev("z_newer", "pkg:npm/foo@1.0", "paid", newer, "high"), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "z_newer"); } @@ -3497,7 +4113,7 @@ mod tests { "HIGH", ), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1, "one patch per PURL"); assert_eq!(out[0].uuid, "83f5a654"); } @@ -3513,7 +4129,7 @@ mod tests { mk_patch("b", "pkg:npm/bbb@1.0", "paid", "2024-01-01"), ]; for _ in 0..8 { - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); let purls: Vec<&str> = out.iter().map(|p| p.purl.as_str()).collect(); assert_eq!( purls, @@ -3530,7 +4146,7 @@ mod tests { mk_patch("free1", "pkg:npm/foo@1.0", "free", "2024-01-01"), mk_patch("paid1", "pkg:npm/foo@1.0", "paid", "2024-01-01"), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "paid1"); assert_eq!(out[0].tier, "paid"); @@ -3542,7 +4158,7 @@ mod tests { mk_patch("old", "pkg:npm/foo@1.0", "paid", "2024-01-01"), mk_patch("new", "pkg:npm/foo@1.0", "paid", "2024-06-01"), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "new"); } @@ -3553,7 +4169,7 @@ mod tests { mk_patch("old", "pkg:npm/foo@1.0", "free", "2024-01-01"), mk_patch("new", "pkg:npm/foo@1.0", "free", "2024-06-01"), ]; - let out = select_patches(&patches, true, false).expect("ok"); + let out = select_patches(&patches, true, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "new"); } @@ -3566,17 +4182,17 @@ mod tests { mk_patch("a", "pkg:npm/foo@1.0", "free", "2024-01-01"), mk_patch("b", "pkg:npm/foo@1.0", "free", "2024-06-01"), ]; - let err = select_patches(&patches, false, true).expect_err("should fail"); + let err = select_patches(&patches, false, &json_args()).expect_err("should fail"); assert_eq!(err, 1); } #[test] fn select_empty_input_returns_empty() { - let out = select_patches(&[], false, false).expect("ok"); + let out = select_patches(&[], false, &human_args()).expect("ok"); assert!(out.is_empty()); - let out = select_patches(&[], true, false).expect("ok"); + let out = select_patches(&[], true, &human_args()).expect("ok"); assert!(out.is_empty()); - let out = select_patches(&[], false, true).expect("ok"); + let out = select_patches(&[], false, &json_args()).expect("ok"); assert!(out.is_empty()); } @@ -3589,7 +4205,7 @@ mod tests { mk_patch("paid", "pkg:npm/foo@1.0", "paid", "2024-06-01"), mk_patch("free", "pkg:npm/foo@1.0", "free", "2024-01-01"), ]; - let out = select_patches(&patches, false, false).expect("ok"); + let out = select_patches(&patches, false, &human_args()).expect("ok"); assert_eq!(out.len(), 1); assert_eq!(out[0].uuid, "free"); assert_eq!(out[0].tier, "free"); @@ -3941,54 +4557,6 @@ mod tests { } } - // --- truncate_with_ellipsis ------------------------------------------ - // Patch descriptions come from the API and may contain multi-byte - // UTF-8. The old `&desc[..n]` byte slicing panicked when `n` fell mid - // codepoint; these lock in char-safe behavior. - - #[test] - fn truncate_short_string_unchanged() { - assert_eq!(truncate_with_ellipsis("hello", 60), "hello"); - } - - #[test] - fn truncate_at_limit_unchanged() { - let s = "a".repeat(60); - assert_eq!(truncate_with_ellipsis(&s, 60), s); - } - - #[test] - fn truncate_long_ascii_adds_ellipsis_and_respects_limit() { - let s = "a".repeat(100); - let out = truncate_with_ellipsis(&s, 60); - // 57 content chars + "..." == 60, never wider than the limit. - assert_eq!(out.chars().count(), 60); - assert!(out.ends_with("...")); - assert_eq!(out, format!("{}...", "a".repeat(57))); - } - - #[test] - fn truncate_multibyte_does_not_panic_and_is_char_safe() { - // 90 bytes (30 * 3-byte chars) but only 30 chars: the byte length - // exceeds 80 while the char count does not. A `&s[..77]` byte slice - // would land mid-codepoint and panic; this must return the string - // untouched because it fits within the char limit. - let s = "日".repeat(30); - let out = truncate_with_ellipsis(&s, 80); - assert_eq!(out, s); - } - - #[test] - fn truncate_multibyte_long_truncates_on_char_boundary() { - // 100 multi-byte chars (300 bytes) — must truncate to 77 chars plus - // the ellipsis without ever slicing through a codepoint. - let s = "é".repeat(100); - let out = truncate_with_ellipsis(&s, 80); - assert_eq!(out.chars().count(), 80); - assert!(out.ends_with("...")); - assert_eq!(out, format!("{}...", "é".repeat(77))); - } - // --- write_blob_entry ------------------------------------------------ // Blob hashes come straight from the API response and are used as // filesystem path components (`blobs_dir.join(hash)`). A hostile or @@ -4444,7 +5012,7 @@ mod tests { ); assert_eq!( format_patch_option(&a), - "a [free] (fixes: CVE-2024-0001, CVE-2024-0002) - desc-a" + "a [FREE] (fixes: CVE-2024-0001, CVE-2024-0002) - desc-a" ); } @@ -4464,14 +5032,581 @@ mod tests { ); assert_eq!( format_patch_option(&b), - "b [free] (fixes: GHSA-no-cves) - desc-b" + "b [FREE] (fixes: GHSA-no-cves) - desc-b" ); } #[test] fn patch_option_line_omits_fixes_segment_without_vulnerabilities() { let c = mk_patch("c", "pkg:npm/foo@1.0", "paid", "2024-06-01"); - assert_eq!(format_patch_option(&c), "c [paid] - desc-c"); + assert_eq!(format_patch_option(&c), "c [PAID] - desc-c"); + } + + // --- terminal-UI text helpers ------------------------------------------ + + fn vuln(cves: &[&str], severity: &str, summary: &str) -> VulnerabilityResponse { + VulnerabilityResponse { + cves: cves.iter().map(|c| c.to_string()).collect(), + summary: summary.into(), + severity: severity.into(), + description: String::new(), + } + } + + fn with_vulns( + mut p: PatchSearchResult, + vulns: &[(&str, VulnerabilityResponse)], + ) -> PatchSearchResult { + for (id, v) in vulns { + p.vulnerabilities.insert(id.to_string(), v.clone()); + } + p + } + + #[test] + fn patch_option_line_has_no_dangling_dash_for_empty_description() { + let mut p = mk_patch("u1", "pkg:npm/nuxt@4.5.0", "free", "2024-01-01"); + p.description = String::new(); + let p = with_vulns(p, &[("GHSA-x", vuln(&["CVE-2026-71315"], "high", ""))]); + assert_eq!(format_patch_option(&p), "u1 [FREE] (fixes: CVE-2026-71315)"); + // Whitespace-only descriptions collapse to nothing too. + let mut q = mk_patch("u2", "pkg:npm/nuxt@4.5.0", "free", "2024-01-01"); + q.description = " \n ".into(); + assert_eq!(format_patch_option(&q), "u2 [FREE]"); + } + + #[test] + fn patch_option_line_sorts_ids_across_advisories_and_truncates_multibyte() { + let mut p = mk_patch("u", "pkg:npm/a@1", "free", "2024-01-01"); + p.description = "é".repeat(100); + let p = with_vulns( + p, + &[ + ("GHSA-b", vuln(&["CVE-2026-2"], "low", "")), + ("GHSA-a", vuln(&["CVE-2026-1", "CVE-2025-9"], "high", "")), + ("GHSA-z", vuln(&[], "low", "")), + ], + ); + assert_eq!( + format_patch_option(&p), + format!( + "u [FREE] (fixes: CVE-2025-9, CVE-2026-1, CVE-2026-2, GHSA-z) - {}...", + "é".repeat(57) + ) + ); + } + + #[test] + fn vuln_labels_dedup_and_sort() { + let mut m = HashMap::new(); + m.insert("GHSA-1".to_string(), vuln(&["CVE-2", "CVE-1"], "high", "")); + m.insert("GHSA-2".to_string(), vuln(&["CVE-1"], "low", "")); + assert_eq!(vuln_labels(&m), vec!["CVE-1", "CVE-2"]); + assert!(vuln_labels(&HashMap::new()).is_empty()); + } + + #[test] + fn patch_summary_line_shapes() { + let mut m = HashMap::new(); + m.insert( + "GHSA-1".to_string(), + vuln(&["CVE-2021-44906"], "critical", ""), + ); + assert_eq!( + format_patch_summary("pkg:npm/minimist@1.2.5", "free", None, &m, false), + "pkg:npm/minimist@1.2.5 [FREE] fixes CVE-2021-44906 (CRITICAL)" + ); + assert_eq!( + format_patch_summary( + "pkg:npm/%40scope/x@1.0.0", + "paid", + Some("a8b05a61-1e2f-4c5f-a65b-93e71deba1ae"), + &m, + false + ), + "pkg:npm/@scope/x@1.0.0 [PAID] a8b05a61: fixes CVE-2021-44906 (CRITICAL)" + ); + // No advisories: no `fixes`, no colon. + assert_eq!( + format_patch_summary( + "pkg:npm/a@1", + "free", + Some("abcdef0123"), + &HashMap::new(), + false + ), + "pkg:npm/a@1 [FREE] abcdef01" + ); + // Unknown severity: ids without a severity suffix. + let mut u = HashMap::new(); + u.insert("GHSA-2".to_string(), vuln(&[], "", "")); + assert_eq!( + format_patch_summary("pkg:npm/a@1", "free", None, &u, false), + "pkg:npm/a@1 [FREE] fixes GHSA-2" + ); + // Several advisories: the max severity is labeled as such, and + // colored like the listing when color is on. + let mut several = HashMap::new(); + several.insert("GHSA-1".to_string(), vuln(&["CVE-2026-1"], "high", "")); + several.insert("GHSA-2".to_string(), vuln(&["CVE-2026-2"], "moderate", "")); + assert_eq!( + format_patch_summary( + "pkg:npm/nuxt@4.5.0", + "paid", + Some("884e9f6d-x"), + &several, + false + ), + "pkg:npm/nuxt@4.5.0 [PAID] 884e9f6d: fixes CVE-2026-1, CVE-2026-2 (highest: HIGH)" + ); + assert_eq!( + format_patch_summary("pkg:npm/minimist@1.2.5", "free", None, &m, true), + "pkg:npm/minimist@1.2.5 [FREE] fixes CVE-2021-44906 (\x1b[91mCRITICAL\x1b[0m)" + ); + } + + #[test] + fn natural_cmp_orders_versions_numerically() { + let mut v = vec![ + "pkg:npm/lodash@4.17.10", + "pkg:npm/lodash@4.2.0", + "pkg:npm/lodash@4.17.2", + "pkg:npm/lodash@4.10.0", + "pkg:npm/lodash@4.9.0", + "pkg:npm/lodash-amd@4.0.0", + ]; + v.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!( + v, + vec![ + "pkg:npm/lodash-amd@4.0.0", + "pkg:npm/lodash@4.2.0", + "pkg:npm/lodash@4.9.0", + "pkg:npm/lodash@4.10.0", + "pkg:npm/lodash@4.17.2", + "pkg:npm/lodash@4.17.10", + ] + ); + use std::cmp::Ordering; + assert_eq!(natural_cmp("a1", "a01"), "a1".cmp("a01")); + assert_ne!(natural_cmp("a1", "a01"), Ordering::Equal); + assert_eq!(natural_cmp("", ""), Ordering::Equal); + assert_eq!(natural_cmp("a", "a1"), Ordering::Less); + assert_eq!( + natural_cmp("x99999999999999999999999", "x1"), + Ordering::Greater + ); + assert_eq!(natural_cmp("é2", "é10"), Ordering::Less); + } + + #[test] + fn search_results_listing_exact_text() { + let a = with_vulns( + mk_patch("uuid-a", "pkg:npm/lodash@4.17.10", "free", "2024-01-01"), + &[ + ("GHSA-b", vuln(&["CVE-2026-2"], "MODERATE", "")), + ("GHSA-a", vuln(&["CVE-2026-1"], "HIGH", "")), + ], + ); + let mut b = mk_patch("uuid-b", "pkg:npm/lodash@4.17.2", "paid", "2024-01-01"); + b.description = String::new(); + let out = format_search_results(&[&a, &b], false, false); + assert_eq!( + out, + "Found 2 patches:\n\n\ + \x20 1. pkg:npm/lodash@4.17.2 [PAID] (no access)\n\ + \x20 UUID: uuid-b\n\n\ + \x20 2. pkg:npm/lodash@4.17.10 [FREE]\n\ + \x20 UUID: uuid-a\n\ + \x20 Description: desc-uuid-a\n\ + \x20 Fixes: CVE-2026-1 (HIGH), CVE-2026-2 (MODERATE)\n\n" + ); + let one = format_search_results(&[&a], true, false); + assert!(one.starts_with("Found 1 patch:\n\n"), "{one}"); + assert_eq!( + format_search_results(&[], true, false), + "Found 0 patches:\n\n" + ); + let colored = format_search_results(&[&a], true, true); + assert!( + colored.contains("CVE-2026-1 (\x1b[31mHIGH\x1b[0m)"), + "{colored:?}" + ); + } + + #[test] + fn selected_block_names_uuid_and_fixes() { + let a = with_vulns( + mk_patch( + "6332e781-0a42-4b0e-95c4-61f834461268", + "pkg:npm/lodash@4.17.20", + "free", + "2024-01-01", + ), + &[("GHSA-a", vuln(&["CVE-2026-4800"], "HIGH", ""))], + ); + assert_eq!( + format_selected_patches(&[a], false), + "Selected:\n pkg:npm/lodash@4.17.20 [FREE] 6332e781: fixes CVE-2026-4800 (HIGH)\n\n" + ); + assert_eq!(format_selected_patches(&[], false), "Selected:\n\n"); + } + + fn skip(purl: &str, code: &str) -> serde_json::Value { + serde_json::json!({"purl": purl, "uuid": "u", "action": "skipped", "errorCode": code}) + } + + #[test] + fn skip_summary_one_line_per_reason() { + assert!(format_skip_summary(&[]).is_empty()); + assert_eq!( + format_skip_summary(&[skip("pkg:npm/a@1", "package_not_installed")]), + vec![ + "Skipped 1 patch for 1 package version not installed here \ + (use --all-releases to include it)." + ] + ); + let many = vec![ + skip("pkg:npm/a@1", "package_not_installed"), + skip("pkg:npm/a@1", "package_not_installed"), + skip("pkg:npm/a@2", "package_not_installed"), + skip("pkg:npm/b@1", "yarn_pnp_unsupported"), + ]; + assert_eq!( + format_skip_summary(&many), + vec![ + "Skipped 3 patches for 2 package versions not installed here \ + (use --all-releases to include them)." + .to_string(), + "Skipped 1 patch for 1 package version (yarn_pnp_unsupported; \ + see the warning above)." + .to_string(), + ] + ); + } + + #[test] + fn all_narrowed_message_plurals_and_pnp() { + assert_eq!( + format_all_narrowed(&[skip("pkg:npm/a@1", "package_not_installed")]), + "Patches exist for 1 package version, but it is not installed here. \ + Use --all-releases to fetch it anyway." + ); + assert_eq!( + format_all_narrowed(&[ + skip("pkg:npm/a@1", "package_not_installed"), + skip("pkg:npm/a@2", "package_not_installed"), + skip("pkg:npm/a@2", "package_not_installed"), + ]), + "Patches exist for 2 package versions, but none of them are installed here. \ + Use --all-releases to fetch them anyway." + ); + assert_eq!( + format_all_narrowed(&[skip("pkg:npm/a@1", "pnpm_pnp_unsupported")]), + "Found 1 patch, but this project's Plug'n'Play layout makes its npm packages \ + unpatchable here; see the layout warning above for the remedy." + ); + } + + #[test] + fn confirm_prompts_per_mode() { + use super::super::scan::ScanMode; + assert_eq!( + format_confirm_prompt(ScanMode::Agent, 1, false), + "Download and apply 1 patch?" + ); + assert_eq!( + format_confirm_prompt(ScanMode::Agent, 2, false), + "Download and apply 2 patches?" + ); + assert_eq!( + format_confirm_prompt(ScanMode::Agent, 1, true), + "Download 1 patch?" + ); + assert_eq!( + format_confirm_prompt(ScanMode::Vendored, 3, false), + "Download and vendor 3 patches?" + ); + assert_eq!( + format_confirm_prompt(ScanMode::Hosted, 1, false), + "Redirect 1 package to the hosted patch server?" + ); + assert_eq!( + format_confirm_prompt(ScanMode::Hosted, 0, false), + "Redirect 0 packages to the hosted patch server?" + ); + } + + #[test] + fn dry_run_line_plurals() { + assert_eq!( + format_dry_run("download and apply", 1), + "[dry-run] Would download and apply 1 patch. No changes made." + ); + assert_eq!( + format_dry_run("download and vendor", 0), + "[dry-run] Would download and vendor 0 patches. No changes made." + ); + } + + #[test] + fn no_packages_message_points_at_the_package_manager() { + assert_eq!(no_packages_message(true), "No global packages found."); + assert_eq!( + no_packages_message(false), + "No packages found. Run your package manager's install first." + ); + } + + #[test] + fn paid_required_text() { + assert_eq!( + format_paid_required("pkg:npm/a@1"), + "This patch requires a paid subscription to download.\n \ + Patch: pkg:npm/a@1\n \ + Upgrade at: https://socket.dev/pricing" + ); + } + + #[test] + fn save_summary_lines() { + let m = Path::new(".socket/manifest.json"); + assert_eq!( + format_save_summary(m, 2, 0, 0, 0), + "Patches saved to .socket/manifest.json\n Added: 2" + ); + assert_eq!( + format_save_summary(m, 1, 1, 1, 1), + "Patches saved to .socket/manifest.json\n Added: 1\n Updated: 1\n Skipped: 1\n Failed: 1" + ); + assert_eq!( + format_save_summary(m, 0, 0, 2, 0), + "No changes to .socket/manifest.json\n Added: 0\n Skipped: 2" + ); + assert_eq!( + format_save_summary(m, 0, 0, 0, 1), + "No changes to .socket/manifest.json\n Added: 0\n Failed: 1" + ); + } + + #[test] + fn best_match_line_names_the_count_only_when_there_was_a_choice() { + assert_eq!( + format_best_match("pkg:npm/%40s/a@1", 1), + "Best match: pkg:npm/@s/a@1" + ); + assert_eq!( + format_best_match("pkg:npm/a@1", 3), + "Best match: pkg:npm/a@1 (of 3 matching packages)" + ); + } + + #[test] + fn verbose_skips_dedupe_and_sort_naturally() { + let rec = |purl: &str, code: Option<&str>| { + let mut r = serde_json::json!({"purl": purl, "action": "skipped"}); + if let Some(c) = code { + r["errorCode"] = serde_json::json!(c); + } + r + }; + let skips = vec![ + rec("pkg:npm/a@4.10.0", Some("package_not_installed")), + rec("pkg:npm/a@4.2.0", None), + rec("pkg:npm/a@4.10.0", Some("package_not_installed")), + rec("pkg:npm/a@4.1.0", Some("yarn_pnp_unsupported")), + ]; + assert_eq!( + format_verbose_skips(&skips), + vec![ + " [skip] pkg:npm/a@4.1.0 (yarn_pnp_unsupported)", + " [skip] pkg:npm/a@4.2.0 (version not installed)", + " [skip] pkg:npm/a@4.10.0 (version not installed)", + ] + ); + assert!(format_verbose_skips(&[]).is_empty()); + } + + #[test] + fn selection_prompted_only_for_an_interactive_free_choice() { + let two = vec![ + mk_patch("a", "pkg:npm/x@1", "free", "2024-01-01"), + mk_patch("b", "pkg:npm/x@1", "free", "2024-02-01"), + ]; + let one_plus_paid = vec![ + mk_patch("a", "pkg:npm/x@1", "free", "2024-01-01"), + mk_patch("b", "pkg:npm/x@1", "paid", "2024-02-01"), + ]; + let plain = GlobalArgs::default(); + let yes = GlobalArgs { + yes: true, + ..GlobalArgs::default() + }; + let json = GlobalArgs { + json: true, + ..GlobalArgs::default() + }; + // Paid users, --yes and --json never see a menu. + assert!(!selection_prompted(&two, true, &plain)); + assert!(!selection_prompted(&two, false, &yes)); + assert!(!selection_prompted(&two, false, &json)); + // A single free candidate is auto-picked. + assert!(!selection_prompted(&one_plus_paid, false, &plain)); + // Several free candidates prompt exactly when stdin is a terminal. + use std::io::IsTerminal; + assert_eq!( + selection_prompted(&two, false, &plain), + std::io::stdin().is_terminal() + ); + } + + #[test] + fn single_save_lines() { + let m = Path::new("p/.socket/manifest.json"); + assert_eq!( + format_single_save("Patch", &PatchAction::Added, m, "pkg:npm/a@1", true), + "Patch saved to p/.socket/manifest.json\n Added: 1" + ); + assert_eq!( + format_single_save( + "Patch record", + &PatchAction::Updated { + old_uuid: "0123456789abcdef".into() + }, + m, + "pkg:npm/a@1", + false + ), + "Patch record saved to p/.socket/manifest.json\n Updated: 1 (replacing 01234567)" + ); + // A malformed short uuid never panics. + assert!(format_single_save( + "Patch", + &PatchAction::Updated { + old_uuid: "é".into() + }, + m, + "x", + true + ) + .ends_with("(replacing é)")); + assert_eq!( + format_single_save("Patch", &PatchAction::Skipped, m, "pkg:npm/%40s/a@1", true), + "pkg:npm/@s/a@1 already has this patch recorded in p/.socket/manifest.json; \ + nothing to update." + ); + // The vendored path still runs its vendor step: no "nothing to + // update" promise. + assert_eq!( + format_single_save( + "Patch record", + &PatchAction::Skipped, + m, + "pkg:npm/a@1", + false + ), + "pkg:npm/a@1 already has this patch recorded in p/.socket/manifest.json." + ); + } + + #[test] + fn record_skip_line_decodes_the_purl() { + assert_eq!( + format_record_skip("pkg:npm/%40scope/a@1.0.0", "already vendored"), + " [skip] pkg:npm/@scope/a@1.0.0 (already vendored)" + ); + assert_eq!( + format_record_skip("pkg:npm/a@1", "already in manifest"), + " [skip] pkg:npm/a@1 (already in manifest)" + ); + } + + #[test] + fn apply_failed_line_is_an_error_even_when_silent() { + assert_eq!(APPLY_FAILED, "Error: Some patches could not be applied."); + } + + #[test] + fn forced_identifier_shapes() { + assert_eq!( + forced_identifier_error("lodash", IdentifierType::Uuid).as_deref(), + Some("\"lodash\" is not a valid patch UUID (expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)") + ); + assert_eq!( + forced_identifier_error("lodash", IdentifierType::Cve).as_deref(), + Some("\"lodash\" is not a valid CVE ID (expected CVE-YYYY-NNNN)") + ); + assert_eq!( + forced_identifier_error("GHSA-1", IdentifierType::Ghsa).as_deref(), + Some("\"GHSA-1\" is not a valid GHSA ID (expected GHSA-xxxx-xxxx-xxxx)") + ); + assert_eq!( + forced_identifier_error("a8b05a61-1e2f-4c5f-a65b-93e71deba1ae", IdentifierType::Uuid), + None + ); + assert_eq!( + forced_identifier_error("cve-2021-44906", IdentifierType::Cve), + None + ); + assert_eq!( + forced_identifier_error("GHSA-xvch-5gv4-984h", IdentifierType::Ghsa), + None + ); + assert_eq!( + forced_identifier_error("anything", IdentifierType::Package), + None + ); + assert_eq!( + forced_identifier_error("anything", IdentifierType::Purl), + None + ); + } + + #[test] + fn yes_auto_picks_the_top_ranked_free_patch_without_a_prompt() { + // Two free patches for one purl would open the interactive menu; + // --yes answers it with its default (the top-ranked patch). + let patches = vec![ + mk_patch("old", "pkg:npm/foo@1.0", "free", "2024-01-01"), + mk_patch("new", "pkg:npm/foo@1.0", "free", "2024-06-01"), + ]; + let yes = GlobalArgs { + yes: true, + ..GlobalArgs::default() + }; + let out = select_patches(&patches, false, &yes).expect("ok"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "new"); + // --json keeps its selection_required contract even with --yes. + let json_yes = GlobalArgs { + yes: true, + json: true, + ..GlobalArgs::default() + }; + assert_eq!(select_patches(&patches, false, &json_yes).unwrap_err(), 1); + } + + #[test] + fn help_text_has_no_implementation_notes() { + use clap::CommandFactory; + let mut cmd = crate::Cli::command(); + let get = cmd + .find_subcommand_mut("get") + .expect("get subcommand") + .clone(); + let mut get = get; + let help = get.render_long_help().to_string(); + for leak in [ + "value_parser", + "parse_bool_flag", + "No env binding", + "locally- installed", + "SOCKET_ONE_OFF", + "--one-off", + ] { + assert!(!help.contains(leak), "get --help leaks {leak:?}:\n{help}"); + } + assert!(help.contains("locally-installed distribution"), "{help}"); } // --- download_patch_records (detached download phase) ------------------ @@ -4731,7 +5866,7 @@ mod tests { description: "desc-b".to_string(), }, ); - let result = select_patches(&[a, b], false, true); + let result = select_patches(&[a, b], false, &json_args()); assert_eq!( result.err(), Some(1), diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 936efbb9..c74f6ad4 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use clap::Args; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; @@ -7,7 +9,7 @@ use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ - Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, + Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, RunWarning, }; #[derive(Args)] @@ -173,17 +175,139 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { /// Emit the top-level envelope for `list` in error states. Used for the /// "manifest not found" and "manifest unreadable" paths so they share -/// the same JSON shape as a successful list. -fn emit_error(args: &ListArgs, code: &str, message: String) { +/// the same JSON shape as a successful list. `warnings` gathered before +/// the error (a corrupt redirect ledger) ride the error envelope too, so a +/// JSON consumer sees them on every exit path. +fn emit_error(args: &ListArgs, code: &str, message: String, warnings: Vec) { if args.common.json { let mut env = Envelope::new(Command::List); env.mark_error(EnvelopeError::new(code, message)); + env.warnings = warnings; println!("{}", env.to_pretty_json()); } else { eprintln!("Error: {message}"); } } +/// The message for a manifest that exists but could not be read, naming +/// the file (the bare io/serde text — "Permission denied (os error 13)", +/// "EOF while parsing ..." — doesn't say which file). Shared with `repair`. +pub(crate) fn manifest_error_message(path: &Path, e: &std::io::Error) -> String { + if e.kind() == std::io::ErrorKind::InvalidData { + let detail = e.to_string(); + let detail = detail + .strip_prefix("Failed to parse manifest JSON: ") + .map(|d| format!("not valid JSON: {d}")) + .or_else(|| { + detail + .strip_prefix("Invalid manifest: ") + .map(str::to_string) + }) + .unwrap_or(detail); + format!("Invalid manifest at {}: {detail}", path.display()) + } else { + format!("Could not read manifest at {}: {e}", path.display()) + } +} + +/// Manifest/ledger text is free-form (API-sourced descriptions): drop +/// control characters that would rewrite the terminal (ESC, a stray +/// `\r`), keeping newlines and tabs, and normalize `\r\n`. +fn sanitize(s: &str) -> String { + s.replace("\r\n", "\n") + .chars() + .filter(|&c| c == '\n' || c == '\t' || !c.is_control()) + .collect() +} + +/// `"{indent}{label}: {value}"`, or `None` when the value is blank (no +/// dangling `License: ` line). Continuation lines of a multi-line value +/// are indented two past the label so they stay inside the entry. +fn field(indent: &str, label: &str, value: &str) -> Option { + let value = sanitize(value); + let value = value.trim(); + if value.is_empty() { + return None; + } + let continuation = format!("\n{indent} "); + let body = value + .lines() + .map(str::trim_end) + .collect::>() + .join(&continuation); + Some(format!("{indent}{label}: {body}")) +} + +/// One entry of the human listing (no trailing newline). +fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { + let patch = entry.record; + let mut lines = vec![format!("Package: {}", sanitize(entry.purl))]; + lines.extend(field(" ", "UUID", &patch.uuid)); + if let Some((mode, ledger)) = ledger_label(entry.source) { + // Same labeling rule as the JSON details: the record comes from a + // ledger, not the manifest — hosted installs resolve the package + // to the hosted patch server, vendored ones to the committed + // `.socket/vendor/` artifact; no manifest entry exists or is + // needed. + lines.push(format!(" Mode: {mode} (recorded in {ledger})")); + } + lines.extend(field(" ", "Tier", &patch.tier)); + lines.extend(field(" ", "License", &patch.license)); + lines.extend(field(" ", "Exported", &patch.exported_at)); + lines.extend(field(" ", "Description", &patch.description)); + + // Sort vulnerabilities by advisory ID for stable output. + let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect(); + vuln_entries.sort_by(|a, b| a.0.cmp(b.0)); + if !vuln_entries.is_empty() { + lines.push(format!(" Vulnerabilities ({}):", vuln_entries.len())); + for (id, vuln) in &vuln_entries { + let cve_list = if vuln.cves.is_empty() { + String::new() + } else { + format!(" ({})", sanitize(&vuln.cves.join(", "))) + }; + lines.push(format!(" - {}{cve_list}", sanitize(id))); + // Upper-cased like scan's table, and colored by tier on a + // color terminal. + let severity = sanitize(vuln.severity.trim()).to_uppercase(); + if !severity.is_empty() { + lines.push(format!( + " Severity: {}", + crate::ui::severity(&severity, color) + )); + } + lines.extend(field(" ", "Summary", &vuln.summary)); + } + } + + // Sort patched files by path for stable output. + let mut file_list: Vec<_> = patch.files.keys().collect(); + file_list.sort(); + if !file_list.is_empty() { + lines.push(format!(" Files patched ({}):", file_list.len())); + for file_path in &file_list { + lines.push(format!(" - {}", sanitize(file_path))); + } + } + lines.join("\n") +} + +/// The whole human listing for stdout: a count header, then the entries +/// separated by one blank line (none after the last). +fn format_listing(entries: &[ListEntry<'_>], color: bool) -> String { + if entries.is_empty() { + return "No patches found in manifest.".to_string(); + } + let mut out = format!( + "Found {}:\n\n", + crate::ui::plural(entries.len(), "patch", "patches") + ); + let blocks: Vec = entries.iter().map(|e| format_entry(e, color)).collect(); + out.push_str(&blocks.join("\n\n")); + out +} + pub async fn run(args: ListArgs) -> i32 { apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); @@ -212,7 +336,12 @@ pub async fn run(args: ListArgs) -> i32 { } else { "manifest_unreadable" }; - emit_error(&args, code, e.to_string()); + emit_error( + &args, + code, + manifest_error_message(&manifest_path, &e), + Vec::new(), + ); return 1; } }; @@ -226,9 +355,27 @@ pub async fn run(args: ListArgs) -> i32 { // with `--manifest-path` pointing at another project, reading the LOCAL // cwd's ledgers would interleave two projects' patch state (and a local // ledger could suppress the flagged project's manifest_not_found). + // + // Under --json a corrupt redirect ledger rides the envelope's + // `warnings[]` (stdout is the machine channel; a stderr-only warning + // would vanish for JSON consumers), the same split `update` uses. let project_root = args.common.project_root(); + let mut warnings: Vec = Vec::new(); let redirect_state = - crate::commands::load_redirect_state_lenient(&project_root, args.common.silent).await; + match socket_patch_core::patch::redirect::load_redirect_state(&project_root).await { + Ok(state) => state, + Err(corrupt) => { + if args.common.json { + warnings.push(RunWarning { + code: "redirect_ledger_corrupt".to_string(), + detail: corrupt.to_string(), + }); + } else if !args.common.silent { + eprintln!("Warning: {corrupt}"); + } + None + } + }; let vendor_state = crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; @@ -252,6 +399,7 @@ pub async fn run(args: ListArgs) -> i32 { &args, "manifest_not_found", format!("Manifest not found at {}", manifest_path.display()), + warnings, ); return 1; } @@ -275,64 +423,15 @@ pub async fn run(args: ListArgs) -> i32 { .await; if args.common.json { - println!("{}", build_list_envelope(&entries).to_pretty_json()); + let mut env = build_list_envelope(&entries); + env.warnings = warnings; + println!("{}", env.to_pretty_json()); } else if args.common.silent { // `--silent` is "errors only" (CLI_CONTRACT.md): suppress the // entire human-readable listing, mirroring `get`/`repair`. // The exit code still distinguishes the manifest states. - } else if entries.is_empty() { - println!("No patches found in manifest."); } else { - println!("Found {} patch(es):\n", entries.len()); - for entry in &entries { - let patch = entry.record; - println!("Package: {}", entry.purl); - println!(" UUID: {}", patch.uuid); - if let Some((mode, ledger)) = ledger_label(entry.source) { - // Same labeling rule as the JSON details: the record comes - // from a ledger, not the manifest — hosted installs resolve - // the package to the hosted patch server, vendored ones to - // the committed `.socket/vendor/` artifact; no manifest - // entry exists or is needed. - println!(" Mode: {mode} (recorded in {ledger})"); - } - println!(" Tier: {}", patch.tier); - println!(" License: {}", patch.license); - println!(" Exported: {}", patch.exported_at); - - if !patch.description.is_empty() { - println!(" Description: {}", patch.description); - } - - // Sort vulnerabilities by advisory ID for stable output. - let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect(); - vuln_entries.sort_by(|a, b| a.0.cmp(b.0)); - if !vuln_entries.is_empty() { - println!(" Vulnerabilities ({}):", vuln_entries.len()); - for (id, vuln) in &vuln_entries { - let cve_list = if vuln.cves.is_empty() { - String::new() - } else { - format!(" ({})", vuln.cves.join(", ")) - }; - println!(" - {id}{cve_list}"); - println!(" Severity: {}", vuln.severity); - println!(" Summary: {}", vuln.summary); - } - } - - // Sort patched files by path for stable output. - let mut file_list: Vec<_> = patch.files.keys().collect(); - file_list.sort(); - if !file_list.is_empty() { - println!(" Files patched ({}):", file_list.len()); - for file_path in &file_list { - println!(" - {file_path}"); - } - } - - println!(); - } + println!("{}", format_listing(&entries, crate::ui::stdout_color())); } 0 @@ -726,4 +825,142 @@ mod tests { let b = manifest_envelope(&manifest).to_pretty_json(); assert_eq!(a, b); } + + fn record_with(description: &str, license: &str, severity: &str, summary: &str) -> PatchRecord { + let mut rec = sample_manifest().patches["pkg:npm/minimist@1.2.2"].clone(); + rec.description = description.to_string(); + rec.license = license.to_string(); + let vuln = rec.vulnerabilities.get_mut("GHSA-xyz-1234").unwrap(); + vuln.severity = severity.to_string(); + vuln.summary = summary.to_string(); + rec + } + + fn entry<'a>(purl: &'a str, record: &'a PatchRecord) -> ListEntry<'a> { + ListEntry { + purl, + record, + source: Source::Manifest, + } + } + + #[test] + fn format_entry_exact_layout() { + let rec = record_with("Some fix", "MIT", "high", "Prototype Pollution"); + assert_eq!( + format_entry(&entry("pkg:npm/minimist@1.2.2", &rec), false), + "Package: pkg:npm/minimist@1.2.2\n\ + \x20 UUID: 11111111-1111-4111-8111-111111111111\n\ + \x20 Tier: free\n\ + \x20 License: MIT\n\ + \x20 Exported: 2024-01-01T00:00:00Z\n\ + \x20 Description: Some fix\n\ + \x20 Vulnerabilities (1):\n\ + \x20 - GHSA-xyz-1234 (CVE-2024-12345)\n\ + \x20 Severity: HIGH\n\ + \x20 Summary: Prototype Pollution\n\ + \x20 Files patched (1):\n\ + \x20 - package/index.js" + ); + } + + #[test] + fn format_entry_indents_multiline_and_skips_blank_fields() { + let rec = record_with("Multi\r\nline \ndescription", "", "", ""); + let out = format_entry(&entry("pkg:npm/x@1", &rec), false); + assert!( + out.contains(" Description: Multi\n line\n description\n"), + "{out}" + ); + for gone in ["License:", "Severity:", "Summary:"] { + assert!(!out.contains(gone), "blank {gone} must be skipped: {out}"); + } + assert!( + !out.lines().any(|l| l.ends_with(' ')), + "no trailing spaces: {out:?}" + ); + } + + #[test] + fn format_entry_strips_terminal_control_sequences() { + let rec = record_with("evil\x1b[2J\x07 text\rmore", "MIT", "low", "s\x1b]0;t\x07"); + let out = format_entry(&entry("pkg:npm/x@1", &rec), false); + assert!( + !out.contains('\x1b') && !out.contains('\x07') && !out.contains('\r'), + "{out:?}" + ); + assert!(out.contains(" Description: evil[2J textmore"), "{out}"); + } + + #[test] + fn format_entry_colors_severity_only_when_asked() { + let rec = record_with("d", "MIT", "critical", "s"); + let plain = format_entry(&entry("pkg:npm/x@1", &rec), false); + assert!(!plain.contains('\x1b')); + let colored = format_entry(&entry("pkg:npm/x@1", &rec), true); + assert!( + colored.contains("Severity: \x1b[91mCRITICAL\x1b[0m"), + "{colored:?}" + ); + } + + #[test] + fn format_entry_multibyte_passes_through() { + let rec = record_with("修复 — é", "MIT", "medium", "漏洞"); + let out = format_entry(&entry("pkg:npm/日本@1", &rec), false); + assert!(out.starts_with("Package: pkg:npm/日本@1\n"), "{out}"); + assert!(out.contains(" Description: 修复 — é\n"), "{out}"); + assert!(out.contains(" Summary: 漏洞\n"), "{out}"); + } + + #[test] + fn format_listing_counts_and_separates_entries() { + assert_eq!(format_listing(&[], false), "No patches found in manifest."); + let manifest = sample_manifest(); + let one = combined_entries(Some(&manifest), None, None); + let out = format_listing(&one, false); + assert!(out.starts_with("Found 1 patch:\n\nPackage: "), "{out}"); + assert!(!out.ends_with('\n'), "no trailing blank line: {out:?}"); + + let multi = multi_entry_manifest(); + let many = combined_entries(Some(&multi), None, None); + let out = format_listing(&many, false); + assert!( + out.starts_with(&format!("Found {} patches:\n\n", many.len())), + "{out}" + ); + // Exactly one blank line between entries, none doubled. + assert_eq!(out.matches("\n\nPackage: ").count(), many.len()); + assert!(!out.contains("\n\n\n"), "{out:?}"); + } + + #[test] + fn manifest_error_message_names_the_file() { + let path = Path::new("proj/.socket/manifest.json"); + let io = std::io::Error::from(std::io::ErrorKind::PermissionDenied); + assert!(manifest_error_message(path, &io) + .starts_with("Could not read manifest at proj/.socket/manifest.json: "),); + let bad_json = std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Failed to parse manifest JSON: EOF while parsing an object at line 2 column 0", + ); + assert_eq!( + manifest_error_message(path, &bad_json), + "Invalid manifest at proj/.socket/manifest.json: not valid JSON: EOF while \ + parsing an object at line 2 column 0" + ); + let schema = std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid manifest: missing field `exportedAt`", + ); + assert_eq!( + manifest_error_message(path, &schema), + "Invalid manifest at proj/.socket/manifest.json: missing field `exportedAt`" + ); + let other = std::io::Error::new(std::io::ErrorKind::InvalidData, "odd"); + assert_eq!( + manifest_error_message(path, &other), + "Invalid manifest at proj/.socket/manifest.json: odd" + ); + } } diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 33e9d6bd..6b804c07 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -47,20 +47,95 @@ pub(crate) fn acquire_or_emit( dry_run: bool, timeout: Duration, ) -> Result { - match acquire(socket_dir, timeout) { + match acquire_with_status(socket_dir, timeout) { Ok(guard) => Ok(guard), Err(err) => { - let hint = match err { - LockError::Held => Hint::Wait, - LockError::Io { .. } => Hint::None, - }; + let hint = failure_hint(&err, &socket_dir.join("apply.lock"), timeout); let (code, message) = lock_failure(&err, timeout); - emit(command, json, dry_run, code, &message, hint); + emit(command, json, dry_run, code, &message, &hint); Err(1) } } } +/// [`acquire`] with the contended-wait status line: a `--lock-timeout 30` +/// run that finds the lock held says what it is waiting on instead of +/// sitting there silently for 30 s. The status line is terminal-only and +/// quiet under --json/--silent (the process-wide quiet switch). Every +/// lock site goes through this (or [`acquire_or_emit`], which wraps it). +pub(crate) fn acquire_with_status( + socket_dir: &Path, + timeout: Duration, +) -> Result { + match acquire(socket_dir, Duration::ZERO) { + Err(LockError::Held) if timeout > Duration::ZERO => { + let mut status = crate::ui::StatusLine::stderr(crate::ui::quiet(), false); + status.set(waiting_message(&socket_dir.join("apply.lock"), timeout)); + let result = acquire(socket_dir, timeout); + status.finish(); + result + } + other => other, + } +} + +/// The remediation line under a human lock error (empty when there is no +/// useful advice). +fn failure_hint(err: &LockError, lock_path: &Path, timeout: Duration) -> String { + match err { + LockError::Held => held_hint(lock_path, timeout), + LockError::Io { path, source } if source.kind() == std::io::ErrorKind::PermissionDenied => { + format!( + "Check that {} is writable.", + path.parent().unwrap_or(path).display() + ) + } + LockError::Io { .. } => String::new(), + } +} + +/// The human (stderr) report of a lock failure, for the sites that build +/// their own JSON envelope: the same `Error: ` line plus hint +/// that [`acquire_or_emit`] prints. +pub(crate) fn format_lock_error(socket_dir: &Path, err: &LockError, timeout: Duration) -> String { + let (_, message) = lock_failure(err, timeout); + format_human_error( + &message, + &failure_hint(err, &socket_dir.join("apply.lock"), timeout), + ) +} + +/// The status line shown while waiting out a contended lock. +fn waiting_message(lock_path: &Path, timeout: Duration) -> String { + format!( + "Waiting for another socket-patch process to release {} (up to {})...", + lock_path.display(), + fmt_duration(timeout) + ) +} + +/// The wait hint under a `lock_held` error from a step that builds its own +/// error line (the scan/get vendor step, hosted scan), which has no lock +/// path to name. +pub(crate) const HELD_RETRY_HINT: &str = + "Wait for it to finish, or retry with --lock-timeout to wait for the lock."; + +/// Remediation printed under a human-mode `lock_held` error. `Held` +/// always means a live process (leftover files never contend), so the +/// only honest advice is to wait; how depends on whether this run already +/// waited. +fn held_hint(lock_path: &Path, timeout: Duration) -> String { + let how = if timeout > Duration::ZERO { + "retry with a longer --lock-timeout" + } else { + "pass --lock-timeout to wait for it automatically" + }; + format!( + "Wait for it to finish, or {how}. (Lock file: {})", + lock_path.display() + ) +} + /// The one `LockError` → (`errorCode`, message) mapping every lock /// site renders: `Held` → `lock_held` with the wait budget spelled out /// by [`held_message`], `Io` → `lock_io` naming the path and the OS @@ -120,16 +195,7 @@ pub(crate) fn error_envelope( env } -/// Remediation hint appended under the human-mode error line. `Held` -/// always means a live process (leftover files never contend), so the -/// only honest advice is to wait — pointing at another socket-patch -/// command would just hit the same contention. -enum Hint { - None, - Wait, -} - -fn emit(command: Command, json: bool, dry_run: bool, code: &str, message: &str, hint: Hint) { +fn emit(command: Command, json: bool, dry_run: bool, code: &str, message: &str, hint: &str) { if json { println!( "{}", @@ -140,15 +206,23 @@ fn emit(command: Command, json: bool, dry_run: bool, code: &str, message: &str, // — CLI_CONTRACT.md): exit 1 with no message would be // undiagnosable. The remediation hint is part of the error report, // not informational chatter, so it prints with the error. - eprintln!("Error: {message}."); - match hint { - Hint::None => {} - Hint::Wait => { - eprintln!( - " Wait for it to finish, or retry with --lock-timeout to wait for the lock." - ); - } - } + eprint!("{}", format_human_error(message, hint)); + } +} + +/// The human-mode error report: `Error: ` (first letter +/// capitalized; the envelope keeps the message verbatim), then the +/// indented hint when there is one. +fn format_human_error(message: &str, hint: &str) -> String { + let mut chars = message.chars(); + let message: String = match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + }; + if hint.is_empty() { + format!("Error: {message}\n") + } else { + format!("Error: {message}\n {hint}\n") } } @@ -373,6 +447,90 @@ mod tests { ); } + /// The report the sites with their own envelope print (get, hosted + /// scan): the same capitalized line and wait hint as acquire_or_emit. + #[test] + fn format_lock_error_matches_acquire_or_emit() { + let dir = std::path::Path::new(".socket"); + let lock = dir.join("apply.lock"); + assert_eq!( + format_lock_error(dir, &LockError::Held, Duration::from_secs(2)), + format!( + "Error: Another socket-patch process is operating in this directory \ + (waited 2s)\n Wait for it to finish, or retry with a longer \ + --lock-timeout. (Lock file: {})\n", + lock.display() + ) + ); + assert_eq!( + format_lock_error(dir, &LockError::Held, Duration::ZERO), + format!( + "Error: Another socket-patch process is operating in this directory\n \ + Wait for it to finish, or pass --lock-timeout to wait for it \ + automatically. (Lock file: {})\n", + lock.display() + ) + ); + } + + #[test] + fn acquire_with_status_takes_a_free_lock_and_reports_a_held_one() { + let dir = tempfile::tempdir().unwrap(); + let guard = acquire_with_status(dir.path(), Duration::ZERO).unwrap(); + assert!(matches!( + acquire_with_status(dir.path(), Duration::from_millis(200)), + Err(LockError::Held) + )); + drop(guard); + } + + #[test] + fn human_error_is_capitalized_without_forced_period() { + assert_eq!( + format_human_error( + "another socket-patch process is operating in this directory", + "" + ), + "Error: Another socket-patch process is operating in this directory\n" + ); + assert_eq!( + format_human_error( + "failed to open lock file at ro/.socket/apply.lock: Permission denied (os error 13)", + "Check that ro/.socket is writable." + ), + "Error: Failed to open lock file at ro/.socket/apply.lock: Permission denied \ + (os error 13)\n Check that ro/.socket is writable.\n" + ); + assert_eq!(format_human_error("", ""), "Error: \n"); + } + + #[test] + fn held_hint_depends_on_whether_we_already_waited() { + let lock = Path::new("proj/.socket/apply.lock"); + assert_eq!( + held_hint(lock, Duration::ZERO), + "Wait for it to finish, or pass --lock-timeout to wait for it \ + automatically. (Lock file: proj/.socket/apply.lock)" + ); + assert_eq!( + held_hint(lock, Duration::from_secs(2)), + "Wait for it to finish, or retry with a longer --lock-timeout. \ + (Lock file: proj/.socket/apply.lock)" + ); + } + + #[test] + fn waiting_message_names_lock_and_budget() { + assert_eq!( + waiting_message(Path::new("p/.socket/apply.lock"), Duration::from_secs(5)), + "Waiting for another socket-patch process to release p/.socket/apply.lock (up to 5s)..." + ); + assert_eq!( + waiting_message(Path::new("p/apply.lock"), Duration::from_millis(250)), + "Waiting for another socket-patch process to release p/apply.lock (up to 250ms)..." + ); + } + /// Whole-second budgets read naturally in the contention message. #[test] fn held_message_reports_whole_seconds() { diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 33786a54..2fa73051 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -39,7 +39,7 @@ pub(crate) const VENDORED_MODE_LABEL: &str = "vendored"; /// from `load_redirect_state`'s contract — the warning is advisory /// (muted by `--silent`, "errors only"), because every path that would /// WRITE or ATTEST from the ledger hard-errors on the same corruption -/// instead. Shared by `list` and both of scan's read-only consults. +/// instead. Shared by both of scan's read-only consults. pub(crate) async fn load_redirect_state_lenient( cwd: &Path, silent: bool, diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 8d90e924..2e6559f8 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1,6 +1,6 @@ use clap::Args; use socket_patch_core::api::client::get_api_client_with_overrides; -use socket_patch_core::manifest::cleanup_blobs::format_cleanup_result; +use socket_patch_core::manifest::cleanup_blobs::format_bytes; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::patch::redirect::{ @@ -16,14 +16,13 @@ use std::time::Duration; use super::get::short_uuid; use super::rollback::{ - all_files_already_original, pin_before_hash_blobs, revert_vendor_entry, rollback_patches_inner, - run_hosted_leg, sweep_failure, sweep_unused_artifacts, HostedLegOutcome, InnerSelection, - VendorRevertStep, + pin_before_hash_blobs, revert_vendor_entry, rollback_patches_inner, run_hosted_leg, + sweep_failure, sweep_unused_artifacts, HostedLegOutcome, InnerSelection, VendorRevertStep, }; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; -use crate::output::confirm; +use crate::ui::plural; /// Vendor-ledger entries matching a remove identifier (by ledger key, /// base purl or uuid — `VendorEntry::matches_identifier`), sorted by key @@ -94,7 +93,7 @@ async fn emit_not_found( env.error = Some(EnvelopeError::new("not_found", msg)); println!("{}", env.to_pretty_json()); } else { - eprintln!("{msg}"); + eprintln!("Error: {msg}"); } } @@ -108,10 +107,159 @@ fn emit_error_envelope(json: bool, dry_run: bool, code: &str, message: String) { env.mark_error(EnvelopeError::new(code, message)); println!("{}", env.to_pretty_json()); } else { - eprintln!("Error: {message}"); + eprintln!("Error: {}", super::rollback::capitalize_first(&message)); } } +/// The listing header above the patches a `remove` targets. +fn format_remove_header( + identifier: &str, + count: usize, + variants: bool, + dry_run: bool, + preserve_state: bool, + skip_rollback: bool, +) -> String { + let will = if dry_run { "would be" } else { "will be" }; + let action = if preserve_state { + format!("{will} rolled back (patch records preserved)") + } else if skip_rollback { + format!("{will} removed from the manifest (files are not rolled back)") + } else { + format!("{will} removed") + }; + if variants { + format!("{identifier} matches {count} release variants — all {action}:") + } else { + format!( + "The following {} {action}:", + if count == 1 { "patch" } else { "patches" } + ) + } +} + +/// The confirmation prompt, naming every leg the removal touches. +fn remove_prompt( + count: usize, + preserve_state: bool, + skip_rollback: bool, + vendored: usize, + hosted: usize, +) -> String { + let patches = plural(count, "patch", "patches"); + let its = if count == 1 { "its" } else { "their" }; + let mut clauses: Vec = if preserve_state { + vec![format!("roll back files for {patches}")] + } else if skip_rollback { + vec![format!( + "remove {patches} from the manifest without rolling back {its} files" + )] + } else { + vec![ + format!("remove {patches}"), + format!("roll back {its} files"), + ] + }; + if vendored > 0 { + let artifacts = plural(vendored, "vendored artifact", "vendored artifacts"); + clauses.push(if preserve_state { + format!("unwire {artifacts}") + } else { + format!("revert {artifacts}") + }); + } + if hosted > 0 { + clauses.push(format!( + "unwind {}", + plural(hosted, "hosted redirect", "hosted redirects") + )); + } + let question = super::rollback::as_question(&super::rollback::join_clauses(&clauses)); + if preserve_state { + format!("{question} (patch records will be preserved)") + } else { + question + } +} + +/// The nested rollback's result lines (not-installed entries are reported +/// separately, by [`format_not_installed_warning`]). +fn format_rollback_counts(dry_run: bool, rolled_back: usize, already: usize) -> Vec { + let mut lines = Vec::new(); + if rolled_back > 0 { + let packages = plural(rolled_back, "package", "packages"); + lines.push(if dry_run { + format!("Would roll back {packages}") + } else { + format!("Rolled back {packages}") + }); + } + if already > 0 { + lines.push(format!( + "{} already in original state", + plural(already, "package", "packages") + )); + } + lines +} + +/// The crawler-miss warning for removed entries whose rollback was +/// skipped. Kept revert data is claimed only for entries whose beforeHash +/// blobs actually exist in `.socket/blobs` (`with_blobs`). +fn format_not_installed_warning(with_blobs: &[&str], without_blobs: &[&str]) -> Vec { + let mut lines = Vec::new(); + let mut group = |purls: &[&str], tail: &dyn Fn(bool) -> &'static str| { + if purls.is_empty() { + return; + } + let one = purls.len() == 1; + let tail = tail(one); + lines.push(String::new()); + lines.push(format!( + "Warning: {} no matching installed package, so {} rollback was skipped (a \ + crawler miss would look the same); {tail}:", + plural(purls.len(), "removed patch had", "removed patches had"), + if one { "its" } else { "their" }, + )); + lines.extend(purls.iter().map(|p| format!(" - {p}"))); + }; + group(with_blobs, &|_| { + "the revert data (beforeHash blobs) was kept in .socket/blobs" + }); + group(without_blobs, &|one| { + if one { + "no local revert data exists, so reinstall the package to restore its original files" + } else { + "no local revert data exists, so reinstall the packages to restore their original \ + files" + } + }); + lines +} + +/// The blob-sweep result line (plus the swept list on a dry run). +fn format_blob_sweep( + removed: usize, + bytes: u64, + removed_blobs: &[String], + dry_run: bool, +) -> String { + let mut out = format!( + "{} {} ({} {})", + if dry_run { "Would remove" } else { "Removed" }, + plural(removed, "unused blob", "unused blobs"), + format_bytes(bytes), + if dry_run { "would be freed" } else { "freed" } + ); + if dry_run && !removed_blobs.is_empty() { + out.push_str("\nUnused blobs:"); + for blob in removed_blobs { + out.push_str(&format!("\n - {blob}")); + } + } + out +} + #[derive(Args)] pub struct RemoveArgs { /// Package PURL or patch UUID. @@ -120,13 +268,12 @@ pub struct RemoveArgs { #[command(flatten)] pub common: GlobalArgs, - /// Skip rolling back files before removing (only update manifest). - /// - /// `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_SKIP_ROLLBACK=1` (or - /// an exported-but-empty `SOCKET_SKIP_ROLLBACK=`) aborted every - /// `remove` invocation. + // `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_SKIP_ROLLBACK=1` (or + // an exported-but-empty `SOCKET_SKIP_ROLLBACK=`) aborted every + // `remove` invocation. + /// Skip rolling back files before removing (only update the manifest). #[arg( long = "skip-rollback", env = "SOCKET_SKIP_ROLLBACK", @@ -151,6 +298,10 @@ pub struct RemoveArgs { pub preserve_state: bool, } +/// The last line of a human `remove --dry-run` (the wording `repair +/// --dry-run` ends with too). +const DRY_RUN_FOOTER: &str = "Dry run: no changes made."; + pub async fn run(args: RemoveArgs) -> i32 { apply_env_toggles(&args.common); @@ -160,7 +311,7 @@ pub async fn run(args: RemoveArgs) -> i32 { // they select the do-nothing quadrant. if args.preserve_state && args.skip_rollback { eprintln!( - "error: --preserve-state cannot be used with --skip-rollback: the \ + "Error: --preserve-state cannot be used with --skip-rollback: the \ combination would be a no-op (nothing would change)" ); return 2; @@ -254,12 +405,14 @@ pub async fn run(args: RemoveArgs) -> i32 { } }; - // Find matching patches to show what will be removed. - let matching: Vec<_> = manifest + // Find matching patches to show what will be removed (sorted: the + // manifest is a HashMap, and the listing must be deterministic). + let mut matching: Vec<_> = manifest .patches .iter() .filter(|(purl, patch)| patch_matches(purl, &patch.uuid, &args.identifier)) .collect(); + matching.sort_by(|a, b| a.0.cmp(b.0)); // The vendor ledger, loaded ONCE under the lock: it scopes the nested // rollback (vendor-owned purls are not restored in place) and drives @@ -322,24 +475,26 @@ pub async fn run(args: RemoveArgs) -> i32 { // blast radius explicit so the user understands why a single // `remove pkg:pypi/foo@1.0` is removing several variants. if loud { - if args.identifier.starts_with("pkg:") + let variants = args.identifier.starts_with("pkg:") && !args.identifier.contains('?') - && matching.len() > 1 - { - eprintln!( - "{} matches {} release variant(s) — all will be removed:", - args.identifier, - matching.len() - ); - } else { - eprintln!("The following patch(es) will be removed:"); - } + && matching.len() > 1; + eprintln!( + "{}", + format_remove_header( + &args.identifier, + matching.len(), + variants, + args.common.dry_run, + args.preserve_state, + args.skip_rollback, + ) + ); for (purl, patch) in &matching { eprintln!( - " - {} (UUID: {}, {} file(s))", + " - {} (UUID: {}, {})", purl, short_uuid(&patch.uuid), - patch.files.len() + plural(patch.files.len(), "file", "files") ); } eprintln!(); @@ -347,20 +502,38 @@ pub async fn run(args: RemoveArgs) -> i32 { // `--dry-run` previews without mutating, so there is nothing to // confirm — skip the prompt (matching the global contract row: - // "Preview, no mutations"). - let prompt = if args.preserve_state { - format!( - "Rollback files for {} patch(es)? (patch records will be preserved)", - matching.len() - ) - } else { - format!("Remove {} patch(es) and rollback files?", matching.len()) - }; - if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { - if loud { - println!("Removal cancelled."); + // "Preview, no mutations"). The prompt names every leg the removal + // will touch: the redirect ledger is probed read-only here (the legs + // below re-load it and decide for real). + if !args.common.dry_run { + let (vendored, hosted) = if args.skip_rollback { + (0, 0) + } else { + let vendored = vendor_state_result + .as_ref() + .map(|st| vendor_entries_matching(st, &args.identifier).len()) + .unwrap_or(0); + let hosted = load_redirect_state(cwd) + .await + .ok() + .flatten() + .map(|st| hosted_records_matching(&st, &args.identifier).len()) + .unwrap_or(0); + (vendored, hosted) + }; + let prompt = remove_prompt( + matching.len(), + args.preserve_state, + args.skip_rollback, + vendored, + hosted, + ); + if !crate::ui::confirm(&prompt, true, &args.common) { + if loud { + println!("Removal cancelled."); + } + return 0; } - return 0; } // ── nested in-place rollback ──────────────────────────────────────── @@ -382,10 +555,14 @@ pub async fn run(args: RemoveArgs) -> i32 { // warning event rides the envelope. Empty under `--skip-rollback` // (no rollback ran, so nothing is known — semantics unchanged). let mut rollback_not_installed: Vec = Vec::new(); + // Whether something was printed after the header listing, so the + // manifest result below gets a separating blank line (and only then). + let mut printed_progress = false; if !args.skip_rollback { - if loud { - println!("Rolling back patch before removal..."); - } + // No outer status line: the engine prints its own progress (the + // blob-download status) and diagnostics, which an outer line + // would collide with on a TTY. + // // The delegation runs muted under --json/--silent (the envelope, // or the silence, is ours) and unscoped by --ecosystems (the // identifier IS the scope). @@ -413,6 +590,19 @@ pub async fn run(args: RemoveArgs) -> i32 { org_slug.as_deref(), ) .await; + // The nested rollback reports per-package failures + // inline only under --silent; say why here otherwise. + if loud { + for r in outcome.results.iter().filter(|r| !r.success) { + eprintln!( + "{}", + super::rollback::format_rollback_failure( + &r.package_key, + r.error.as_deref().unwrap_or("unknown error") + ) + ); + } + } emit_error_envelope( args.common.json, args.common.dry_run, @@ -422,46 +612,56 @@ pub async fn run(args: RemoveArgs) -> i32 { return 1; } + // Counted per package (two installed copies of one purl + // are one package), the same way rollback and apply count + // (`tally_rollback_results` reuses rollback's canonical + // `all_files_already_original` predicate, whose non-empty + // guard keeps a zero-file or not-installed result from + // counting as "already in original state"). + // `rollback_count` stays per copy: it feeds the JSON + // envelope's `rolledBack`, which is unchanged. + let tally = super::rollback::tally_rollback_results(&outcome.results); rollback_count = outcome .results .iter() .filter(|r| r.success && !r.files_rolled_back.is_empty()) .count(); - // Reuse rollback's canonical predicate rather than - // re-deriving it: the `!files_verified.is_empty()` guard - // inside `all_files_already_original` is essential — - // `Iterator::all` over an empty slice is vacuously `true`, - // so a zero-file (or not-installed) result would otherwise - // be miscounted as "already in original state". - let already_original = outcome - .results - .iter() - .filter(|r| r.success && all_files_already_original(r)) - .count(); if loud { - if rollback_count > 0 { - println!("Rolled back {rollback_count} package(s)"); - } - if already_original > 0 { - println!("{already_original} package(s) already in original state"); - } // Vendor-owned targets say nothing here: the vendored // leg below reports each key's own disposition. - if !rollback_not_installed.is_empty() { - println!("No packages found to rollback (not installed)"); + // Not-installed entries are reported by the crawler- + // miss warning after the manifest result below. A dry + // run restores nothing (`files_rolled_back` stays + // empty), so it counts what WOULD be rolled back. + for line in format_rollback_counts( + args.common.dry_run, + if args.common.dry_run { + tally.can_roll_back + } else { + tally.rolled_back + }, + tally.already, + ) { + println!("{line}"); + printed_progress = true; } - println!(); } } Err(e) => { track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await; - emit_error_envelope( - args.common.json, - args.common.dry_run, - "rollback_failed", - format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."), - ); + let remedy = "Use --skip-rollback to remove from manifest without restoring files."; + if args.common.json { + // The pinned envelope message keeps its historical prefix. + emit_error_envelope( + true, + args.common.dry_run, + "rollback_failed", + format!("Error during rollback: {e}. {remedy}"), + ); + } else { + eprintln!("Error: Rollback failed: {e}. {remedy}"); + } return 1; } } @@ -526,6 +726,7 @@ pub async fn run(args: RemoveArgs) -> i32 { Ok(leg) => leg, Err(code) => return code, }; + printed_progress |= loud && vendor_leg.printed; } } @@ -576,6 +777,8 @@ pub async fn run(args: RemoveArgs) -> i32 { their ledger records were dropped with the unwound wiring." ); } + // `run_hosted_leg` printed one line per unwound purl. + printed_progress |= loud && !leg.reverted.is_empty(); let hosted_action = if args.common.dry_run { PatchAction::Verified } else { @@ -660,23 +863,29 @@ pub async fn run(args: RemoveArgs) -> i32 { } if loud { + if printed_progress { + println!(); + } if args.preserve_state { println!( - "Manifest entries and vendored artifacts preserved \ - (--preserve-state); re-apply with `socket-patch apply` or \ - `socket-patch vendor`." + "{}", + super::rollback::format_preserved_note(matching.len(), vendor_leg.preserved) ); } else if args.common.dry_run { - println!("Would remove {} patch(es) from manifest:", removed.len()); + println!( + "Would remove {} from manifest:", + plural(removed.len(), "patch", "patches") + ); } else { - println!("Removed {} patch(es) from manifest:", removed.len()); + println!( + "Removed {} from manifest:", + plural(removed.len(), "patch", "patches") + ); } for purl in &removed { println!(" - {purl}"); } - if args.common.dry_run { - println!("\nDry run — nothing was changed."); - } else if !args.preserve_state { + if !args.common.dry_run && !args.preserve_state { println!("\nManifest updated at {}", manifest_path.display()); } } @@ -695,14 +904,24 @@ pub async fn run(args: RemoveArgs) -> i32 { .filter(|p| removed.iter().any(|r| r == p)) .collect(); if loud && !retained_not_installed.is_empty() { - eprintln!( - "\nWarning: {} removed patch(es) had no matching installed package, so \ - their rollback was skipped (a crawler miss would look the same); their \ - revert data (beforeHash blobs) was kept in .socket/blobs:", - retained_not_installed.len() - ); + // Claim kept revert data only where it exists on disk. + let blobs_dir = socket_dir.join("blobs"); + let mut with_blobs: Vec<&str> = Vec::new(); + let mut without_blobs: Vec<&str> = Vec::new(); for purl in &retained_not_installed { - eprintln!(" - {purl}"); + let has_local = manifest.patches.get(*purl).is_some_and(|record| { + record.files.values().any(|info| { + !info.before_hash.is_empty() && blobs_dir.join(&info.before_hash).is_file() + }) + }); + if has_local { + with_blobs.push(purl); + } else { + without_blobs.push(purl); + } + } + for line in format_not_installed_warning(&with_blobs, &without_blobs) { + eprintln!("{line}"); } } @@ -736,7 +955,15 @@ pub async fn run(args: RemoveArgs) -> i32 { if let Ok(r) = sweep.blobs { blobs_removed = r.blobs_removed; if loud && r.blobs_removed > 0 { - println!("\n{}", format_cleanup_result(&r, args.common.dry_run)); + println!( + "\n{}", + format_blob_sweep( + r.blobs_removed, + r.bytes_freed, + &r.removed_blobs, + args.common.dry_run + ) + ); } } // Diff/package archives use the same manifest-uuid keep rule @@ -753,6 +980,12 @@ pub async fn run(args: RemoveArgs) -> i32 { } } + // The dry-run footer closes the whole preview, the blob-cleanup + // preview above included. + if loud && args.common.dry_run { + println!("\n{DRY_RUN_FOOTER}"); + } + if args.common.json { let mut env = Envelope::new(Command::Remove); env.dry_run = args.common.dry_run; @@ -890,6 +1123,11 @@ struct RemoveVendorLeg { kept: Vec, /// Entries actually reverted and dropped from the ledger (wet runs). reverted_count: usize, + /// Entries unwired with their artifact and ledger entry kept + /// (`--preserve-state`, wet runs). + preserved: usize, + /// A human progress line reached stdout. + printed: bool, } /// The vendored-revert loop shared by the manifest-backed and ledger-only @@ -966,7 +1204,9 @@ async fn revert_vendored_matches( ) }; if loud { - eprintln!("Kept vendored state for {key}: lockfile wiring drifted{note}"); + eprintln!( + "Warning: Kept vendored state for {key}: lockfile wiring drifted{note}" + ); } leg.kept.push(key.clone()); leg.skipped.push( @@ -981,6 +1221,7 @@ async fn revert_vendored_matches( } else { println!("Would revert vendoring for {key}"); } + leg.printed = true; } // Dry-run flips the would-be Removed to a Verified preview, // same convention as apply/vendor/repair. @@ -992,8 +1233,10 @@ async fn revert_vendored_matches( ); } VendorRevertStep::Preserved => { + leg.preserved += 1; if loud { println!("Unwired vendoring for {key} (artifact preserved)"); + leg.printed = true; } leg.skipped.push( PatchEvent::new(PatchAction::Skipped, key.clone()).with_reason( @@ -1006,6 +1249,7 @@ async fn revert_vendored_matches( VendorRevertStep::Reverted => { if loud { println!("Reverted vendoring for {key}"); + leg.printed = true; } leg.reverted_count += 1; leg.reverted.push( @@ -1123,7 +1367,7 @@ async fn remove_hosted_only( args.common.dry_run, "hosted_state_retained", format!( - "{} matches only hosted redirect record(s); removing one means unwinding \ + "{} matches only hosted redirect records; removing one means unwinding \ its lockfile redirect, which --skip-rollback prevents", args.identifier ), @@ -1132,7 +1376,19 @@ async fn remove_hosted_only( } if loud { - eprintln!("The following hosted redirect(s) will be unwound and removed:"); + eprintln!( + "The following {} {} unwound and removed:", + if hosted_matches.len() == 1 { + "hosted redirect" + } else { + "hosted redirects" + }, + if args.common.dry_run { + "would be" + } else { + "will be" + } + ); for purl in &hosted_matches { eprintln!(" - {purl}"); } @@ -1140,10 +1396,15 @@ async fn remove_hosted_only( } // `--dry-run` previews without mutating — nothing to confirm. let prompt = format!( - "Remove {} hosted redirect(s) and unwind their lockfile wiring?", - hosted_matches.len() + "Remove {} and unwind {} lockfile wiring?", + plural(hosted_matches.len(), "hosted redirect", "hosted redirects"), + if hosted_matches.len() == 1 { + "its" + } else { + "their" + } ); - if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if loud { println!("Removal cancelled."); } @@ -1221,7 +1482,7 @@ async fn remove_ledger_only( args.common.dry_run, "vendor_state_retained", format!( - "{} matches only vendored patch(es) with no manifest record; removing one \ + "{} matches only vendored patches with no manifest record; removing one \ means reverting its vendoring, which --skip-rollback prevents", args.identifier ), @@ -1230,13 +1491,19 @@ async fn remove_ledger_only( } if loud { + let subject = match (matches.len() == 1, args.common.dry_run) { + (true, false) => "patch will be", + (true, true) => "patch would be", + (false, false) => "patches will be", + (false, true) => "patches would be", + }; if args.preserve_state { eprintln!( - "The following vendored patch(es) will be unwired (artifacts and ledger \ - entries preserved):" + "The following vendored {subject} unwired (artifacts and ledger entries \ + preserved):" ); } else { - eprintln!("The following vendored patch(es) will be reverted and removed:"); + eprintln!("The following vendored {subject} reverted and removed:"); } for (key, entry) in &matches { eprintln!(" - {key} (UUID: {})", short_uuid(&entry.uuid)); @@ -1244,19 +1511,25 @@ async fn remove_ledger_only( eprintln!(); } // `--dry-run` previews without mutating — nothing to confirm. + let one = matches.len() == 1; let prompt = if args.preserve_state { format!( - "Unwire vendoring for {} vendored patch(es)? (artifacts and ledger entries will \ - be preserved)", - matches.len() + "Unwire vendoring for {}? ({} will be preserved)", + plural(matches.len(), "vendored patch", "vendored patches"), + if one { + "its artifact and ledger entry" + } else { + "their artifacts and ledger entries" + } ) } else { format!( - "Remove {} vendored patch(es) and revert their vendoring?", - matches.len() + "Remove {} and revert {} vendoring?", + plural(matches.len(), "vendored patch", "vendored patches"), + if one { "its" } else { "their" } ) }; - if !args.common.dry_run && !confirm(&prompt, true, args.common.yes, args.common.json) { + if !args.common.dry_run && !crate::ui::confirm(&prompt, true, &args.common) { if loud { println!("Removal cancelled."); } @@ -1329,6 +1602,11 @@ mod tests { use socket_patch_core::manifest::schema::PatchRecord; use std::collections::HashMap; + #[test] + fn dry_run_footer_matches_repair() { + assert_eq!(DRY_RUN_FOOTER, "Dry run: no changes made."); + } + fn make_record(uuid: &str) -> PatchRecord { PatchRecord { uuid: uuid.to_string(), @@ -1494,4 +1772,113 @@ mod tests { assert!(manifest.patches.contains_key("pkg:npm/foo@1.0")); assert_eq!(manifest.patches.len(), 2); } + + // ── human output formatters ────────────────────────────────────────── + + #[test] + fn remove_header_by_mode_and_count() { + let h = |n, dry, pres, skip| format_remove_header("pkg:npm/a@1", n, false, dry, pres, skip); + assert_eq!( + h(1, false, false, false), + "The following patch will be removed:" + ); + assert_eq!( + h(2, true, false, false), + "The following patches would be removed:" + ); + assert_eq!( + h(1, false, true, false), + "The following patch will be rolled back (patch records preserved):" + ); + assert_eq!( + h(1, false, false, true), + "The following patch will be removed from the manifest (files are not rolled back):" + ); + assert_eq!( + format_remove_header("pkg:pypi/six@1.17.0", 3, true, false, false, false), + "pkg:pypi/six@1.17.0 matches 3 release variants — all will be removed:" + ); + } + + #[test] + fn remove_prompt_names_every_leg() { + assert_eq!( + remove_prompt(1, false, false, 0, 0), + "Remove 1 patch and roll back its files?" + ); + assert_eq!( + remove_prompt(2, false, false, 0, 0), + "Remove 2 patches and roll back their files?" + ); + assert_eq!( + remove_prompt(1, false, false, 0, 1), + "Remove 1 patch, roll back its files, and unwind 1 hosted redirect?" + ); + assert_eq!( + remove_prompt(1, false, false, 2, 1), + "Remove 1 patch, roll back its files, revert 2 vendored artifacts, and unwind 1 \ + hosted redirect?" + ); + assert_eq!( + remove_prompt(1, true, false, 1, 0), + "Roll back files for 1 patch and unwire 1 vendored artifact? (patch records will \ + be preserved)" + ); + assert_eq!( + remove_prompt(3, false, true, 0, 0), + "Remove 3 patches from the manifest without rolling back their files?" + ); + } + + #[test] + fn rollback_counts_lines() { + assert_eq!( + format_rollback_counts(false, 1, 0), + vec!["Rolled back 1 package"] + ); + assert_eq!( + format_rollback_counts(true, 2, 1), + vec![ + "Would roll back 2 packages", + "1 package already in original state" + ] + ); + assert!(format_rollback_counts(false, 0, 0).is_empty()); + } + + #[test] + fn not_installed_warning_claims_kept_blobs_only_when_present() { + let with = format_not_installed_warning(&["pkg:npm/a@1"], &[]); + assert_eq!( + with, + vec![ + "", + "Warning: 1 removed patch had no matching installed package, so its rollback \ + was skipped (a crawler miss would look the same); the revert data \ + (beforeHash blobs) was kept in .socket/blobs:", + " - pkg:npm/a@1", + ] + ); + let without = format_not_installed_warning(&[], &["pkg:npm/b@1", "pkg:npm/c@1"]); + assert_eq!(without.len(), 4); + assert!(without[1].starts_with("Warning: 2 removed patches had no matching installed")); + assert!(without[1].contains("their rollback was skipped")); + assert!(without[1].contains("reinstall the packages to restore their original files")); + let one = format_not_installed_warning(&[], &["pkg:npm/b@1"]); + assert!(one[1].contains("reinstall the package to restore its original files")); + assert!(!without[1].contains("was kept in .socket/blobs")); + assert!(format_not_installed_warning(&[], &[]).is_empty()); + } + + #[test] + fn blob_sweep_line() { + assert_eq!( + format_blob_sweep(1, 2048, &[], false), + "Removed 1 unused blob (2.00 KB freed)" + ); + assert_eq!( + format_blob_sweep(2, 10, &["h1".to_string(), "h2".to_string()], true), + "Would remove 2 unused blobs (10 B would be freed)\nUnused blobs:\n - h1\n - h2" + ); + } } diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index e1693b40..58d0699b 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -1,10 +1,12 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::{ - fetch_missing_sources, format_fetch_result, get_missing_archives, get_missing_blobs, - DownloadMode, FetchMissingBlobsResult, + fetch_missing_sources, format_fetch_failures, format_fetch_successes, get_missing_archives, + get_missing_blobs, ArtifactNoun, DownloadMode, BLOB, DIFF_ARCHIVE, PACKAGE_ARCHIVE, }; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; -use socket_patch_core::manifest::cleanup_blobs::format_cleanup_result; +use socket_patch_core::manifest::cleanup_blobs::{ + format_all_in_use, format_cleanup_result_for, CleanupResult, +}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::telemetry::{track_patch_repair_failed, track_patch_repaired}; @@ -23,13 +25,13 @@ pub struct RepairArgs { /// Only download missing artifacts; skip the cleanup phase. /// Incompatible with `--offline`. - /// - /// `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_DOWNLOAD_ONLY=1` (or - /// an exported-but-empty `SOCKET_DOWNLOAD_ONLY=`) aborted every `repair` - /// invocation. This flag is also outside `GLOBAL_ARG_ENV_VARS`, so - /// `main`'s empty-var scrub never rescues it. + // + // `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_DOWNLOAD_ONLY=1` (or + // an exported-but-empty `SOCKET_DOWNLOAD_ONLY=`) aborted every `repair` + // invocation. This flag is also outside `GLOBAL_ARG_ENV_VARS`, so + // `main`'s empty-var scrub never rescues it. #[arg( long = "download-only", env = "SOCKET_DOWNLOAD_ONLY", @@ -55,17 +57,6 @@ pub async fn run(args: RepairArgs) -> i32 { return 2; } - // Resolve telemetry credentials through the API client the way - // apply/rollback/remove do: passing the raw `--api-token`/`--org` flag - // values meant env-provided SOCKET_API_TOKEN/SOCKET_ORG_SLUG (the - // standard configuration) never reached telemetry, which then fell - // back to the anonymous public-proxy endpoint instead of the - // org-scoped one. - let (telemetry_client, _) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; - let api_token = telemetry_client.api_token().cloned(); - let org_slug = telemetry_client.org_slug().cloned(); - let manifest_path = args.common.resolved_manifest_path(); // The lockfile scan (`scan_vendor_references` opens every wiring file) @@ -99,9 +90,7 @@ pub async fn run(args: RepairArgs) -> i32 { } if !has_vendor_traces { if tokio::fs::metadata(&redirect_state).await.is_ok() { - let msg = "hosted redirects need no local repair; re-run \ - `scan --mode hosted` to refresh the lockfile redirects \ - (it also re-checks for stale pre-redirect installs)"; + let msg = HOSTED_ONLY_REASON; if args.common.json { let mut env = Envelope::new(Command::Repair); env.dry_run = args.common.dry_run; @@ -111,7 +100,9 @@ pub async fn run(args: RepairArgs) -> i32 { ); println!("{}", env.to_pretty_json()); } else if !args.common.silent { - println!("{msg}"); + // A sentence on the terminal; the JSON reason keeps + // its historical, period-less text. + println!("{msg}."); } return 0; } @@ -125,7 +116,7 @@ pub async fn run(args: RepairArgs) -> i32 { ); println!("{}", env.to_pretty_json()); } else { - eprintln!("{msg}"); + eprintln!("Error: {msg}"); } return 1; } @@ -158,14 +149,35 @@ pub async fn run(args: RepairArgs) -> i32 { None => crate::commands::repair_vendor::scan_vendor_references(&args.common.cwd).await, }; - match repair_inner( - &args, - &manifest_path, - Some(&telemetry_client), - vendor_references, - ) - .await - { + // The API client is built lazily: `repair_inner` constructs it only on + // the download branch, so a run that downloads nothing (an invalid or + // empty manifest, every artifact present, a dry run) never prints the + // client's public-proxy advisory ahead of its own output. + let mut client: Option = None; + let result = repair_inner(&args, &manifest_path, &mut client, vendor_references).await; + + // Resolve telemetry credentials through the API client the way + // apply/rollback/remove do: passing the raw `--api-token`/`--org` flag + // values meant env-provided SOCKET_API_TOKEN/SOCKET_ORG_SLUG (the + // standard configuration) never reached telemetry, which then fell + // back to the anonymous public-proxy endpoint instead of the + // org-scoped one. Reuse the download phase's client when there was + // one. Otherwise build one only when a token is available (so the + // org auto-resolve still attributes the event, and no proxy advisory + // is printed): without a token telemetry goes to the public endpoint + // whatever the org slug, so `(None, None)` is equivalent. + if client.is_none() && api_token_available(&args.common) { + client = Some( + get_api_client_with_overrides(args.common.api_client_overrides()) + .await + .0, + ); + } + let (api_token, org_slug) = client.as_ref().map_or((None, None), |c| { + (c.api_token().cloned(), c.org_slug().cloned()) + }); + + match result { Ok((env, counts)) => { // A repair where some artifacts failed to download is marked a // partial failure inside `repair_inner` (a `Failed` event plus @@ -219,14 +231,137 @@ struct RepairCounts { bytes_freed: u64, } +/// How many missing ids the offline warning lists. +const OFFLINE_LIST_CAP: usize = 5; +/// How many missing ids the dry-run preview lists. +const DRY_RUN_LIST_CAP: usize = 10; + +/// ` - ` lines for `ids`, sorted (they come from a `HashSet`), at +/// most `cap` of them, then ` ... and N more`. +fn format_id_list(ids: &[String], noun: ArtifactNoun, cap: usize) -> Vec { + let mut sorted: Vec<&String> = ids.iter().collect(); + sorted.sort(); + let mut lines: Vec = sorted + .iter() + .take(cap) + .map(|id| format!(" - {}", noun.display_id(id))) + .collect(); + if sorted.len() > cap { + lines.push(format!(" ... and {} more", sorted.len() - cap)); + } + lines +} + +/// `Found 2 missing diff archives` / `Found 1 missing blob`. +fn format_found_missing(n: usize, noun: ArtifactNoun) -> String { + format!("Found {}", noun.count(n).replacen(' ', " missing ", 1)) +} + +/// Why a hosted-only project has nothing to repair (the JSON skip +/// reason; the human line adds the period). +const HOSTED_ONLY_REASON: &str = "Hosted redirects need no local repair; re-run \ + `scan --mode hosted` to refresh the lockfile redirects (it also re-checks for stale \ + pre-redirect installs)"; + +/// Step 1's line when no patch artifact is missing: why there is nothing +/// to download (no manifest, as in a vendored-only project, or an empty +/// one), or that everything is on disk. +fn format_nothing_missing( + manifest: Option<&socket_patch_core::manifest::schema::PatchManifest>, + noun: ArtifactNoun, +) -> String { + match manifest { + None => "No manifest; no patch artifacts to download.".to_string(), + Some(m) if m.patches.is_empty() => { + "No patches in manifest; nothing to download.".to_string() + } + Some(_) => format!("All {} are present locally.", noun.many), + } +} + +/// The `--offline` warning (stderr) for artifacts that cannot be fetched. +fn format_offline_warning(ids: &[String], noun: ArtifactNoun) -> String { + let verb = if ids.len() == 1 { "is" } else { "are" }; + let mut lines = vec![format!( + "Warning: {} {verb} missing (offline mode - not downloading):", + noun.count(ids.len()) + )]; + lines.extend(format_id_list(ids, noun, OFFLINE_LIST_CAP)); + lines.join("\n") +} + +/// The cleanup phase's summary: one result block per kind that had +/// something to remove; otherwise a single line saying what was checked. +fn format_cleanup_summary(results: &[(ArtifactNoun, CleanupResult)], dry_run: bool) -> String { + let removed: Vec = results + .iter() + .filter(|(_, r)| r.blobs_removed > 0) + .map(|(noun, r)| format_cleanup_result_for(r, dry_run, *noun)) + .collect(); + if !removed.is_empty() { + return removed.join("\n"); + } + let checked: Vec = results + .iter() + .filter(|(_, r)| r.blobs_checked > 0) + .map(|(noun, r)| noun.count(r.blobs_checked)) + .collect(); + if checked.is_empty() { + return "Nothing to clean up.".to_string(); + } + let total = results.iter().map(|(_, r)| r.blobs_checked).sum(); + format_all_in_use(&checked, total) +} + +/// The closing line of a human repair run. `other_failure` is a failure +/// recorded elsewhere in the run (a vendored artifact that could not be +/// rebuilt): the run exits 1, so it must not close on "Repair complete.". +fn format_final_line( + download_failed: usize, + other_failure: bool, + noun: ArtifactNoun, + dry_run: bool, +) -> String { + if download_failed > 0 { + let verb = if download_failed == 1 { "was" } else { "were" }; + format!( + "Repair finished with errors: {} {verb} not downloaded.", + noun.count(download_failed) + ) + } else if other_failure { + "Repair finished with errors.".to_string() + } else if dry_run { + "Dry run: no changes made.".to_string() + } else { + "Repair complete.".to_string() + } +} + +/// Whether an API token will be found, mirroring the client's chain: the +/// `--api-token` flag (clap also maps SOCKET_API_TOKEN into it), then — +/// unless `SOCKET_NO_API_TOKEN` vetoes ambient tokens — the env var and the +/// socket-cli config. Checked without building a client, which would print +/// the public-proxy advisory when there is none. +fn api_token_available(common: &GlobalArgs) -> bool { + use socket_patch_core::utils::socket_cli_config; + if common.api_token.as_deref().is_some_and(|t| !t.is_empty()) { + return true; + } + if socket_cli_config::no_api_token_veto() { + return false; + } + std::env::var("SOCKET_API_TOKEN").is_ok_and(|t| !t.is_empty()) + || socket_cli_config::load().is_some_and(|c| c.api_token.is_some()) +} + async fn repair_inner( args: &RepairArgs, manifest_path: &Path, - // The client `run()` already built: constructing another one for the - // download printed the core client's "No SOCKET_API_TOKEN set" notice - // twice per repair. `None` (unit tests) builds one on demand, only when - // the download below actually fires. - api_client: Option<&ApiClient>, + // Built lazily on the download branch (see `run`) and handed on to + // the vendored phase, so one repair constructs at most one client and + // prints the core client's "No SOCKET_API_TOKEN set" notice at most + // once. Unit tests pass `&mut None`. + client: &mut Option, // `(eco, uuid, rel)` lockfile vendor references, scanned once by `run`. vendor_references: Vec<(String, String, String)>, ) -> Result<(Envelope, RepairCounts), String> { @@ -234,7 +369,7 @@ async fn repair_inner( // stays a hard error. let manifest = read_manifest(manifest_path) .await - .map_err(|e| e.to_string())?; + .map_err(|e| crate::commands::list::manifest_error_message(manifest_path, &e))?; let socket_dir = crate::args::socket_dir_of(manifest_path, &args.common.cwd); let blobs_path = socket_dir.join("blobs"); @@ -316,68 +451,48 @@ async fn repair_inner( .collect(), }; let missing_count = missing_artifacts.len(); + let noun = download_mode.noun(); + // Whether stdout already carries a line, so the blank separators + // between sections never open the output (the offline warning goes + // to stderr). + let mut stdout_started = true; if missing_artifacts.is_empty() { if !quiet { - println!( - "All {} artifacts are present locally.", - download_mode.as_tag() - ); + println!("{}", format_nothing_missing(manifest.as_ref(), noun)); } } else if args.common.offline { if !quiet { - println!( - "Warning: {} {} artifact(s) are missing (offline mode - not downloading)", - missing_artifacts.len(), - download_mode.as_tag() - ); - for id in missing_artifacts.iter().take(5) { - // Truncate by characters, not bytes: manifest hashes are - // unvalidated strings, and a byte slice panics when index - // 12 lands inside a multibyte char (see format_fetch_result). - let short: String = id.chars().take(12).collect(); - println!(" - {short}..."); - } - if missing_artifacts.len() > 5 { - println!(" ... and {} more", missing_artifacts.len() - 5); - } + eprintln!("{}", format_offline_warning(&missing_artifacts, noun)); } + stdout_started = false; } else { if !quiet { - println!( - "Found {} missing {} artifact(s)", - missing_artifacts.len(), - download_mode.as_tag() - ); + println!("{}", format_found_missing(missing_artifacts.len(), noun)); } if args.common.dry_run { if !quiet { - println!("\nDry run - would download:"); - for id in missing_artifacts.iter().take(10) { - // Chars, not bytes — same constraint as the offline list. - let short: String = id.chars().take(12).collect(); - println!(" - {short}..."); - } - if missing_artifacts.len() > 10 { - println!(" ... and {} more", missing_artifacts.len() - 10); + println!(); + println!("Would download:"); + for line in format_id_list(&missing_artifacts, noun, DRY_RUN_LIST_CAP) { + println!("{line}"); } } } else { - if !quiet { - println!("\nDownloading missing {}s...", download_mode.as_tag()); + let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); + status.set(format!( + "Downloading {}...", + noun.count(missing_artifacts.len()) + )); + if client.is_none() { + *client = Some( + get_api_client_with_overrides(args.common.api_client_overrides()) + .await + .0, + ); } - let built_client; - let client = match api_client { - Some(c) => c, - None => { - built_client = - get_api_client_with_overrides(args.common.api_client_overrides()) - .await - .0; - &built_client - } - }; + let client = client.as_ref().expect("client built just above"); let sources = PatchSources { blobs_path: &blobs_path, packages_path: Some(&packages_path), @@ -391,21 +506,27 @@ async fn repair_inner( .expect("step 1 requires a manifest"); let fetch_result = fetch_missing_sources(m, &sources, download_mode, client, None).await; + status.finish(); downloaded_count = fetch_result.downloaded; download_failed_count = fetch_result.failed; if !quiet { - println!("{}", format_fetch_result(&fetch_result)); - } else if fetch_result.failed > 0 && !args.common.json { - // `--silent` suppresses NON-error output only: a failed - // download must still reach stderr (`--json` runs carry it - // in the envelope instead). Zeroing the success counters - // makes `format_fetch_result` emit just the failure lines. - let failures_only = FetchMissingBlobsResult { - downloaded: 0, - skipped: 0, - ..fetch_result - }; - eprintln!("{}", format_fetch_result(&failures_only)); + for line in format_fetch_successes(&fetch_result, noun) { + println!("{line}"); + } + } + // Failures are error output: stderr, and not muted by + // `--silent` (`--json` runs carry them in the envelope). + if !args.common.json { + for (i, line) in format_fetch_failures(&fetch_result, noun) + .iter() + .enumerate() + { + if i == 0 { + eprintln!("Error: {line}"); + } else { + eprintln!("{line}"); + } + } } } } @@ -422,28 +543,29 @@ async fn repair_inner( &mut env, &vendor_references, ledger, - api_client, + client.as_ref(), ) .await; if !quiet && vendor_rebuilt > 0 { - println!("Rebuilt {} vendored artifact(s).", vendor_rebuilt); + stdout_started = true; + println!( + "Rebuilt {}.", + crate::ui::plural(vendor_rebuilt, "vendored artifact", "vendored artifacts") + ); } - // Step 2: Clean up unused artifacts across all three directories. + // Step 2: Clean up unused artifacts across all three directories. The + // summary prints once all three passes are in, so "nothing to clean + // up" is only said when all three really are empty. if let (false, Some(manifest)) = (args.download_only, manifest.as_ref()) { - if !quiet { - println!(); - } let sweep = sweep_unused_artifacts(manifest, &socket_dir, args.common.dry_run).await; - // The blob pass prints its status unconditionally ("all are in - // use" included — the core helper owns that wording); the archive - // passes print only when they removed something, relabeled. let passes = [ - ("blob", None, sweep.blobs), - ("diff", Some("diff archive(s)"), sweep.diffs), - ("package", Some("package archive(s)"), sweep.packages), + ("blob", BLOB, sweep.blobs), + ("diff", DIFF_ARCHIVE, sweep.diffs), + ("package", PACKAGE_ARCHIVE, sweep.packages), ]; - for (label, relabel, result) in passes { + let mut results: Vec<(ArtifactNoun, CleanupResult)> = Vec::new(); + for (label, noun, result) in passes { // A failed cleanup — the pass aborted, or it could not unlink // every orphan — is error output: `--silent` (suppress // NON-error output) must not mute it, and the JSON envelope @@ -452,6 +574,8 @@ async fn repair_inner( // informational skip (not `Failed`) to preserve the human // path's warn-and-continue contract: status stays success, // exit stays 0, and the loop goes on to the next directory. + // A pass that swept past unlink failures still counts what it + // did reclaim. if let Some(detail) = sweep_failure(label, &result) { if !args.common.json { eprintln!("Warning: {detail}"); @@ -461,28 +585,47 @@ async fn repair_inner( .with_reason("cleanup_failed", detail), ); } - let Ok(cleanup_result) = result else { - continue; - }; - blobs_checked += cleanup_result.blobs_checked; - blobs_cleaned += cleanup_result.blobs_removed; - bytes_freed += cleanup_result.bytes_freed; - if quiet { - continue; + if let Ok(cleanup_result) = result { + results.push((noun, cleanup_result)); } - let text = format_cleanup_result(&cleanup_result, args.common.dry_run); - match relabel { - None => println!("{text}"), - Some(relabel) if cleanup_result.blobs_removed > 0 => { - println!("{}", text.replace("blob(s)", relabel)); - } - Some(_) => {} + } + + for (_, r) in &results { + blobs_checked += r.blobs_checked; + blobs_cleaned += r.blobs_removed; + bytes_freed += r.bytes_freed; + } + if !quiet { + if stdout_started { + println!(); } + stdout_started = true; + println!("{}", format_cleanup_summary(&results, args.common.dry_run)); } } - if !args.common.dry_run && !quiet { - println!("\nRepair complete."); + if !quiet { + // The blank separator goes to the same stream as the final line, + // so a piped stdout never ends in a stray blank line when the + // line itself goes to stderr. + let other_failure = matches!(env.status, Status::PartialFailure | Status::Error); + let line = format_final_line( + download_failed_count, + other_failure, + noun, + args.common.dry_run, + ); + if download_failed_count > 0 || other_failure { + if stdout_started { + eprintln!(); + } + eprintln!("{line}"); + } else { + if stdout_started { + println!(); + } + println!("{line}"); + } } // Translate the aggregate counts into envelope events. `repair` @@ -509,7 +652,7 @@ async fn repair_inner( if download_failed_count > 0 { env.record(PatchEvent::artifact(PatchAction::Failed).with_error( "download_failed", - format!("{} artifact(s) failed to download", download_failed_count), + format!("{} failed to download", noun.count(download_failed_count)), )); env.mark_partial_failure(); } @@ -546,6 +689,32 @@ mod tests { use crate::args::GlobalArgs; use std::path::PathBuf; + #[test] + fn nothing_missing_line_says_why() { + use socket_patch_core::manifest::schema::PatchManifest; + assert_eq!( + format_nothing_missing(None, DIFF_ARCHIVE), + "No manifest; no patch artifacts to download." + ); + let empty = PatchManifest::new(); + assert_eq!( + format_nothing_missing(Some(&empty), DIFF_ARCHIVE), + "No patches in manifest; nothing to download." + ); + let one: PatchManifest = + serde_json::from_str(MANIFEST_JSON).expect("fixture manifest parses"); + assert!(!one.patches.is_empty()); + assert_eq!( + format_nothing_missing(Some(&one), DIFF_ARCHIVE), + "All diff archives are present locally." + ); + assert_eq!( + HOSTED_ONLY_REASON, + "Hosted redirects need no local repair; re-run `scan --mode hosted` to refresh \ + the lockfile redirects (it also re-checks for stale pre-redirect installs)" + ); + } + const MANIFEST_JSON: &str = r#"{ "patches": { "pkg:npm/__repair_unit__@1.0.0": { @@ -627,9 +796,10 @@ mod tests { let mut args = offline_args(tmp.path()); args.common.dry_run = true; - let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); assert!( !has_download_event(&env), @@ -652,9 +822,10 @@ mod tests { args.common.offline = false; args.common.dry_run = true; - let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, _counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); assert!( has_download_event(&env), @@ -676,9 +847,10 @@ mod tests { write_blob(&socket, &orphan_hash, orphan_bytes); let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); assert_eq!(counts.cleaned, 1, "one orphan should be cleaned"); assert_eq!( @@ -709,9 +881,10 @@ mod tests { args.common.offline = false; args.download_only = true; - let (_env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (_env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); assert_eq!(counts.cleaned, 0, "download-only must skip cleanup"); assert_eq!(counts.bytes_freed, 0); @@ -751,9 +924,10 @@ mod tests { ); let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .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"); @@ -831,9 +1005,10 @@ mod tests { let mut args = offline_args(tmp.path()); args.common.json = false; - let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); assert_eq!(counts.downloaded, 0); assert_eq!(env.status, Status::Success); @@ -850,9 +1025,10 @@ mod tests { args.common.dry_run = true; args.common.json = false; - let (env, _counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, _counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); // The preview event is still recorded once the print survives. assert!( @@ -872,9 +1048,10 @@ mod tests { // No blob on disk → manifest afterHash is "missing". Not dry-run. let args = offline_args(tmp.path()); - let (env, counts) = repair_inner(&args, &socket.join("manifest.json"), None, Vec::new()) - .await - .expect("repair_inner"); + let (env, counts) = + repair_inner(&args, &socket.join("manifest.json"), &mut None, Vec::new()) + .await + .expect("repair_inner"); assert!( !has_download_event(&env), @@ -888,4 +1065,177 @@ mod tests { "missing artifacts in offline mode are a warning, not a failure" ); } + + fn ids(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn id_list_is_sorted_capped_and_honest_about_truncation() { + let uuids = ids(&[ + "22222222-2222-4222-8222-222222222222", + "11111111-1111-4111-8111-111111111111", + ]); + // Diff-mode UUIDs print in full, in sorted order. + assert_eq!( + format_id_list(&uuids, DIFF_ARCHIVE, 5), + vec![ + " - 11111111-1111-4111-8111-111111111111", + " - 22222222-2222-4222-8222-222222222222", + ] + ); + // File-mode hashes: 64-hex cut to 12 + "...", a short one kept whole. + let hashes = ids(&[&"b".repeat(64), "22"]); + assert_eq!( + format_id_list(&hashes, BLOB, 5), + vec![" - 22", " - bbbbbbbbbbbb..."] + ); + let many: Vec = (0..7).map(|i| format!("id{i}")).collect(); + let lines = format_id_list(&many, BLOB, 5); + assert_eq!(lines.len(), 6); + assert_eq!(lines[5], " ... and 2 more"); + assert!(format_id_list(&[], BLOB, 5).is_empty()); + // Multibyte ids never panic and are counted in chars. + assert_eq!( + format_id_list(&ids(&[MULTIBYTE_HASH]), BLOB, 5), + vec![" - aéééééééé"] + ); + } + + #[test] + fn found_missing_line() { + assert_eq!(format_found_missing(1, BLOB), "Found 1 missing blob"); + assert_eq!( + format_found_missing(12, DIFF_ARCHIVE), + "Found 12 missing diff archives" + ); + } + + #[test] + fn offline_warning_singular_and_plural() { + assert_eq!( + format_offline_warning( + &ids(&["11111111-1111-4111-8111-111111111111"]), + DIFF_ARCHIVE + ), + "Warning: 1 diff archive is missing (offline mode - not downloading):\n\ + \x20 - 11111111-1111-4111-8111-111111111111" + ); + assert_eq!( + format_offline_warning(&ids(&["b", "a"]), BLOB), + "Warning: 2 blobs are missing (offline mode - not downloading):\n - a\n - b" + ); + } + + #[test] + fn cleanup_summary_names_each_kind() { + let checked = |n: usize| CleanupResult { + blobs_checked: n, + ..Default::default() + }; + assert_eq!( + format_cleanup_summary( + &[ + (BLOB, checked(0)), + (DIFF_ARCHIVE, checked(0)), + (PACKAGE_ARCHIVE, checked(0)) + ], + false + ), + "Nothing to clean up." + ); + assert_eq!(format_cleanup_summary(&[], false), "Nothing to clean up."); + assert_eq!( + format_cleanup_summary(&[(BLOB, checked(1)), (DIFF_ARCHIVE, checked(0))], false), + "Checked 1 blob: in use." + ); + assert_eq!( + format_cleanup_summary( + &[ + (BLOB, checked(2)), + (DIFF_ARCHIVE, checked(1)), + (PACKAGE_ARCHIVE, checked(3)) + ], + false + ), + "Checked 2 blobs, 1 diff archive and 3 package archives: all in use." + ); + let orphan = CleanupResult { + blobs_checked: 2, + blobs_removed: 1, + bytes_freed: 3, + removed_blobs: vec!["3333.tar.gz".into()], + ..Default::default() + }; + let pkg = CleanupResult { + blobs_checked: 1, + blobs_removed: 1, + bytes_freed: 2, + removed_blobs: vec!["4444.tar.gz".into()], + ..Default::default() + }; + assert_eq!( + format_cleanup_summary( + &[ + (BLOB, checked(0)), + (DIFF_ARCHIVE, orphan), + (PACKAGE_ARCHIVE, pkg) + ], + true + ), + "Would remove 1 unused diff archive (3 B freed)\n\ + Unused diff archives:\n - 3333.tar.gz\n\ + Would remove 1 unused package archive (2 B freed)\n\ + Unused package archives:\n - 4444.tar.gz" + ); + } + + #[test] + fn final_line_reflects_failures_and_dry_run() { + assert_eq!(format_final_line(0, false, BLOB, false), "Repair complete."); + assert_eq!( + format_final_line(0, false, BLOB, true), + "Dry run: no changes made." + ); + assert_eq!( + format_final_line(1, false, DIFF_ARCHIVE, false), + "Repair finished with errors: 1 diff archive was not downloaded." + ); + assert_eq!( + format_final_line(2, true, BLOB, false), + "Repair finished with errors: 2 blobs were not downloaded." + ); + // A failed vendored rebuild (exit 1) never closes on "complete". + assert_eq!( + format_final_line(0, true, BLOB, false), + "Repair finished with errors." + ); + assert_eq!( + format_final_line(0, true, BLOB, true), + "Repair finished with errors." + ); + } + + #[test] + fn help_has_no_developer_commentary() { + use clap::CommandFactory; + let mut cmd = crate::Cli::command(); + let help = cmd + .find_subcommand_mut("repair") + .expect("repair subcommand") + .render_long_help() + .to_string(); + assert!( + help.contains("Only download missing artifacts; skip the cleanup phase."), + "{help}" + ); + for internal in [ + "value_parser", + "GLOBAL_ARG_ENV_VARS", + "`main`'s", + "parse_bool_flag", + ] { + assert!(!help.contains(internal), "leaked {internal:?} into --help"); + } + } } diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index afac5869..5817afec 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -76,6 +76,7 @@ use crate::commands::vendor::{ }; use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::{Envelope, PatchAction, PatchEvent, RunWarning}; +use crate::ui::plural; /// One broken vendored unit queued for rebuild. struct Candidate { @@ -360,17 +361,48 @@ async fn reconstruct_entry_wiring( } } -fn fail(env: &mut Envelope, quiet: bool, purl: &str, code: &str, detail: String) { - if !quiet { - eprintln!( - "Cannot repair vendored artifact for {}: {detail}", - normalize_purl(purl) - ); +/// Record one artifact that cannot be repaired. An error, so the line +/// prints even under `--silent` (`json` mutes it: the envelope carries it). +fn fail(env: &mut Envelope, json: bool, purl: &str, code: &str, detail: String) { + if !json { + eprintln!("{}", format_repair_failure(purl, &detail)); } env.record(PatchEvent::new(PatchAction::Failed, purl.to_string()).with_error(code, detail)); env.mark_partial_failure(); } +/// `Error: Cannot repair vendored artifact for : `. +fn format_repair_failure(purl: &str, detail: &str) -> String { + format!( + "Error: Cannot repair vendored artifact for {}: {detail}", + normalize_purl(purl) + ) +} + +/// The `repair --dry-run` preview of vendored rebuilds: a heading, then +/// ` - (: )` per artifact. `items` are +/// `(purl, reason code, artifact path)`. +fn format_rebuild_preview(items: &[(String, &str, &str)]) -> Vec { + let mut lines = vec![format!( + "Would rebuild {}:", + plural(items.len(), "vendored artifact", "vendored artifacts") + )]; + lines.extend(items.iter().map(|(purl, reason, path)| { + format!(" - {purl} ({}: {path})", rebuild_reason_label(reason)) + })); + lines +} + +/// Plain words for a rebuild candidate's reason code. +fn rebuild_reason_label(code: &str) -> &str { + match code { + "vendor_artifact_missing" => "missing", + "vendor_artifact_corrupt" => "corrupt", + "vendor_inventory_unverified" => "unverified", + other => other, + } +} + /// A soft (healthy-by-members, unanchored) reconstruction whose trustworthy /// rebuild cannot proceed: the entry stays restored WITHOUT a whole-file /// fingerprint — the legacy member-only state pass 1 keeps warning about @@ -544,6 +576,14 @@ pub(crate) async fn repair_vendored_artifacts_with_references( let mut state = match ledger { Ok(s) => s, Err(e) => { + // Errors print even under --silent; without this line the + // run exits 1 after a clean-looking repair report. + if !common.json { + eprintln!( + "{}", + crate::commands::vendor::format_state_unreadable(&e.to_string()) + ); + } env.record( PatchEvent::artifact(PatchAction::Failed) .with_error("vendor_state_unreadable", e.to_string()), @@ -589,7 +629,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( None => { fail( env, - quiet, + common.json, purl, "vendor_artifact_unrepairable", format!( @@ -651,7 +691,13 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } Ok(false) => {} Err(detail) => { - fail(env, quiet, purl, "vendor_artifact_unrepairable", detail); + fail( + env, + common.json, + purl, + "vendor_artifact_unrepairable", + detail, + ); continue; } } @@ -758,7 +804,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( ArtifactHealth::Unverifiable { reason } => { fail( env, - quiet, + common.json, purl, "vendor_artifact_unrepairable", format!("the ledger entry cannot be verified ({reason}); fix state.json"), @@ -804,7 +850,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( None => { fail( env, - quiet, + common.json, &format!("pkg:{eco}/unknown@{uuid}"), "vendor_artifact_missing", format!( @@ -925,7 +971,13 @@ pub(crate) async fn repair_vendored_artifacts_with_references( if let Err(detail) = repair_workspace_copies(&common.cwd, &mut entry, false).await { - fail(env, quiet, &purl, "vendor_artifact_unrepairable", detail); + fail( + env, + common.json, + &purl, + "vendor_artifact_unrepairable", + detail, + ); continue; } let save_failed = persist_vendor_entry( @@ -969,7 +1021,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( ArtifactHealth::Unverifiable { reason } if reason == "vendor_workspace_artifact_invalid" => { - fail(env, quiet, &purl, "vendor_artifact_unrepairable", + fail(env, common.json, &purl, "vendor_artifact_unrepairable", "workspace tarball paths cannot be validated; fix the binary lock or symbolic links before repairing".into()); } _ => { @@ -992,6 +1044,19 @@ pub(crate) async fn repair_vendored_artifacts_with_references( // ── Dry run: preview only ──────────────────────────────────────────── if common.dry_run { + if !quiet { + let items: Vec<(String, &str, &str)> = candidates + .iter() + .map(|c| { + let purl = normalize_purl(&c.purl).into_owned(); + (purl, c.reason, c.entry.artifact.path.as_str()) + }) + .collect(); + println!(); + for line in format_rebuild_preview(&items) { + println!("{line}"); + } + } for c in &candidates { env.record( PatchEvent::new(PatchAction::Verified, c.purl.clone()).with_details( @@ -1008,9 +1073,14 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } if !quiet { + println!(); println!( - "\nRebuilding {} broken vendored artifact(s)...", - candidates.len() + "Rebuilding {}...", + plural( + candidates.len(), + "broken vendored artifact", + "broken vendored artifacts" + ) ); } @@ -1091,7 +1161,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } fail( env, - quiet, + common.json, &c.purl, c.reason, format!( @@ -1173,7 +1243,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } else { fail( env, - quiet, + common.json, &c.purl, c.reason, format!( @@ -1225,7 +1295,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } Err(registry_fetch::FetchError::Failed(d)) | Err(registry_fetch::FetchError::Unverifiable(d)) => { - fail(env, quiet, &c.purl, "vendor_fetch_failed", d); + fail(env, common.json, &c.purl, "vendor_fetch_failed", d); unrebuildable.insert(c.purl.clone()); continue; } @@ -1261,7 +1331,13 @@ pub(crate) async fn repair_vendored_artifacts_with_references( ledger records no recoverable registry fragment" .to_string() }; - fail(env, quiet, &c.purl, "vendor_artifact_unrepairable", detail); + fail( + env, + common.json, + &c.purl, + "vendor_artifact_unrepairable", + detail, + ); unrebuildable.insert(c.purl.clone()); } PristineFetch::Failed(detail) => { @@ -1275,7 +1351,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( ); rebuilt += 1; } else { - fail(env, quiet, &c.purl, "vendor_fetch_failed", detail); + fail(env, common.json, &c.purl, "vendor_fetch_failed", detail); } unrebuildable.insert(c.purl.clone()); } @@ -1327,7 +1403,13 @@ pub(crate) async fn repair_vendored_artifacts_with_references( if let Some((live, kept)) = &aside { restore_aside_vendor_dir(live, kept).await; } - fail(env, quiet, &c.purl, "vendor_artifact_unrepairable", detail); + fail( + env, + common.json, + &c.purl, + "vendor_artifact_unrepairable", + detail, + ); continue; } }; @@ -1370,7 +1452,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } fail( env, - quiet, + common.json, &c.purl, "vendor_artifact_unrepairable", "no vendor backend for this ecosystem in this build".to_string(), @@ -1380,7 +1462,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( if let Some((live, kept)) = &aside { restore_aside_vendor_dir(live, kept).await; } - fail(env, quiet, &c.purl, code, detail); + fail(env, common.json, &c.purl, code, detail); } Some(VendorOutcome::Done { result, @@ -1393,7 +1475,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } fail( env, - quiet, + common.json, &c.purl, "vendor_artifact_rebuild_failed", result.error.unwrap_or_else(|| "rebuild failed".to_string()), @@ -1441,7 +1523,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( } fail( env, - quiet, + common.json, &c.purl, "vendor_artifact_rebuild_failed", format!( @@ -1560,7 +1642,7 @@ pub(crate) async fn repair_vendored_artifacts_with_references( .await; fail( env, - quiet, + common.json, &c.purl, "vendor_artifact_rebuild_failed", format!( @@ -2147,3 +2229,54 @@ mod tests { assert_eq!(npm_coords("pkg:npm/left-pad"), None); } } + +/// Exact-string tests for the vendored-repair human lines. +#[cfg(test)] +mod ui_format_tests { + use super::*; + + #[test] + fn repair_failure_line_has_error_prefix() { + assert_eq!( + format_repair_failure("pkg:npm/%40s/x@1.0.0", "no pristine source"), + "Error: Cannot repair vendored artifact for pkg:npm/@s/x@1.0.0: no pristine source" + ); + } + + #[test] + fn rebuild_preview_singular_and_plural() { + let one = vec![( + "pkg:npm/minimist@1.2.5".to_string(), + "vendor_artifact_missing", + ".socket/vendor/npm/u/minimist-1.2.5.tgz", + )]; + assert_eq!( + format_rebuild_preview(&one), + vec![ + "Would rebuild 1 vendored artifact:", + " - pkg:npm/minimist@1.2.5 (missing: .socket/vendor/npm/u/minimist-1.2.5.tgz)", + ] + ); + let two = vec![ + ( + "pkg:npm/a@1".to_string(), + "vendor_artifact_corrupt", + "p/a.tgz", + ), + ( + "pkg:gem/b@1".to_string(), + "vendor_inventory_unverified", + "p/b", + ), + ]; + assert_eq!( + format_rebuild_preview(&two), + vec![ + "Would rebuild 2 vendored artifacts:", + " - pkg:npm/a@1 (corrupt: p/a.tgz)", + " - pkg:gem/b@1 (unverified: p/b)", + ] + ); + assert_eq!(rebuild_reason_label("something_else"), "something_else"); + } +} diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 555ce670..e339981c 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -28,6 +28,7 @@ use crate::commands::vendor::dispatch_revert_one_opts; use crate::ecosystem_dispatch::{find_all_packages_for_rollback, partition_purls}; use crate::json_envelope::Command as EnvelopeCommand; use crate::looks_like_uuid; +use crate::ui::{plural, StatusLine}; /// Pin the beforeHash blobs of `purls` into `reference` as synthetic keep /// records: `cleanup_unused_blobs` keeps only afterHash blobs (beforeHash @@ -98,14 +99,13 @@ pub struct RollbackArgs { #[command(flatten)] pub common: GlobalArgs, - /// Rollback a patch by fetching beforeHash blobs from API (no manifest required). - /// - /// `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. + // `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", @@ -129,6 +129,326 @@ pub struct RollbackArgs { pub preserve_state: bool, } +/// Join prompt clauses as an English list: `a`, `a and b`, `a, b, and c`. +pub(crate) fn join_clauses(clauses: &[String]) -> String { + match clauses { + [] => String::new(), + [one] => one.clone(), + [a, b] => format!("{a} and {b}"), + [init @ .., last] => format!("{}, and {last}", init.join(", ")), + } +} + +/// `msg` with its first character uppercased: human `Error: …` lines +/// start with a capital even when the message (shared with the JSON +/// envelope, which keeps it verbatim) does not. A message that opens with +/// a value rather than a word — a purl, a patch UUID, a path, a flag — is +/// returned unchanged: capitalizing it would corrupt text the user may +/// copy and paste. +pub(crate) fn capitalize_first(msg: &str) -> String { + let first_word = msg + .split_whitespace() + .next() + .unwrap_or("") + .trim_end_matches([',', '.', ';', ':']); + let is_plain_word = !first_word.is_empty() + && first_word + .chars() + .all(|c| c.is_alphabetic() || c == '\'' || c == '-') + && first_word.chars().next().is_some_and(char::is_alphabetic); + if !is_plain_word { + return msg.to_string(); + } + let mut chars = msg.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } +} + +/// Capitalize the first character and end with `?`. +pub(crate) fn as_question(text: &str) -> String { + if text.is_empty() { + return String::new(); + } + format!("{}?", capitalize_first(text)) +} + +/// The default (destructive) rollback's confirmation prompt, naming only +/// the legs that have work. +fn rollback_prompt( + manifest: usize, + vendored: usize, + hosted: usize, + leftover_edits: usize, +) -> String { + let mut clauses: Vec = Vec::new(); + if manifest > 0 { + clauses.push(format!( + "roll back {}", + plural(manifest, "patch", "patches") + )); + clauses.push(format!( + "remove {} from the local manifest", + if manifest == 1 { "it" } else { "them" } + )); + } + if vendored > 0 { + // Vendored-mode entries live only in the ledger (their embedded + // patch record is the local copy), so name the ledger records as + // what goes, the way the manifest clause names its entries. + clauses.push(format!( + "delete {} and {} ledger {}", + plural(vendored, "vendored artifact", "vendored artifacts"), + if vendored == 1 { "its" } else { "their" }, + if vendored == 1 { "record" } else { "records" } + )); + } + if hosted > 0 { + clauses.push(format!( + "unwind {}", + plural(hosted, "hosted redirect", "hosted redirects") + )); + } else if leftover_edits > 0 { + clauses.push(format!( + "replay {}", + plural( + leftover_edits, + "leftover hosted redirect edit", + "leftover hosted redirect edits" + ) + )); + } + as_question(&join_clauses(&clauses)) +} + +/// Where a physical copy lives, relative to `cwd` when it is inside it. +/// Shared with `apply`. +pub(crate) fn display_copy_path(package_path: &str, cwd: &Path) -> String { + let path = Path::new(package_path); + let canonical = std::fs::canonicalize(path).ok(); + let rel = path + .strip_prefix(cwd) + .ok() + .or_else(|| canonical.as_deref().and_then(|c| c.strip_prefix(cwd).ok())); + match rel { + Some(r) if !r.as_os_str().is_empty() => r.display().to_string(), + _ => package_path.to_string(), + } +} + +/// `Error: Failed to roll back : ` — the per-package failure +/// line `--silent` runs print inline (their summary is muted). +pub(crate) fn format_rollback_failure(purl: &str, why: &str) -> String { + format!("Error: Failed to roll back {purl}: {why}") +} + +/// The closing stderr error of a human run whose stdout report lists +/// failed packages (under `--silent` the per-package +/// [`format_rollback_failure`] lines print instead). +fn format_rollback_failed(dry_run: bool) -> &'static str { + if dry_run { + "Error: Some patches cannot be rolled back." + } else { + "Error: Some patches could not be rolled back." + } +} + +/// Per-package counts, keyed by `package_key` so two physical copies of +/// one purl count once (apply's summary counts the same way). A package +/// with any failed copy counts as failed; otherwise it is "already +/// original" only when every copy is. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct RollbackTally { + /// Some copy had files restored (wet run). + pub(crate) rolled_back: usize, + /// Some copy is not yet original (dry run: would be rolled back). + pub(crate) can_roll_back: usize, + /// Every copy already matches its beforeHash. + pub(crate) already: usize, + /// Some copy failed. + pub(crate) failed: usize, +} + +pub(crate) fn tally_rollback_results(results: &[RollbackResult]) -> RollbackTally { + let mut by_key: std::collections::BTreeMap<&str, Vec<&RollbackResult>> = + std::collections::BTreeMap::new(); + for r in results { + by_key.entry(r.package_key.as_str()).or_default().push(r); + } + let mut tally = RollbackTally::default(); + for copies in by_key.values() { + if copies.iter().any(|r| !r.success) { + tally.failed += 1; + continue; + } + if copies.iter().all(|r| all_files_already_original(r)) { + tally.already += 1; + continue; + } + tally.can_roll_back += 1; + if copies.iter().any(|r| !r.files_rolled_back.is_empty()) { + tally.rolled_back += 1; + } + } + tally +} + +/// The dry-run verification block, followed by the reason for each +/// package that cannot be rolled back (a dry run prints no other +/// failure report). +fn format_rollback_dry_run_counts(results: &[RollbackResult], cwd: &Path) -> Vec { + let tally = tally_rollback_results(results); + let mut lines = vec![ + String::new(), + "Rollback verification complete:".to_string(), + format!( + " {} can be rolled back", + plural(tally.can_roll_back, "package", "packages") + ), + ]; + if tally.already > 0 { + lines.push(format!( + " {} already in original state", + plural(tally.already, "package", "packages") + )); + } + if tally.failed > 0 { + lines.push(format!( + " {} cannot be rolled back", + plural(tally.failed, "package", "packages") + )); + } + lines.extend(format_rollback_failures(results, cwd)); + lines +} + +/// ` ()` — the copy path only when the package has several. +fn copy_label( + results: &[RollbackResult], + r: &RollbackResult, + note: Option<&str>, + cwd: &Path, +) -> String { + let copies = results + .iter() + .filter(|o| o.package_key == r.package_key) + .count(); + let copy = (copies > 1).then(|| display_copy_path(&r.package_path, cwd)); + match (copy, note) { + (Some(c), Some(n)) => format!(" {} ({c}, {n})", r.package_key), + (Some(c), None) => format!(" {} ({c})", r.package_key), + (None, Some(n)) => format!(" {} ({n})", r.package_key), + (None, None) => format!(" {}", r.package_key), + } +} + +/// The `Failed to roll back:` section (empty when nothing failed). +fn format_rollback_failures(results: &[RollbackResult], cwd: &Path) -> Vec { + let failed: Vec = results + .iter() + .filter(|r| !r.success) + .map(|r| { + format!( + "{}: {}", + copy_label(results, r, None, cwd), + r.error.as_deref().unwrap_or("unknown error") + ) + }) + .collect(); + let mut lines = Vec::new(); + if !failed.is_empty() { + lines.push(String::new()); + lines.push("Failed to roll back:".to_string()); + lines.extend(failed); + } + lines +} + +/// Purls (qualifiers stripped) whose installed tree this run leaves +/// original: restored now, restorable on a dry run, or already original. +/// Any successful in-place result that verified files qualifies — so a +/// dry run's reinstall note matches the wet run's, and a never-patched +/// tree is not reported as still holding patched bytes. +fn handled_in_place(results: &[RollbackResult]) -> HashSet<&str> { + results + .iter() + .filter(|r| r.success && (!r.files_rolled_back.is_empty() || !r.files_verified.is_empty())) + .map(|r| strip_purl_qualifiers(&r.package_key)) + .collect() +} + +/// The wet run's per-package blocks: what was rolled back (naming each +/// physical copy when a package has several) and what failed. +fn format_rollback_results(results: &[RollbackResult], cwd: &Path) -> Vec { + let rolled_back: Vec = results + .iter() + .filter(|r| r.success && !r.files_rolled_back.is_empty()) + .map(|r| copy_label(results, r, None, cwd)) + .chain( + results + .iter() + .filter(|r| r.success && all_files_already_original(r)) + .map(|r| copy_label(results, r, Some("already original"), cwd)), + ) + .collect(); + let mut lines = Vec::new(); + if !rolled_back.is_empty() { + lines.push(String::new()); + lines.push("Rolled back packages:".to_string()); + lines.extend(rolled_back); + } + lines.extend(format_rollback_failures(results, cwd)); + lines +} + +/// `--preserve-state`'s closing line (names vendored artifacts only when +/// some were preserved). Shared with `remove --preserve-state`. +pub(crate) fn format_preserved_note(entries: usize, vendored: usize) -> String { + let entries_part = if entries == 1 { + "Manifest entry" + } else { + "Manifest entries" + }; + let (what, reapply) = match vendored { + 0 => (entries_part.to_string(), "`socket-patch apply`"), + 1 => ( + format!("{entries_part} and vendored artifact"), + "`socket-patch apply` or `socket-patch vendor`", + ), + _ => ( + format!("{entries_part} and vendored artifacts"), + "`socket-patch apply` or `socket-patch vendor`", + ), + }; + format!("{what} preserved (--preserve-state); re-apply with {reapply}.") +} + +/// The GC line: `Freed 328.28 KB of unused blobs and archives`. +fn format_gc_freed(bytes: u64, dry_run: bool) -> String { + format!( + "{} {} of unused blobs and archives", + if dry_run { "Would free" } else { "Freed" }, + socket_patch_core::manifest::cleanup_blobs::format_bytes(bytes) + ) +} + +/// The reinstall note for packages whose wiring was undone but whose +/// installed tree still holds patched bytes. +fn format_reinstall_note(still_patched: usize, dry_run: bool) -> String { + let keep = match (still_patched == 1, dry_run) { + (true, false) => "keeps its", + (true, true) => "would keep its", + (false, false) => "keep their", + (false, true) => "would keep their", + }; + format!( + "Note: {} {keep} patched bytes in installed trees until the next \ + package-manager install.", + plural(still_patched, "unwired package", "unwired packages") + ) +} + /// One classified rollback target token. #[derive(Debug, Clone, PartialEq)] pub(crate) enum RollbackTarget { @@ -358,15 +678,6 @@ pub(crate) fn all_files_already_original(result: &RollbackResult) -> bool { /// are no-ops reported on their own line, so they are excluded here — /// mirroring apply's dry-run split — to avoid double-counting them /// against "can be rolled back". -fn can_rollback_count(results: &[RollbackResult]) -> usize { - let successful = results.iter().filter(|r| r.success).count(); - let already_original = results - .iter() - .filter(|r| r.success && all_files_already_original(r)) - .count(); - successful.saturating_sub(already_original) -} - fn result_to_json(result: &RollbackResult) -> serde_json::Value { serde_json::json!({ "purl": result.package_key, @@ -500,7 +811,7 @@ fn emit_rollback_error(json: bool, msg: &str) { .expect("serializing an in-memory JSON value cannot fail") ); } else { - eprintln!("Error: {msg}"); + eprintln!("Error: {}", capitalize_first(msg)); } } @@ -671,7 +982,7 @@ async fn run_vendored_leg( VendorRevertStep::Failed(why) => { // Errors print even under --silent. if !common.json { - eprintln!("Failed to revert vendoring for {key}: {why}"); + eprintln!("Error: Failed to revert vendoring for {key}: {why}"); } out.failed.push((key.clone(), why)); } @@ -704,8 +1015,12 @@ async fn run_vendored_leg( out.reverted.push(key.clone()); } VendorRevertStep::LedgerWriteFailed(e) => { - out.failed - .push((key.clone(), format!("vendor ledger write failed: {e}"))); + let why = format!("vendor ledger write failed: {e}"); + // Errors print even under --silent: this drives exit 1. + if !common.json { + eprintln!("Error: Failed to revert vendoring for {key}: {why}"); + } + out.failed.push((key.clone(), why)); } } } @@ -756,7 +1071,7 @@ pub(crate) async fn run_hosted_leg( } Err(e) => { if !common.json { - eprintln!("Failed to unwind hosted redirect for {purl}: {e}"); + eprintln!("Error: Failed to unwind hosted redirect for {purl}: {e}"); } out.failed.push((purl.clone(), e)); } @@ -766,7 +1081,7 @@ pub(crate) async fn run_hosted_leg( } else { if !common.json { eprintln!( - "Cannot unwind hosted redirect for {purl}: no per-purl revert exists for \ + "Error: Cannot unwind hosted redirect for {purl}: no per-purl revert exists for \ this ecosystem. Run an unscoped `socket-patch rollback` to unwind ALL \ hosted redirects, or re-run `scan --mode hosted` to normalize." ); @@ -786,7 +1101,7 @@ pub(crate) async fn run_hosted_leg( let why = format!("{} ({})", refusal.reason, files.join(", ")); if !common.json { eprintln!( - "Cannot unwind hosted redirect edits ({}): {why}", + "Error: Cannot unwind hosted redirect edits ({}): {why}", refusal.group ); } @@ -807,8 +1122,12 @@ pub(crate) async fn run_hosted_leg( } out.reverted.push(purl); } else if !out.failed.iter().any(|(p, _)| p.starts_with("group:")) { - out.failed - .push((purl, "hosted redirect edits could not be replayed".into())); + let why = "hosted redirect edits could not be replayed"; + // Errors print even under --silent: this drives exit 1. + if !common.json { + eprintln!("Error: Failed to unwind hosted redirect for {purl}: {why}"); + } + out.failed.push((purl, why.into())); } } } @@ -859,7 +1178,7 @@ pub async fn run(args: RollbackArgs) -> i32 { let path_scope = match crate::path_scope::PathScope::parse(&path_patterns) { Ok(s) => s, Err(e) => { - eprintln!("error: {e}"); + eprintln!("Error: {}", capitalize_first(&e.to_string())); return 2; } }; @@ -920,7 +1239,7 @@ pub async fn run(args: RollbackArgs) -> i32 { } else { // Errors print even under --silent ("errors only", never // "nothing"): exit 1 with no message would be undiagnosable. - eprintln!("Manifest not found at {}", manifest_path.display()); + eprintln!("Error: Manifest not found at {}", manifest_path.display()); } return 1; } @@ -1137,9 +1456,18 @@ pub async fn run(args: RollbackArgs) -> i32 { } } - // `--ecosystems` narrows every leg (the manifest side is scoped inside - // the agent engine as before). - if args.common.ecosystems.is_some() { + // `--ecosystems` narrows every leg. The agent engine scopes the + // manifest side again internally; narrowing it here too keeps the + // confirmation prompt's count honest (an npm-only manifest under + // `-e pypi` has nothing to roll back, so there is nothing to confirm). + let scope_before_eco_filter = manifest_scope.len() + vendor_scope.len() + hosted_scope.len(); + if let Some(ecosystems) = args.common.ecosystems.as_deref() { + let manifest_purls: Vec = manifest_scope.iter().cloned().collect(); + let in_eco: HashSet = partition_purls(&manifest_purls, Some(ecosystems)) + .into_values() + .flatten() + .collect(); + manifest_scope.retain(|purl| in_eco.contains(purl)); vendor_scope.retain(|key| { vendor_entries .iter() @@ -1194,9 +1522,10 @@ pub async fn run(args: RollbackArgs) -> i32 { if redirect_corrupt { run_warnings.push(( "redirect_state_unreadable".into(), + // The core error already carries the recovery steps; only say + // what this run skipped. format!( - "cannot read the hosted redirect ledger: {} — the hosted leg was skipped; \ - quarantine or restore .socket/vendor/redirect-state.json and re-run", + "the hosted leg was skipped: cannot read the hosted redirect ledger: {}", redirect_state_result .as_ref() .expect_err("checked corrupt above") @@ -1224,39 +1553,29 @@ pub async fn run(args: RollbackArgs) -> i32 { || !vendor_scope.is_empty() || !hosted_scope.is_empty() || hosted_leftover_edits > 0; + // Everything in scope was filtered out by `--ecosystems`: say so, + // instead of the misleading "No patches found in manifest". + let eco_filtered_everything = !has_work && scope_before_eco_filter > 0; + if eco_filtered_everything && !args.common.json && !args.common.silent { + println!( + "No patches in scope for --ecosystems {}", + args.common + .ecosystems + .as_deref() + .unwrap_or_default() + .join(",") + ); + } if has_work && !args.common.dry_run && !args.preserve_state { // Compose only the clauses that apply, so a hosted-only run never // claims manifest entries it does not have. - let mut clauses: Vec = Vec::new(); - if !manifest_scope.is_empty() { - clauses.push(format!( - "roll back {} patch(es) and remove them from the local manifest", - manifest_scope.len() - )); - } - if !vendor_scope.is_empty() { - // Vendored-mode entries live only in the ledger (their embedded - // patch record is the local copy), so name the ledger records - // as what goes, the way the manifest clause names its entries. - clauses.push(format!( - "delete {} vendored artifact(s) and their ledger records", - vendor_scope.len() - )); - } - if !hosted_scope.is_empty() { - clauses.push(format!("unwind {} hosted redirect(s)", hosted_scope.len())); - } else if hosted_leftover_edits > 0 { - clauses.push(format!( - "replay {hosted_leftover_edits} leftover hosted redirect edit(s)" - )); - } - let mut prompt = clauses.join(", and "); - if let Some(first) = prompt.get(..1) { - let capitalized = first.to_uppercase(); - prompt.replace_range(..1, &capitalized); - } - prompt.push('?'); - if !crate::output::confirm(&prompt, true, args.common.yes, args.common.json) { + let prompt = rollback_prompt( + manifest_scope.len(), + vendor_scope.len(), + hosted_scope.len(), + hosted_leftover_edits, + ); + if !crate::ui::confirm(&prompt, true, &args.common) { if !args.common.json && !args.common.silent { println!("Rollback cancelled."); } @@ -1267,10 +1586,12 @@ pub async fn run(args: RollbackArgs) -> i32 { // ── agent leg (in-place restore) ──────────────────────────────────── // The "No patches found in manifest" line is for an unscoped run with // nothing to do anywhere: a hosted-/vendored-only project has work in - // the other legs and is not "no patches". + // the other legs and is not "no patches". A manifest-less project, or + // one whose scope `--ecosystems` filtered out entirely (announced + // above), is not told its manifest is empty either. let selection = InnerSelection::Scope { purls: &manifest_scope, - announce_empty: !scoped && !has_work, + announce_empty: !scoped && !manifest_missing && !has_work && !eco_filtered_everything, }; match rollback_patches_inner( &args.common, @@ -1330,7 +1651,7 @@ pub async fn run(args: RollbackArgs) -> i32 { let msg = format!("failed to persist the hosted redirect ledger: {e}"); if !args.common.json { - eprintln!("Error: {msg}"); + eprintln!("Error: {}", capitalize_first(&msg)); } hosted_leg.failed.push(("ledger".to_string(), msg)); } @@ -1513,6 +1834,24 @@ pub async fn run(args: RollbackArgs) -> i32 { )); } } + // The human path's warning lines: every run warning except the + // ones already said another way — the corrupt-ledger skips + // (printed as errors below), `reinstall_required` (the Note + // below), and the vendored leg's own warnings (printed inline + // as they happened). Hosted replay warnings are printed here. + let mut human_warnings: Vec<(String, String)> = run_warnings + .iter() + .filter(|(code, _)| { + !matches!( + code.as_str(), + "vendor_state_unreadable" + | "redirect_state_unreadable" + | "reinstall_required" + ) + }) + .chain(hosted_leg.warnings.iter()) + .cloned() + .collect(); vendored_leg .warnings .iter() @@ -1523,10 +1862,12 @@ pub async fn run(args: RollbackArgs) -> i32 { // restored but worth a note; `results[].error` carries it too. for r in results.iter().filter(|r| r.success) { if let Some(note) = &r.error { - run_warnings.push(( - "ownership_not_restored".into(), + let warning = ( + "ownership_not_restored".to_string(), format!("{}: {note}", r.package_key), - )); + ); + human_warnings.push(warning.clone()); + run_warnings.push(warning); } } @@ -1556,7 +1897,7 @@ pub async fn run(args: RollbackArgs) -> i32 { if let Some(e) = &manifest_write_failed { if !args.common.json { - eprintln!("Error: failed to update the manifest: {e}"); + eprintln!("Error: Failed to update the manifest: {e}"); } run_warnings.push(( "manifest_write_failed".into(), @@ -1633,52 +1974,19 @@ pub async fn run(args: RollbackArgs) -> i32 { .expect("serializing an in-memory JSON value cannot fail") ); } else if !args.common.silent && !results.is_empty() { - let rolled_back: Vec<_> = results - .iter() - .filter(|r| r.success && !r.files_rolled_back.is_empty()) - .collect(); - let already_original: Vec<_> = results - .iter() - .filter(|r| r.success && all_files_already_original(r)) - .collect(); - let failed: Vec<_> = results.iter().filter(|r| !r.success).collect(); - - if args.common.dry_run { - println!("\nRollback verification complete:"); - // Exclude already-original packages — they are - // reported separately just below, so counting them - // here too would double-report each no-op. - let can_rollback = can_rollback_count(&results); - println!(" {can_rollback} package(s) can be rolled back"); - if !already_original.is_empty() { - println!( - " {} package(s) already in original state", - already_original.len() - ); - } - if !failed.is_empty() { - println!(" {} package(s) cannot be rolled back", failed.len()); - } + let cwd_abs = std::fs::canonicalize(&cwd).unwrap_or_else(|_| cwd.clone()); + let lines = if args.common.dry_run { + format_rollback_dry_run_counts(&results, &cwd_abs) } else { - if !rolled_back.is_empty() || !already_original.is_empty() { - println!("\nRolled back packages:"); - for result in &rolled_back { - println!(" {}", result.package_key); - } - for result in &already_original { - println!(" {} (already original)", result.package_key); - } - } - if !failed.is_empty() { - println!("\nFailed to rollback:"); - for result in &failed { - println!( - " {}: {}", - result.package_key, - result.error.as_deref().unwrap_or("unknown error") - ); - } - } + format_rollback_results(&results, &cwd_abs) + }; + for line in lines { + println!("{line}"); + } + // The report above is on stdout; the run exits 1, so the + // error stream says so too. + if results.iter().any(|r| !r.success) { + eprintln!("{}", format_rollback_failed(args.common.dry_run)); } if args.common.verbose { @@ -1709,70 +2017,104 @@ pub async fn run(args: RollbackArgs) -> i32 { } } - // Error-class notices print even under --silent ("errors only, - // never nothing"): drift-keeps and corrupt-ledger skips drive - // exit 1, so a silent run must still say why. - if !args.common.json { - for (key, reason) in &vendored_leg.kept { - eprintln!("Kept vendored state for {key}: {reason}"); + // Apply's unmatched warning, rollback-side — informational only + // (the run still exits 0; see `RollbackOutcome`), so --silent + // mutes it like every other non-error notice. Printed before + // the manifest-removal list that names the same purls. + if !args.common.json && !args.common.silent && !not_installed.is_empty() { + // Separate it from the per-package report only when one + // was printed above. + if !results.is_empty() { + eprintln!(); } - for (code, detail) in &run_warnings { - if code == "vendor_state_unreadable" || code == "redirect_state_unreadable" { - eprintln!("Error ({code}): {detail}"); - } else if code == "ownership_not_restored" && !args.common.silent { - eprintln!("Warning ({code}): {detail}"); - } + eprintln!( + "Warning: {} had no matching installed package:", + plural(not_installed.len(), "manifest patch", "manifest patches") + ); + for purl in ¬_installed { + eprintln!(" - {purl}"); } } + if !args.common.json && !args.common.silent { if args.common.dry_run { if cleanup_allowed && !removed.is_empty() { - println!("\nWould remove {} patch(es) from manifest:", removed.len()); + println!( + "\nWould remove {} from manifest:", + plural(removed.len(), "patch", "patches") + ); for purl in &removed { println!(" - {purl}"); } } } else if !removed.is_empty() { - println!("\nRemoved {} patch(es) from manifest:", removed.len()); + println!( + "\nRemoved {} from manifest:", + plural(removed.len(), "patch", "patches") + ); for purl in &removed { println!(" - {purl}"); } } else if args.preserve_state && has_work { println!( - "\nManifest entries and vendored artifacts preserved \ - (--preserve-state); re-apply with `socket-patch apply` or \ - `socket-patch vendor`." + "\n{}", + format_preserved_note(manifest_scope.len(), vendored_leg.preserved.len()) ); } if gc_bytes_freed > 0 { - println!( - "{} {} bytes of unused blobs/archives", - if args.common.dry_run { - "Would free" - } else { - "Freed" - }, - gc_bytes_freed - ); + println!("\n{}", format_gc_freed(gc_bytes_freed, args.common.dry_run)); } - if unwired_any { + // Only packages that are NOT also handled in place keep + // patched bytes. A successful in-place result that verified + // files leaves the installed tree original: restored now, + // restorable (dry run), or already original (never + // patched). None of those has anything left to reinstall. + let restored = handled_in_place(&results); + let base_of = |key: &str| { + vendor_entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, e)| e.base_purl.clone()) + }; + let still_patched = vendored_leg + .reverted + .iter() + .chain(vendored_leg.preserved.iter()) + .chain(hosted_leg.reverted.iter()) + .filter(|key| { + !restored.contains(strip_purl_qualifiers(key)) + && !base_of(key).is_some_and(|b| restored.contains(b.as_str())) + }) + .count(); + if still_patched > 0 { println!( - "\nNote: unwired packages keep their patched bytes in installed \ - trees until the next package-manager install." + "\n{}", + format_reinstall_note(still_patched, args.common.dry_run) ); } } - // Apply's unmatched warning, rollback-side — informational only - // (the run still exits 0; see `RollbackOutcome`), so --silent - // mutes it like every other non-error notice. - if !args.common.json && !args.common.silent && !not_installed.is_empty() { - eprintln!( - "\nWarning: {} manifest patch(es) had no matching installed package:", - not_installed.len() - ); - for purl in ¬_installed { - eprintln!(" - {purl}"); + // Non-error run warnings (out-of-scope copies restored, cleanup + // failures, hosted replay notes, ...): the JSON envelope's + // `warnings[]`, one stderr line each here. + if !args.common.json && !args.common.silent { + for (code, detail) in &human_warnings { + eprintln!("Warning ({code}): {detail}"); + } + } + + // Error-class notices print even under --silent ("errors only, + // never nothing"): drift-keeps and corrupt-ledger skips drive + // exit 1, so a silent run must still say why. Printed after + // the summary blocks so they are the last thing on screen. + if !args.common.json { + for (key, reason) in &vendored_leg.kept { + eprintln!("Error: Kept vendored state for {key}: {reason}"); + } + for (code, detail) in &run_warnings { + if code == "vendor_state_unreadable" || code == "redirect_state_unreadable" { + eprintln!("Error ({code}): {}", capitalize_first(detail)); + } } } @@ -1818,7 +2160,7 @@ pub async fn run(args: RollbackArgs) -> i32 { } else { // Errors print even under --silent ("errors only", never // "nothing"): exit 1 with no message would be undiagnosable. - eprintln!("Error: {e}"); + eprintln!("Error: {}", capitalize_first(&e)); } 1 } @@ -2102,7 +2444,7 @@ pub(crate) async fn rollback_patches_inner( // installed) don't gate either: there is nothing on disk to // restore, so no before-blob is ever read for them. They used to // be gated "fail-closed", which hard-failed the run (exit 1, - // `Cannot rollback: ... Before blob not found`, `path: ""`) over + // `Cannot roll back: ... Before blob not found`, `path: ""`) over // an entry that had nothing to roll back — the same entry apply // reports as a benign `package_not_installed` skip. They surface // via `not_installed` below instead. @@ -2221,8 +2563,8 @@ pub(crate) async fn rollback_patches_inner( // the synthesized per-package failures below. if !common.json { eprintln!( - "Error: {} blob(s) are missing and --offline mode is enabled.", - missing_blobs.len() + "Error: {} missing and --offline is set.", + plural(missing_blobs.len(), "blob is", "blobs are") ); eprintln!("Run \"socket-patch repair\" to download missing blobs."); } @@ -2247,9 +2589,12 @@ pub(crate) async fn rollback_patches_inner( }); } - if !common.silent && !common.json { - println!("Downloading {} missing blob(s)...", missing_blobs.len()); - } + // Transient progress on stderr; the result line replaces it. + let mut status = StatusLine::stderr(common.json, common.silent); + status.set(format!( + "Downloading {}...", + plural(missing_blobs.len(), "missing blob", "missing blobs") + )); let built_client; let client = match api_client { @@ -2263,9 +2608,7 @@ pub(crate) async fn rollback_patches_inner( }; let fetch_result = fetch_blobs_by_hash(&missing_blobs, &blobs_path, client, None).await; - if !common.silent && !common.json { - println!("{}", format_fetch_result(&fetch_result)); - } + status.finish_with(format_fetch_result(&fetch_result)); // Re-check ONLY the needed-missing set the download targeted (built // from the local-go-excluded, installed-only gate above) — never the @@ -2283,8 +2626,8 @@ pub(crate) async fn rollback_patches_inner( // offline bail above (and same `--json` carrier). if !common.json { eprintln!( - "{} blob(s) could not be downloaded. Cannot rollback.", - still_missing.len() + "Error: {} not be downloaded; cannot roll back.", + plural(still_missing.len(), "blob could", "blobs could") ); } // Per-hash download outcomes; a hash the fetch never reported @@ -2327,9 +2670,10 @@ pub(crate) async fn rollback_patches_inner( } if all_packages.is_empty() && undiscovered_redirects.is_empty() { - if !common.silent && !common.json { - println!("No packages found that match patches to rollback"); - } + // Nothing printed here: every caller reports `not_installed` itself + // (rollback's "had no matching installed package" warning, + // remove's crawler-miss warning). + // // `success: true` — per-package semantics for the `remove` // delegation. The CLI boundary layers apply's "nothing matched at // all" exit-1 on top via `not_installed`. @@ -2366,14 +2710,18 @@ pub(crate) async fn rollback_patches_inner( if !result.success { has_errors = true; - // Errors print even under --silent ("errors only", never - // "nothing"): with the summary muted, this line is the - // silent run's only failure diagnostic. - if !common.json { + // Under --silent (the summary muted) this line is the run's + // only failure diagnostic ("errors only", never "nothing"). + // Otherwise the failure is reported once, in the summary's + // "Failed to roll back:" section plus a closing stderr error + // (or by `remove`). + if common.silent && !common.json { eprintln!( - "Failed to rollback {}: {}", - purl, - result.error.as_deref().unwrap_or("unknown error") + "{}", + format_rollback_failure( + purl, + result.error.as_deref().unwrap_or("unknown error") + ) ); } } @@ -2393,13 +2741,14 @@ pub(crate) async fn rollback_patches_inner( }; if !result.success { has_errors = true; - // Errors print even under --silent — same contract as the - // in-place loop above. - if !common.json { + // Same contract as the in-place loop above. + if common.silent && !common.json { eprintln!( - "Failed to rollback {}: {}", - purl, - result.error.as_deref().unwrap_or("unknown error") + "{}", + format_rollback_failure( + purl, + result.error.as_deref().unwrap_or("unknown error") + ) ); } } @@ -2681,42 +3030,71 @@ mod tests { assert!(!all_files_already_original(&r)); } + /// `make_result` with a distinct package key (the tally is per package). + fn keyed(key: &str, r: RollbackResult) -> RollbackResult { + RollbackResult { + package_key: key.to_string(), + ..r + } + } + /// Regression: the dry-run "can be rolled back" count must exclude /// already-original packages, which are reported on their own line. /// Otherwise each no-op is double-counted (once as can-rollback, once /// as already-original). #[test] - fn can_rollback_count_excludes_already_original() { + fn can_roll_back_tally_excludes_already_original() { let results = vec![ // Genuinely needs restoring. - make_result(&[VerifyRollbackStatus::Ready], &[]), + keyed( + "pkg:npm/a@1", + make_result(&[VerifyRollbackStatus::Ready], &[]), + ), // No-op: already at beforeHash. - make_result(&[VerifyRollbackStatus::AlreadyOriginal], &[]), + keyed( + "pkg:npm/b@1", + make_result(&[VerifyRollbackStatus::AlreadyOriginal], &[]), + ), // Mixed → still needs restoring. - make_result( - &[ - VerifyRollbackStatus::Ready, - VerifyRollbackStatus::AlreadyOriginal, - ], - &[], + keyed( + "pkg:npm/c@1", + make_result( + &[ + VerifyRollbackStatus::Ready, + VerifyRollbackStatus::AlreadyOriginal, + ], + &[], + ), ), // Failed (e.g. HashMismatch) → not counted as rollbackable. - make_result(&[VerifyRollbackStatus::HashMismatch], &[]), + keyed( + "pkg:npm/d@1", + make_result(&[VerifyRollbackStatus::HashMismatch], &[]), + ), ]; - // 2 successful non-no-op packages; the already-original one is - // excluded and the failed one was never successful. - assert_eq!(can_rollback_count(&results), 2); + let t = tally_rollback_results(&results); + assert_eq!(t.can_roll_back, 2); + assert_eq!(t.already, 1); + assert_eq!(t.failed, 1); } /// A summary made entirely of no-ops reports zero rollbackable - /// packages (and `saturating_sub` keeps it from underflowing). + /// packages. #[test] - fn can_rollback_count_all_already_original_is_zero() { + fn can_roll_back_tally_all_already_original_is_zero() { let results = vec![ - make_result(&[VerifyRollbackStatus::AlreadyOriginal], &[]), - make_result(&[VerifyRollbackStatus::AlreadyOriginal], &[]), + keyed( + "pkg:npm/a@1", + make_result(&[VerifyRollbackStatus::AlreadyOriginal], &[]), + ), + keyed( + "pkg:npm/b@1", + make_result(&[VerifyRollbackStatus::AlreadyOriginal], &[]), + ), ]; - assert_eq!(can_rollback_count(&results), 0); + let t = tally_rollback_results(&results); + assert_eq!(t.can_roll_back, 0); + assert_eq!(t.already, 2); } // --- Missing-blob gate consistency ---------------------------------- @@ -2819,7 +3197,7 @@ mod tests { assert!(r.files_rolled_back.is_empty()); assert_eq!( r.error.as_deref(), - Some("Cannot rollback: a.js - gone: missing_a"), + Some("Cannot roll back: a.js - gone: missing_a"), "error mirrors the engine's first-blocking-file shape" ); assert_eq!(r.files_verified.len(), 1); @@ -3526,7 +3904,7 @@ mod tests { /// Regression (rollback ordering): a manifest entry whose package is /// NOT installed must never enter the before-blob plan. Before the gate /// reorder, its missing before-blob hard-failed the whole offline run - /// (exit 1, `Cannot rollback: ... Before blob not found`, `path: ""`) + /// (exit 1, `Cannot roll back: ... Before blob not found`, `path: ""`) /// even though there was nothing on disk to roll back. Through the /// `remove`-facing delegation this is a benign no-op: success with zero /// results, exactly as when the blob IS present — so `remove` can drop @@ -4217,4 +4595,357 @@ mod tests { state.entries.keys().collect::>() ); } + + // ── human output formatters ────────────────────────────────────────── + + fn rb(purl: &str, path: &str, status: VerifyRollbackStatus, rolled: bool) -> RollbackResult { + RollbackResult { + package_key: purl.to_string(), + package_path: path.to_string(), + success: true, + files_verified: vec![VerifyRollbackResult { + file: "index.js".to_string(), + status, + message: None, + current_hash: None, + expected_hash: None, + target_hash: None, + }], + files_rolled_back: if rolled { + vec!["index.js".to_string()] + } else { + Vec::new() + }, + error: None, + sidecar: None, + } + } + + #[test] + fn join_clauses_is_an_english_list() { + let c = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(join_clauses(&[]), ""); + assert_eq!(join_clauses(&c(&["a"])), "a"); + assert_eq!(join_clauses(&c(&["a", "b"])), "a and b"); + assert_eq!(join_clauses(&c(&["a", "b", "c"])), "a, b, and c"); + assert_eq!(as_question(""), ""); + assert_eq!(as_question("roll back"), "Roll back?"); + assert_eq!(as_question("éclair"), "Éclair?"); + } + + #[test] + fn rollback_prompt_singular_plural_and_clauses() { + assert_eq!( + rollback_prompt(1, 0, 0, 0), + "Roll back 1 patch and remove it from the local manifest?" + ); + assert_eq!( + rollback_prompt(2, 0, 0, 0), + "Roll back 2 patches and remove them from the local manifest?" + ); + // Never "..., and unwind" after an inner "and". + assert_eq!( + rollback_prompt(1, 0, 1, 0), + "Roll back 1 patch, remove it from the local manifest, and unwind 1 hosted \ + redirect?" + ); + assert_eq!(rollback_prompt(0, 0, 3, 0), "Unwind 3 hosted redirects?"); + assert_eq!( + rollback_prompt(0, 0, 0, 1), + "Replay 1 leftover hosted redirect edit?" + ); + assert_eq!( + rollback_prompt(0, 1, 0, 0), + "Delete 1 vendored artifact and its ledger record?" + ); + assert_eq!( + rollback_prompt(0, 2, 0, 0), + "Delete 2 vendored artifacts and their ledger records?" + ); + } + + #[test] + fn copy_path_outside_cwd_stays_absolute() { + assert_eq!( + display_copy_path("/elsewhere/node_modules/x", Path::new("/p")), + "/elsewhere/node_modules/x" + ); + assert_eq!(display_copy_path("/p", Path::new("/p")), "/p"); + assert_eq!( + display_copy_path("/p/node_modules/é", Path::new("/p")), + "node_modules/é" + ); + } + + #[test] + fn capitalize_first_is_char_safe() { + assert_eq!(capitalize_first(""), ""); + 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("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. + assert_eq!( + capitalize_first("pkg:npm/a@1 matches only hosted redirect records"), + "pkg:npm/a@1 matches only hosted redirect records" + ); + assert_eq!( + capitalize_first("a1b2c3d4-0000-4000-8000-000000000000 matches nothing"), + "a1b2c3d4-0000-4000-8000-000000000000 matches nothing" + ); + assert_eq!(capitalize_first("abcdef matches"), "Abcdef matches"); + assert_eq!(capitalize_first(".socket/x is bad"), ".socket/x is bad"); + } + + #[test] + fn rollback_failure_line() { + assert_eq!( + format_rollback_failure("pkg:npm/a@1", "boom"), + "Error: Failed to roll back pkg:npm/a@1: boom" + ); + } + + #[test] + fn rollback_failed_closing_line() { + assert_eq!( + format_rollback_failed(false), + "Error: Some patches could not be rolled back." + ); + assert_eq!( + format_rollback_failed(true), + "Error: Some patches cannot be rolled back." + ); + } + + #[test] + fn dry_run_counts_block() { + let p = Path::new("/p"); + let results = vec![ + rb("pkg:npm/a@1", "/p/a", VerifyRollbackStatus::Ready, false), + rb( + "pkg:npm/b@1", + "/p/b", + VerifyRollbackStatus::AlreadyOriginal, + false, + ), + ]; + assert_eq!( + format_rollback_dry_run_counts(&results, p), + vec![ + "", + "Rollback verification complete:", + " 1 package can be rolled back", + " 1 package already in original state", + ] + ); + // Failures carry their reason: a dry run has no other report. + let mut failed = rb( + "pkg:npm/c@1", + "/p/c", + VerifyRollbackStatus::HashMismatch, + false, + ); + failed.success = false; + failed.error = Some("modified after patching".into()); + let mut other = failed.clone(); + other.package_key = "pkg:npm/d@1".into(); + assert_eq!( + format_rollback_dry_run_counts(&[failed, other], p), + vec![ + "", + "Rollback verification complete:", + " 0 packages can be rolled back", + " 2 packages cannot be rolled back", + "", + "Failed to roll back:", + " pkg:npm/c@1: modified after patching", + " pkg:npm/d@1: modified after patching", + ] + ); + assert_eq!(format_rollback_dry_run_counts(&[], p).len(), 3); + } + + #[test] + fn dry_run_counts_each_package_once_across_copies() { + // Two installed copies of one purl: "1 package", matching apply. + let results = vec![ + rb( + "pkg:npm/nuxt@4.5.0", + "/p/node_modules/nuxt", + VerifyRollbackStatus::Ready, + false, + ), + rb( + "pkg:npm/nuxt@4.5.0", + "/p/node_modules/vite/node_modules/nuxt", + VerifyRollbackStatus::Ready, + false, + ), + ]; + assert_eq!( + format_rollback_dry_run_counts(&results, Path::new("/p"))[2], + " 1 package can be rolled back" + ); + assert_eq!( + tally_rollback_results(&results), + RollbackTally { + can_roll_back: 1, + ..RollbackTally::default() + } + ); + } + + #[test] + fn handled_in_place_covers_restored_restorable_and_original() { + let restored = rb("pkg:npm/a@1", "/p/a", VerifyRollbackStatus::Ready, true); + let dry = rb("pkg:npm/b@1", "/p/b", VerifyRollbackStatus::Ready, false); + let orig = rb( + "pkg:npm/c@1", + "/p/c", + VerifyRollbackStatus::AlreadyOriginal, + false, + ); + let mut failed = rb( + "pkg:npm/d@1", + "/p/d", + VerifyRollbackStatus::HashMismatch, + false, + ); + failed.success = false; + let mut empty = rb("pkg:npm/e@1", "/p/e", VerifyRollbackStatus::Ready, false); + empty.files_verified.clear(); + let all = [restored, dry, orig, failed, empty]; + let got = handled_in_place(&all); + let mut got: Vec<&str> = got.into_iter().collect(); + got.sort(); + assert_eq!(got, vec!["pkg:npm/a@1", "pkg:npm/b@1", "pkg:npm/c@1"]); + } + + #[test] + fn tally_buckets_per_package() { + let done = rb("pkg:npm/a@1", "/p/a1", VerifyRollbackStatus::Ready, true); + let done_twin = rb( + "pkg:npm/a@1", + "/p/a2", + VerifyRollbackStatus::AlreadyOriginal, + false, + ); + let orig = rb( + "pkg:npm/b@1", + "/p/b", + VerifyRollbackStatus::AlreadyOriginal, + false, + ); + let ok_copy = rb("pkg:npm/c@1", "/p/c1", VerifyRollbackStatus::Ready, true); + let mut bad_copy = rb( + "pkg:npm/c@1", + "/p/c2", + VerifyRollbackStatus::HashMismatch, + false, + ); + bad_copy.success = false; + assert_eq!( + tally_rollback_results(&[done, done_twin, orig, ok_copy, bad_copy]), + RollbackTally { + rolled_back: 1, + can_roll_back: 1, + already: 1, + failed: 1, + } + ); + assert_eq!(tally_rollback_results(&[]), RollbackTally::default()); + } + + #[test] + fn results_block_names_duplicate_copies_and_failures_once() { + let results = vec![ + rb( + "pkg:npm/nuxt@4.5.0", + "/p/node_modules/nuxt", + VerifyRollbackStatus::Ready, + true, + ), + rb( + "pkg:npm/nuxt@4.5.0", + "/p/node_modules/vite/node_modules/nuxt", + VerifyRollbackStatus::Ready, + true, + ), + rb( + "pkg:npm/ok@1", + "/p/node_modules/ok", + VerifyRollbackStatus::AlreadyOriginal, + false, + ), + ]; + assert_eq!( + format_rollback_results(&results, Path::new("/p")), + vec![ + "", + "Rolled back packages:", + " pkg:npm/nuxt@4.5.0 (node_modules/nuxt)", + " pkg:npm/nuxt@4.5.0 (node_modules/vite/node_modules/nuxt)", + " pkg:npm/ok@1 (already original)", + ] + ); + let mut failed = rb( + "pkg:npm/x@1", + "/p/x", + VerifyRollbackStatus::HashMismatch, + false, + ); + failed.success = false; + failed.error = Some("modified".into()); + assert_eq!( + format_rollback_results(&[failed], Path::new("/p")), + vec!["", "Failed to roll back:", " pkg:npm/x@1: modified"] + ); + assert!(format_rollback_results(&[], Path::new("/p")).is_empty()); + } + + #[test] + fn preserved_note_names_only_what_was_kept() { + assert_eq!( + format_preserved_note(1, 0), + "Manifest entry preserved (--preserve-state); re-apply with `socket-patch apply`." + ); + assert_eq!( + format_preserved_note(1, 1), + "Manifest entry and vendored artifact preserved (--preserve-state); re-apply \ + with `socket-patch apply` or `socket-patch vendor`." + ); + assert_eq!( + format_preserved_note(2, 2), + "Manifest entries and vendored artifacts preserved (--preserve-state); re-apply \ + with `socket-patch apply` or `socket-patch vendor`." + ); + } + + #[test] + fn gc_freed_uses_human_bytes() { + assert_eq!( + format_gc_freed(336161, false), + "Freed 328.28 KB of unused blobs and archives" + ); + assert_eq!( + format_gc_freed(12, true), + "Would free 12 B of unused blobs and archives" + ); + } + + #[test] + fn reinstall_note_tense_and_number() { + assert_eq!( + format_reinstall_note(1, false), + "Note: 1 unwired package keeps its patched bytes in installed trees until the \ + next package-manager install." + ); + assert_eq!( + format_reinstall_note(2, true), + "Note: 2 unwired packages would keep their patched bytes in installed trees \ + until the next package-manager install." + ); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 34566c7a..fe01fb1c 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -266,13 +266,15 @@ async fn vendored_purls_from_artifacts(common: &GlobalArgs) -> Vec { /// /// Best-effort and read-only: a detail-fetch failure or an unresolvable /// installed path just skips the annotation — it never blocks the flow and -/// writes nothing. -pub(super) async fn preverify_vendor_baselines( +/// writes nothing. One API round-trip per uncached patch, so progress +/// shows on `status`. +pub(super) async fn preverify_vendor_baselines( api_client: &socket_patch_core::api::client::ApiClient, selected: &[PatchSearchResult], crawled: &[socket_patch_core::crawlers::types::CrawledPackage], lockfile_only: &HashSet, vendor: Option<&HashMap>, + status: &mut crate::ui::StatusLine, ) -> (HashSet, HashMap) { use socket_patch_core::manifest::schema::PatchFileInfo; use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; @@ -281,7 +283,12 @@ pub(super) async fn preverify_vendor_baselines( let mut mismatched: HashSet = HashSet::new(); let mut views: HashMap = HashMap::new(); - for patch in selected { + for (i, patch) in selected.iter().enumerate() { + status.set(format!( + "Checking installed files against patch baselines... ({}/{})", + i + 1, + selected.len() + )); // API purls come percent-encoded, crawler purls literal — purl_eq // bridges the two spellings. let base = strip_purl_qualifiers(&patch.purl); @@ -336,6 +343,7 @@ pub(super) async fn preverify_vendor_baselines( } } } + status.finish(); (mismatched, views) } @@ -544,27 +552,63 @@ fn candidate_supersedes(candidate: &BatchPatchInfo, applied: &BatchPatchInfo) -> matches!((cand_date, applied_date), (Some(c), Some(a)) if c > a) } -/// Collect the deduplicated CVE and GHSA identifiers across every patch of -/// a package, for the scan table's VULNERABILITIES column. CVEs are listed -/// before GHSAs and each group is sorted, so the rendered output is stable — -/// the per-patch ID lists and set-based dedup are otherwise nondeterministic -/// in order. Pure / no I/O so it's unit-testable. -pub(super) fn collect_vuln_ids(pkg: &BatchPackagePatches) -> Vec { - let mut cves: HashSet = HashSet::new(); - let mut ghsas: HashSet = HashSet::new(); +/// The scan table's VULNERABILITIES data for one package, built from the +/// batch results (see [`collect_vuln_ids`]). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(super) struct VulnIds { + /// Ids to show first: every CVE, then the GHSAs that only ever appear + /// on patches listing no CVE (those cannot be aliases). Each group is + /// sorted. + pub primary: Vec, + /// Every CVE and GHSA id (CVEs first, each group sorted), for `--verbose`. + pub all: Vec, + /// How many distinct vulnerabilities the ids stand for, for `(+N)`. + pub count: usize, +} + +/// Collect a package's vulnerability ids across all its patches, for the +/// scan table's VULNERABILITIES column. The output is sorted and deduped, +/// so the rendered table is stable (the per-patch lists and set-based +/// dedup are otherwise nondeterministic in order). Pure / no I/O so it's +/// unit-testable. +/// +/// A GHSA id is usually an alias of a CVE listed beside it, but the batch +/// endpoint gives the two lists without their pairing, so listing both made +/// `(+N)` count most vulnerabilities twice. Each vulnerability has at least +/// one of the two ids, so the count is the larger of the distinct CVEs and +/// the distinct GHSAs. That is exact when every GHSA has at most one CVE, +/// and never counts an alias twice. +pub(super) fn collect_vuln_ids(pkg: &BatchPackagePatches) -> VulnIds { + let mut cves: HashSet<&str> = HashSet::new(); + let mut ghsas: HashSet<&str> = HashSet::new(); + let mut ghsa_only: HashSet<&str> = HashSet::new(); + let mut ghsa_beside_cve: HashSet<&str> = HashSet::new(); for patch in &pkg.patches { - for cve in &patch.cve_ids { - cves.insert(cve.clone()); - } - for ghsa in &patch.ghsa_ids { - ghsas.insert(ghsa.clone()); - } + cves.extend(patch.cve_ids.iter().map(String::as_str)); + ghsas.extend(patch.ghsa_ids.iter().map(String::as_str)); + let bucket = if patch.cve_ids.is_empty() { + &mut ghsa_only + } else { + &mut ghsa_beside_cve + }; + bucket.extend(patch.ghsa_ids.iter().map(String::as_str)); + } + // A GHSA listed beside a CVE on any patch may be its alias. + ghsa_only.retain(|g| !ghsa_beside_cve.contains(g)); + let sorted = |set: &HashSet<&str>| { + let mut v: Vec = set.iter().map(|s| (*s).to_string()).collect(); + v.sort(); + v + }; + let cves = sorted(&cves); + let ghsas = sorted(&ghsas); + let primary: Vec = cves.iter().cloned().chain(sorted(&ghsa_only)).collect(); + let count = cves.len().max(ghsas.len()).max(primary.len()); + VulnIds { + primary, + all: cves.into_iter().chain(ghsas).collect(), + count, } - let mut cves: Vec = cves.into_iter().collect(); - cves.sort(); - let mut ghsas: Vec = ghsas.into_iter().collect(); - ghsas.sort(); - cves.into_iter().chain(ghsas).collect() } /// Severity ordering for the scan table's SEVERITY column: lower = worse. @@ -606,7 +650,7 @@ mod tests { fn severity_order_moderate_is_medium_tier() { // Regression: GHSA emits `moderate` for the medium tier, and scan // passes raw API severities straight through. get.rs - // `severity_rank`, output.rs `format_severity`, and core's + // `severity_rank`, `ui::severity`, and core's // `get_severity_order` all map it to medium; ranking it 4 here // (= unknown, below `low`) made the table's max-severity column // show `low` for a package whose worst vuln is moderate. @@ -1270,10 +1314,27 @@ mod tests { } } + fn strs(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_string()).collect() + } + + fn patch_with(uuid: &str, cves: &[&str], ghsas: &[&str]) -> BatchPatchInfo { + BatchPatchInfo { + uuid: uuid.to_string(), + purl: "pkg:npm/foo@1.0".to_string(), + tier: "free".to_string(), + cve_ids: strs(cves), + ghsa_ids: strs(ghsas), + severity: None, + title: String::new(), + published_at: None, + } + } + #[test] fn collect_vuln_ids_empty_when_no_vulns() { let pkg = batch_with_vulns("pkg:npm/foo@1.0", &[], &[]); - assert!(collect_vuln_ids(&pkg).is_empty()); + assert_eq!(collect_vuln_ids(&pkg), VulnIds::default()); } #[test] @@ -1283,17 +1344,96 @@ mod tests { let pkg = batch_with_vulns( "pkg:npm/foo@1.0", &["CVE-2024-2", "CVE-2024-1"], - &["GHSA-zzzz-zzzz-zzzz", "GHSA-aaaa-aaaa-aaaa"], + &[ + "GHSA-zzzz-zzzz-zzzz", + "GHSA-aaaa-aaaa-aaaa", + "GHSA-mmmm-mmmm-mmmm", + ], ); + let ids = collect_vuln_ids(&pkg); assert_eq!( - collect_vuln_ids(&pkg), - vec![ - "CVE-2024-1".to_string(), - "CVE-2024-2".to_string(), - "GHSA-aaaa-aaaa-aaaa".to_string(), - "GHSA-zzzz-zzzz-zzzz".to_string(), - ], + ids.all, + strs(&[ + "CVE-2024-1", + "CVE-2024-2", + "GHSA-aaaa-aaaa-aaaa", + "GHSA-mmmm-mmmm-mmmm", + "GHSA-zzzz-zzzz-zzzz", + ]), ); + // The GHSAs sit beside CVEs, so none is shown first; there are at + // least three vulnerabilities (one GHSA has no CVE of its own). + assert_eq!(ids.primary, strs(&["CVE-2024-1", "CVE-2024-2"])); + assert_eq!(ids.count, 3); + } + + #[test] + fn collect_vuln_ids_drops_ghsa_aliases_of_listed_cves() { + // minimist: one CVE and its GHSA alias are one vulnerability. + let pkg = batch_with_vulns( + "pkg:npm/minimist@1.2.5", + &["CVE-2021-44906"], + &["GHSA-xvch-5gv4-984h"], + ); + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.primary, strs(&["CVE-2021-44906"])); + assert_eq!(ids.all, strs(&["CVE-2021-44906", "GHSA-xvch-5gv4-984h"])); + assert_eq!(ids.count, 1); + // A GHSA-only advisory has no CVE to alias: it stays. + let pkg = batch_with_vulns("pkg:npm/x@1", &[], &["GHSA-r4q5-vmmm-2653"]); + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.primary, strs(&["GHSA-r4q5-vmmm-2653"])); + assert_eq!(ids.count, 1); + } + + #[test] + fn collect_vuln_ids_one_cve_plus_alias_plus_ghsa_only() { + // CVE-1 (alias GHSA-a) and a GHSA-only GHSA-b on one patch: two + // vulnerabilities, not three. + let pkg = batch_with_vulns("pkg:npm/foo@1.0", &["CVE-1"], &["GHSA-a", "GHSA-b"]); + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.count, 2); + assert_eq!(ids.primary, strs(&["CVE-1"])); + } + + #[test] + fn collect_vuln_ids_two_cves_one_ghsa_alias_plus_ghsa_only() { + // GHSA-a aliases both CVEs, GHSA-b has none. The batch data cannot + // tell this from two CVE/GHSA pairs, so the count is the lower + // bound (2) and every id is still listed under --verbose. + let pkg = batch_with_vulns( + "pkg:npm/foo@1.0", + &["CVE-1", "CVE-2"], + &["GHSA-a", "GHSA-b"], + ); + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.count, 2); + assert_eq!(ids.all, strs(&["CVE-1", "CVE-2", "GHSA-a", "GHSA-b"])); + // On its own patch, the GHSA-only advisory is known and shown. + let pkg = BatchPackagePatches { + purl: "pkg:npm/foo@1.0".to_string(), + patches: vec![ + patch_with("u1", &["CVE-1", "CVE-2"], &["GHSA-a"]), + patch_with("u2", &[], &["GHSA-b"]), + ], + }; + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.primary, strs(&["CVE-1", "CVE-2", "GHSA-b"])); + assert_eq!(ids.count, 3); + } + + #[test] + fn collect_vuln_ids_ghsa_beside_a_cve_elsewhere_is_not_ghsa_only() { + let pkg = BatchPackagePatches { + purl: "pkg:npm/foo@1.0".to_string(), + patches: vec![ + patch_with("u1", &["CVE-1"], &["GHSA-a"]), + patch_with("u2", &[], &["GHSA-a"]), + ], + }; + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.primary, strs(&["CVE-1"])); + assert_eq!(ids.count, 1); } // ---- unsupported_layout_warnings ----------------------------------- @@ -1508,8 +1648,28 @@ mod tests { let lockfile_only: HashSet = std::iter::once("pkg:npm/@scope/lockonly@1.0.0".to_string()).collect(); - let (mismatched, views) = - preverify_vendor_baselines(&client, &selected, &crawled, &lockfile_only, None).await; + // A live status line: every step is shown, and the line is gone + // once the check returns (nothing left over for the preview). + let mut status = crate::ui::StatusLine::new(Vec::new(), true, true, 80); + let (mismatched, views) = preverify_vendor_baselines( + &client, + &selected, + &crawled, + &lockfile_only, + None, + &mut status, + ) + .await; + let out = status.into_inner(); + let raw = String::from_utf8_lossy(&out); + assert!( + raw.contains("Checking installed files against patch baselines... (2/2)"), + "{raw:?}" + ); + assert!( + crate::ui::test_support::render(&out).is_empty(), + "the status line must be cleared: {raw:?}" + ); assert!(mismatched.is_empty()); assert!( mock.received_requests().await.unwrap().is_empty(), @@ -1563,8 +1723,15 @@ mod tests { let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; let selected = vec![search_result("u3", "pkg:npm/newfile@1.0.0")]; - let (mismatched, views) = - preverify_vendor_baselines(&client, &selected, &crawled, &HashSet::new(), None).await; + let (mismatched, views) = preverify_vendor_baselines( + &client, + &selected, + &crawled, + &HashSet::new(), + None, + &mut crate::ui::StatusLine::new(Vec::new(), false, false, 80), + ) + .await; assert!( mismatched.is_empty(), "a new-file-only patch never annotates a baseline mismatch" @@ -1592,8 +1759,15 @@ mod tests { let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; let selected = vec![search_result("u404", "pkg:npm/newfile@1.0.0")]; - let (mismatched, views) = - preverify_vendor_baselines(&client, &selected, &crawled, &HashSet::new(), None).await; + let (mismatched, views) = preverify_vendor_baselines( + &client, + &selected, + &crawled, + &HashSet::new(), + None, + &mut crate::ui::StatusLine::new(Vec::new(), false, false, 80), + ) + .await; assert!(mismatched.is_empty()); assert_eq!( mock.received_requests().await.unwrap().len(), @@ -1632,8 +1806,15 @@ mod tests { let crawled = vec![crawled_pkg("newfile", "pkg:npm/newfile@1.0.0", pkg_dir)]; let selected = vec![search_result("u4", "pkg:npm/newfile@1.0.0")]; - let (mismatched, views) = - preverify_vendor_baselines(&client, &selected, &crawled, &HashSet::new(), None).await; + let (mismatched, views) = preverify_vendor_baselines( + &client, + &selected, + &crawled, + &HashSet::new(), + None, + &mut crate::ui::StatusLine::new(Vec::new(), false, false, 80), + ) + .await; assert_eq!( mismatched, std::iter::once("u4".to_string()).collect::>(), @@ -1711,6 +1892,7 @@ mod tests { &crawled, &HashSet::new(), Some(&ledger), + &mut crate::ui::StatusLine::new(Vec::new(), false, false, 80), ) .await; assert_eq!( @@ -1739,6 +1921,7 @@ mod tests { &crawled, &HashSet::new(), Some(&ledger), + &mut crate::ui::StatusLine::new(Vec::new(), false, false, 80), ) .await; assert!(mismatched.is_empty()); @@ -1779,9 +1962,9 @@ mod tests { }, ], }; - assert_eq!( - collect_vuln_ids(&pkg), - vec!["CVE-2024-1".to_string(), "GHSA-aaaa-aaaa-aaaa".to_string(),], - ); + // (u2's GHSA is the alias of its one CVE, so it is not counted.) + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.primary, vec!["CVE-2024-1".to_string()]); + assert_eq!(ids.count, 1); } } diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index e069b682..58eee602 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -5,7 +5,6 @@ use socket_patch_core::manifest::cleanup_blobs::CleanupResult; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::PatchManifest; -use socket_patch_core::patch::apply_lock; use socket_patch_core::utils::purl::{canonical_purl, strip_purl_qualifiers}; use socket_patch_core::vendor::VENDOR_STATE_REL; use std::collections::HashSet; @@ -221,7 +220,7 @@ pub(super) async fn run_apply_gc( // halves: flock is per open file description, so a nested acquire in // the vendored half would read as a live holder and silently skip it. let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - let _guard = match apply_lock::acquire(socket_dir, timeout) { + let _guard = match crate::commands::lock_cli::acquire_with_status(socket_dir, timeout) { Ok(g) => g, Err(e) => { return GcSummary { @@ -257,9 +256,8 @@ pub(super) async fn run_apply_gc( write_failure = Some(( "manifest_write_failed", format!( - "pruned {} manifest entr{} but could not update {}: {e}", - prunable.len(), - if prunable.len() == 1 { "y" } else { "ies" }, + "pruned {} but could not update {}: {e}", + count(prunable.len(), "manifest entry", "manifest entries"), manifest_path.display() ), )); @@ -336,20 +334,35 @@ pub(super) async fn gc_json( } } -/// Human-readable line(s) for the vendored-state half of a GC pass (and -/// the lock-skip reason / failed rewrites, when the pass could not run or -/// persist in full); prints nothing when there is nothing to report. -pub(super) fn print_gc_vendored_line(gc: &GcSummary) { - for line in gc_vendored_lines(gc) { - println!("{line}"); +/// `1 manifest entry` / `2 manifest entries` (and friends). +fn count(n: usize, one: &str, many: &str) -> String { + crate::ui::plural(n, one, many) +} + +/// The main human GC line for a pass that pruned or swept something +/// (`None` when it did nothing). `preview`: the `--dry-run` wording. +pub(super) fn format_gc_line(gc: &GcSummary, preview: bool) -> Option { + let files = gc.blobs.blobs_removed + gc.diffs.blobs_removed + gc.packages.blobs_removed; + if gc.pruned.is_empty() && files == 0 { + return None; } + let entries = count(gc.pruned.len(), "manifest entry", "manifest entries"); + let files_s = count(files, "orphan file", "orphan files"); + let bytes = socket_patch_core::manifest::cleanup_blobs::format_bytes(gc.total_bytes()); + Some(if preview { + format!("[dry-run] GC would prune {entries} and remove {files_s} ({bytes}).") + } else { + format!("GC: pruned {entries} and removed {files_s} ({bytes}).") + }) } -/// The lines [`print_gc_vendored_line`] prints, in order — split out so the -/// contract's human GC vocabulary (`GC: skipped (): .`, one -/// `GC: .` per warning, `GC: failed to revert N vendored -/// entr(y|ies): …`) is unit-testable without capturing stdout. -fn gc_vendored_lines(gc: &GcSummary) -> Vec { +/// Human-readable line(s) for the vendored-state half of a GC pass (and +/// the lock-skip reason / failed rewrites, when the pass could not run or +/// persist in full), in order; empty when there is nothing to report. +/// Split out so the contract's human GC vocabulary (`GC: skipped (): +/// .`, one `GC: .` per warning, `GC: failed to revert N +/// vendored entry/entries: …`) is unit-testable without capturing stdout. +pub(super) fn format_gc_vendored_lines(gc: &GcSummary) -> Vec { let mut lines = Vec::new(); if let Some((code, message)) = &gc.skipped { lines.push(format!("GC: skipped ({code}): {message}.")); @@ -359,15 +372,17 @@ fn gc_vendored_lines(gc: &GcSummary) -> Vec { } if !gc.vendored_reverted.is_empty() || gc.vendor_orphan_dirs > 0 { lines.push(format!( - "GC: reverted {} vendored entr{}; swept {} orphan vendor dir{}.", - gc.vendored_reverted.len(), - if gc.vendored_reverted.len() == 1 { - "y" - } else { - "ies" - }, - gc.vendor_orphan_dirs, - if gc.vendor_orphan_dirs == 1 { "" } else { "s" }, + "GC: reverted {}; swept {}.", + count( + gc.vendored_reverted.len(), + "vendored entry", + "vendored entries" + ), + count( + gc.vendor_orphan_dirs, + "orphan vendor dir", + "orphan vendor dirs" + ), )); } // Drift-keeps are the one GC outcome that silently contradicts the @@ -376,15 +391,14 @@ fn gc_vendored_lines(gc: &GcSummary) -> Vec { // other drift-keep caller prints. if !gc.vendored_kept.is_empty() { lines.push(format!( - "GC: kept {} drifted vendored entr{}: lock entries were re-resolved since \ - vendoring, so their artifacts and manifest/ledger entries were retained — undo \ - the drift and re-run `vendor --revert` to finish.", - gc.vendored_kept.len(), - if gc.vendored_kept.len() == 1 { - "y" - } else { - "ies" - }, + "GC: kept {}: lock entries were re-resolved since vendoring, so their \ + artifacts and manifest/ledger entries were retained; undo the drift and \ + re-run `vendor --revert` to finish.", + count( + gc.vendored_kept.len(), + "drifted vendored entry", + "drifted vendored entries" + ), )); } // A failed revert leaves the entry and its artifacts in place; the @@ -392,19 +406,57 @@ fn gc_vendored_lines(gc: &GcSummary) -> Vec { // not reclaimed. if !gc.vendored_failed.is_empty() { lines.push(format!( - "GC: failed to revert {} vendored entr{}: {}.", - gc.vendored_failed.len(), - if gc.vendored_failed.len() == 1 { - "y" - } else { - "ies" - }, + "GC: failed to revert {}: {}.", + count( + gc.vendored_failed.len(), + "vendored entry", + "vendored entries" + ), gc.vendored_failed.join(", "), )); } lines } +/// Print [`format_gc_vendored_lines`]. +pub(super) fn print_gc_vendored_line(gc: &GcSummary) { + for line in format_gc_vendored_lines(gc) { + println!("{line}"); + } +} + +/// The human path's `--prune` pass: the mutating GC, or its read-only +/// preview under `--dry-run`, with its summary lines (unless `--silent`). +/// Mirrors the JSON path, which runs the GC whether or not anything was +/// applied. +pub(super) async fn run_human_gc( + common: &GlobalArgs, + manifest_path: &Path, + socket_dir: &Path, + scanned_purls: &HashSet, + vendored: &HashSet, +) { + let preview = common.dry_run; + let gc = if preview { + preview_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored).await + } else { + run_apply_gc(common, manifest_path, socket_dir, scanned_purls, vendored).await + }; + if common.silent { + return; + } + if let Some(line) = format_gc_line(&gc, preview) { + println!("\n{line}"); + } + for line in format_gc_vendored_lines(&gc) { + if preview { + println!("[dry-run] {line}"); + } else { + println!("{line}"); + } + } +} + /// PURL strings present in the manifest but absent from `scanned_purls`. /// These are candidates for pruning during `scan`'s GC pass — they /// correspond to packages that were once patched but are no longer @@ -461,6 +513,65 @@ fn detect_prunable( #[cfg(test)] mod tests { + + // ---- human GC lines ------------------------------------------------------ + + fn summary(pruned: usize, files: usize, bytes: u64) -> GcSummary { + GcSummary { + pruned: (0..pruned).map(|i| format!("pkg:npm/p{i}@1")).collect(), + blobs: CleanupResult { + blobs_removed: files, + bytes_freed: bytes, + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn format_gc_line_singular_plural_and_preview() { + assert_eq!(format_gc_line(&summary(0, 0, 0), false), None); + assert_eq!( + format_gc_line(&summary(1, 1, 12), false).as_deref(), + Some("GC: pruned 1 manifest entry and removed 1 orphan file (12 B).") + ); + assert_eq!( + format_gc_line(&summary(2, 0, 0), false).as_deref(), + Some("GC: pruned 2 manifest entries and removed 0 orphan files (0 B).") + ); + assert_eq!( + format_gc_line(&summary(1, 3, 0), true).as_deref(), + Some("[dry-run] GC would prune 1 manifest entry and remove 3 orphan files (0 B).") + ); + } + + #[test] + fn format_gc_vendored_lines_counts() { + assert!(format_gc_vendored_lines(&GcSummary::default()).is_empty()); + let gc = GcSummary { + vendored_reverted: vec!["a".into()], + vendor_orphan_dirs: 2, + vendored_kept: vec!["b".into(), "c".into()], + ..Default::default() + }; + let lines = format_gc_vendored_lines(&gc); + assert_eq!( + lines[0], + "GC: reverted 1 vendored entry; swept 2 orphan vendor dirs." + ); + assert!( + lines[1].starts_with("GC: kept 2 drifted vendored entries: lock entries"), + "{lines:?}" + ); + let gc = GcSummary { + vendor_orphan_dirs: 1, + ..Default::default() + }; + assert_eq!( + format_gc_vendored_lines(&gc), + vec!["GC: reverted 0 vendored entries; swept 1 orphan vendor dir."] + ); + } use super::*; use crate::commands::scan::tests::manifest_with; @@ -1465,11 +1576,11 @@ mod tests { /// The human GC vocabulary the contract pins (`GC: skipped (): /// .`, one `GC: .` per warning, `GC: failed to revert N - /// vendored entr(y|ies): …`), rendered in order; a clean pass prints + /// vendored entry/entries: …`), rendered in order; a clean pass prints /// nothing. #[test] fn gc_vendored_lines_render_the_contract_vocabulary() { - assert!(gc_vendored_lines(&GcSummary::default()).is_empty()); + assert!(format_gc_vendored_lines(&GcSummary::default()).is_empty()); let mut gc = GcSummary { skipped: Some(( @@ -1484,7 +1595,7 @@ mod tests { ..Default::default() }); assert_eq!( - gc_vendored_lines(&gc), + format_gc_vendored_lines(&gc), vec![ "GC: skipped (lock_held): another socket-patch process is operating in this \ directory." @@ -1502,7 +1613,7 @@ mod tests { ..Default::default() }); assert_eq!( - gc_vendored_lines(&one), + format_gc_vendored_lines(&one), vec![ "GC: reverted 1 vendored entry; swept 1 orphan vendor dir.".to_string(), "GC: failed to revert 1 vendored entry: pkg:npm/c@1.0.0.".to_string(), diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 82616d3d..5584c409 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -7,7 +7,7 @@ use std::path::Path; use std::time::Duration; use socket_patch_core::api::types::BatchPackagePatches; -use socket_patch_core::patch::apply_lock::{acquire, LockError, LockGuard}; +use socket_patch_core::patch::apply_lock::LockGuard; use socket_patch_core::patch::redirect::DepOverride; use crate::commands::vex::generate_vex_from_manifest_path; @@ -275,8 +275,8 @@ fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) -> S let how = match (created, dry_run) { (true, false) => "`trustLockfile: true` was written to a new", (false, false) => "`trustLockfile: true` was merged into the existing", - (true, true) => "`trustLockfile: true` would be written to a new (--dry-run)", - (false, true) => "`trustLockfile: true` would be merged into the existing (--dry-run)", + (true, true) => "`trustLockfile: true` would be written to a new", + (false, true) => "`trustLockfile: true` would be merged into the existing", }; format!( "{}, so {how} {PNPM_WORKSPACE_REL} — commit it alongside the lock; \ @@ -299,6 +299,29 @@ fn pnpm_lock_version_major(lock_text: &str) -> Option { }) } +/// Whether a pnpm lock may belong to pnpm 1–4, which spell the store flag +/// `--store` (pnpm 1–3 can silently ignore `--store-dir`; early pnpm 4 +/// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion +/// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. +fn pnpm_lock_may_need_store_flag(lock_text: &str) -> bool { + lock_text.lines().any(|line| { + if line.starts_with("shrinkwrapVersion:") { + return true; + } + let Some(rest) = line.strip_prefix("lockfileVersion:") else { + return false; + }; + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + let mut parts = value.split('.'); + let major = parts.next().and_then(|m| m.parse::().ok()); + let minor = parts + .next() + .and_then(|m| m.parse::().ok()) + .unwrap_or(0); + major == Some(5) && minor <= 2 + }) +} + /// The planned pnpm-workspace.yaml `trustLockfile: true` edit. enum TrustPlan { /// No workspace file: create it (root-only `packages` scaffold — pnpm 9 @@ -478,18 +501,16 @@ fn acquire_hosted_lock( ) -> Result { let socket_dir = common.socket_dir(); let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - match acquire(&socket_dir, timeout) { + match crate::commands::lock_cli::acquire_with_status(&socket_dir, timeout) { Ok(guard) => Ok(guard), Err(err) => { let (code, message) = crate::commands::lock_cli::lock_failure(&err, timeout); // Errors print even under --silent ("errors only", never // "nothing"): exit 1 with no message would be undiagnosable. - eprintln!("Error ({code}): {message}"); - if matches!(err, LockError::Held) { - eprintln!( - " Wait for it to finish, or retry with --lock-timeout to wait for the lock." - ); - } + eprint!( + "{}", + crate::commands::lock_cli::format_lock_error(&socket_dir, &err, timeout) + ); if common.json { emit_json_error_with_code(scan_result.take(), Some(code), &message); } @@ -844,6 +865,7 @@ pub(super) async fn run_redirect( api_client, all_packages_with_patches, can_access_paid_patches, + &args.common, false, false, ) @@ -858,6 +880,10 @@ pub(super) async fn run_redirect( Err((code, message)) => { if args.common.json { emit_json_error(scan_result.take(), &message); + } else if code == 0 && !args.common.silent { + // Exit 0 without an error is the cancelled selection + // (`Selection cancelled.` already printed). + eprintln!("Nothing was redirected."); } return code; } @@ -930,14 +956,27 @@ pub(crate) async fn run_redirect_selected( dep: DepOverride, } let mut candidates: Vec = Vec::new(); + // The network phases below (reference grants, wheel metadata, patch + // records) would otherwise be silent gaps on a terminal. Inert under + // --json/--silent and off a terminal. + let mut status = crate::ui::StatusLine::stderr(common.json, common.silent); if !selected.is_empty() { let uuids: Vec = selected.iter().map(|(_, uuid)| uuid.clone()).collect(); - let references = match api_client.fetch_registry_references(&uuids).await { + status.set(format!( + "Resolving hosted artifacts for {}...", + crate::ui::plural(uuids.len(), "patch", "patches") + )); + let fetched = api_client.fetch_registry_references(&uuids).await; + status.finish(); + let references = match fetched { Ok(r) => r, Err(e) => { let message = format!("failed to resolve patch references: {e}"); - eprintln!("{message}"); + eprintln!( + "{} (nothing was changed; re-run to retry)", + format_error_line(&message) + ); if common.json { emit_json_error(scan_result.take(), &message); } @@ -1117,7 +1156,7 @@ pub(crate) async fn run_redirect_selected( corrupt.quarantine().await; } let message = corrupt.to_string(); - eprintln!("{message}"); + eprintln!("{}", format_error_line(&message)); if common.json { emit_json_error(scan_result.take(), &message); } @@ -1161,6 +1200,13 @@ pub(crate) async fn run_redirect_selected( // wet run reverts FIRST) and counted as redirected below, so the // preview's envelope matches the wet run's outcome. let mut dry_run_takeover: Vec<(String, String)> = Vec::new(); + // Human output: the purls migrated (or, on --dry-run, to be migrated) + // from vendored to hosted, and the files their revert touches (or would + // touch). Both modes count `rewritten ∪ takeover_files`, so the + // preview's file count matches the wet run's even for wiring files the + // hosted rewriter does not also rewrite (a Gemfile line, a uv source). + let mut takeover_migrated: Vec = Vec::new(); + let mut takeover_files: std::collections::BTreeSet = std::collections::BTreeSet::new(); if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. } else { @@ -1300,6 +1346,8 @@ pub(crate) async fn run_redirect_selected( ), })); dry_run_takeover.push((purl.clone(), uuid.clone())); + takeover_migrated.push(purl.clone()); + takeover_files.extend(entry.wiring.iter().map(|w| w.file.clone())); continue; } let outcome = @@ -1351,6 +1399,8 @@ pub(crate) async fn run_redirect_selected( takeover: the project is now fully hosted for this package)" ), })); + takeover_migrated.push(purl.clone()); + takeover_files.extend(entry.wiring.iter().map(|w| w.file.clone())); } else { // No usable ledger entry. If socket-owned vendored wiring for // this crate is nevertheless present, the ledger is missing or @@ -1527,6 +1577,10 @@ pub(crate) async fn run_redirect_selected( if !native_target { continue; } + status.set(format!( + "Fetching hosted wheel metadata for {}...", + dep.name + )); match socket_patch_core::vendor::pypi::fetch_hosted_wheel_metadata( api_client, &dep.artifact_url, @@ -1549,6 +1603,7 @@ pub(crate) async fn run_redirect_selected( } } } + status.finish(); candidates.retain(|c| !unavailable_python_artifacts.contains(&c.dep.artifact_url)); // The rewriters' override slice — materialized ONCE, after the last // candidate filter, so it can never disagree with `candidates`. @@ -1687,6 +1742,10 @@ pub(crate) async fn run_redirect_selected( // the rewrite set (decided inside the borrow scope, applied after it). let mut trust_config_write: Option<(String, socket_patch_core::patch::redirect::FileEdit)> = None; + // Human mode only: this run touched nothing pnpm-related (no lock + // spliced, trust already configured), so the full guidance, printed by + // the run that made the change, shrinks to a one-line reminder. + let mut pnpm_rerun_only = false; { // pnpm locks spliced THIS run (any depth — the rewriter is // basename-generalized). @@ -1714,6 +1773,7 @@ pub(crate) async fn run_redirect_selected( files.get("pnpm-lock.yaml"), &overrides, ); + let spliced_pnpm_locks = pnpm_lock_texts.len(); if let Some(text) = heal_root { pnpm_lock_texts.push(text); } @@ -1810,12 +1870,15 @@ pub(crate) async fn run_redirect_selected( )); pnpm_trust_configured_detail(&server, false, common.dry_run) } - TrustPlan::AlreadyTrue => format!( - "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ - true` — keep it committed alongside the lock; installs need \ - no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ), + TrustPlan::AlreadyTrue => { + pnpm_rerun_only = spliced_pnpm_locks == 0; + format!( + "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ + true` — keep it committed alongside the lock; installs need \ + no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ) + } TrustPlan::UserSet(value) => format!( "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ {value}`, which was respected and left untouched — install \ @@ -1827,16 +1890,26 @@ pub(crate) async fn run_redirect_selected( }, } }; + // The `--store` spelling only matters to pnpm 1–4, so it is + // named only when a touched lock may be that old. + let store_note = if pnpm_lock_texts + .iter() + .any(|text| pnpm_lock_may_need_store_flag(text)) + { + " (pnpm 1–4 spell the option `--store`)" + } else { + "" + }; pnpm_warnings.push(serde_json::json!({ "code": "redirect_pnpm_trust_lockfile", "detail": format!( "{}. After a lock-only change, existing node_modules or a warm pnpm store \ can still contain upstream files. For a reliable reinstall, use a clean \ node_modules tree and an empty store with \ - `pnpm install --frozen-lockfile --store-dir ` \ - (pnpm 1–4 accepts the option `--store`). Do not rely on `--force`: some \ - versions re-resolve the upstream artifact. Run `socket-patch vex` after \ - installation to verify the patched files.", + `pnpm install --frozen-lockfile --store-dir `\ + {store_note}. Do not rely on `--force`: some versions re-resolve the \ + upstream artifact. Run `socket-patch vex` after installation to verify \ + the patched files.", detail.trim_end_matches('.') ), })); @@ -2011,7 +2084,9 @@ pub(crate) async fn run_redirect_selected( } if !common.dry_run { - for (purl, uuid) in &confirmed { + let total = confirmed.len(); + for (i, (purl, uuid)) in confirmed.iter().enumerate() { + status.set(format!("Fetching patch records... ({}/{total})", i + 1)); match api_client.fetch_patch(uuid).await { Ok(Some(resp)) => { let (rec_purl, record) = @@ -2023,14 +2098,18 @@ pub(crate) async fn run_redirect_selected( "code": "record_fetch_failed", "detail": format!( "{purl} redirected, but its patch record could not be fetched; \ - it will be missing from VEX until `scan --redirect` is re-run" + it will be missing from VEX until `socket-patch scan --mode \ + hosted` is re-run" ), })); } } } + status.finish(); } + // Whether this run persisted the redirect ledger (human next steps). + let mut ledger_written = false; if !common.dry_run { // Ledger (mirrors the vendor state.json shape): recorded edits for a // future revert + the patch records (file hashes + vulnerabilities) so @@ -2139,11 +2218,12 @@ pub(crate) async fn run_redirect_selected( // The ledger is the only revert path and the VEX record store — // a swallowed write failure would let the lockfile writes below // proceed with no revert data persisted while reporting success. - if let Err(e) = - socket_patch_core::patch::redirect::save_redirect_state(&common.cwd, &ledger).await - { + let saved = + socket_patch_core::patch::redirect::save_redirect_state(&common.cwd, &ledger).await; + ledger_written = saved.is_ok(); + if let Err(e) = saved { let message = format!("failed to write .socket/vendor/redirect-state.json: {e}"); - eprintln!("{message}"); + eprintln!("{}", format_error_line(&message)); if common.json { emit_json_error(scan_result.take(), &message); } @@ -2168,7 +2248,7 @@ pub(crate) async fn run_redirect_selected( .await { let message = format!("failed to write {rel}: {e}"); - eprintln!("{message}"); + eprintln!("{}", format_error_line(&message)); if common.json { emit_json_error(scan_result.take(), &message); } @@ -2275,6 +2355,9 @@ pub(crate) async fn run_redirect_selected( // augment_with_redirect). Requested-but-failed VEX (including "nothing to // attest") flips the exit code, matching `scan --vex`. let mut vex_statements: Option = None; + // VEX run-level advisories: `note_warning` keeps them off stderr under + // --json, so the envelope's `vex.warnings` is their only channel there. + let mut vex_warnings: Vec = Vec::new(); let mut vex_error: Option<(&'static str, String)> = None; let mut vex_code = 0; if vex.vex.is_some() && !common.dry_run { @@ -2299,7 +2382,10 @@ pub(crate) async fn run_redirect_selected( params.known_stale = python_stale.stale_purls.iter().cloned().collect(); let manifest_path = common.resolved_manifest_path(); match generate_vex_from_manifest_path(common, ¶ms, &manifest_path).await { - Ok(summary) => vex_statements = Some(summary.statements), + Ok(summary) => { + vex_statements = Some(summary.statements); + vex_warnings = summary.warnings; + } Err(e) => { vex_code = 1; vex_error = Some((e.code, e.message)); @@ -2307,24 +2393,28 @@ pub(crate) async fn run_redirect_selected( } } - if common.json { - let mut warnings: Vec = rewrite - .warnings - .iter() - .map(|w| { - serde_json::json!({ - "code": w.code, "detail": w.detail, - }) + // One merged warning list, in one order, for both channels: the + // rewriter's own warnings first (e.g. `no package-lock.json`), then the + // record, package-manager, stale-install, takeover and prune warnings. + let mut warnings: Vec = rewrite + .warnings + .iter() + .map(|w| { + serde_json::json!({ + "code": w.code, "detail": w.detail, }) - .collect(); - warnings.extend(record_warnings.iter().cloned()); - warnings.extend(rush_warnings.iter().cloned()); - warnings.extend(pnpm_warnings.iter().cloned()); - warnings.extend(gem_stale.warnings.iter().cloned()); - warnings.extend(python_stale.warnings.iter().cloned()); - warnings.extend(takeover_pre_warnings.iter().cloned()); - warnings.extend(takeover_warnings.iter().cloned()); - warnings.extend(prune_warnings.iter().cloned()); + }) + .collect(); + warnings.extend(record_warnings.iter().cloned()); + warnings.extend(rush_warnings.iter().cloned()); + warnings.extend(pnpm_warnings.iter().cloned()); + warnings.extend(gem_stale.warnings.iter().cloned()); + warnings.extend(python_stale.warnings.iter().cloned()); + warnings.extend(takeover_pre_warnings.iter().cloned()); + warnings.extend(takeover_warnings.iter().cloned()); + warnings.extend(prune_warnings.iter().cloned()); + + if common.json { // Nest the redirect result under `redirect` inside the classic scan // object (built by `run`, threaded in via `scan_result`), mirroring // vendored mode's nested `vendor` block. This keeps the hosted `--json` @@ -2347,6 +2437,11 @@ pub(crate) async fn run_redirect_selected( "format": "openvex-0.2.0", "verified": false, }); + // Same skip-if-empty `warnings` key as the agent arm's VEX block. + if !vex_warnings.is_empty() { + result["vex"]["warnings"] = serde_json::to_value(&vex_warnings) + .expect("RunWarning is a plain string struct: serialization cannot fail"); + } } else if let Some((code, message)) = &vex_error { result["status"] = serde_json::json!("error"); result["error"] = serde_json::json!({ "code": code, "message": message }); @@ -2358,70 +2453,114 @@ pub(crate) async fn run_redirect_selected( ); } else { if !common.silent { - let verb = if common.dry_run { - "would rewrite" - } else { - "rewrote" - }; + // Wrap long warnings only on a terminal: logs and pipes keep one + // line per sentence so CI can grep them. + let width = + std::io::IsTerminal::is_terminal(&std::io::stderr()).then(crate::ui::stderr_width); + for purl in &takeover_migrated { + eprintln!("{}", format_takeover_line(purl, common.dry_run)); + } + // The files a takeover's revert touched (or, on --dry-run, + // would touch) count alongside the rewriters' own: a dry-run + // takeover is withheld from the rewriters, and a wet revert can + // touch a wiring file the hosted rewriter never rewrites. The + // same union in both modes keeps preview and wet counts equal. + let mut human_files = rewritten.clone(); + human_files.extend(takeover_files.iter().cloned()); + human_files.sort(); + human_files.dedup(); + // The one stdout line: scripts read it, so it stays on stdout; + // everything below is on stderr and names its package itself. println!( - "Redirected {} package(s); {verb} {} file(s).", - confirmed.len(), - rewritten.len() + "{}", + format_redirect_summary(confirmed.len(), human_files.len(), common.dry_run) ); + let human_warnings: Vec<(&str, &str)> = warnings + .iter() + .map(|w| { + ( + w["code"].as_str().unwrap_or_default(), + w["detail"].as_str().unwrap_or_default(), + ) + }) + // The prune notice already printed up front (in `run`); + // successful takeovers printed above as progress lines. + .filter(|(code, _)| { + *code != super::REDIRECT_PRUNE_IGNORED && !TAKEOVER_INFO_CODES.contains(code) + }) + .collect(); // Human output prints the bare strings — `Value`'s `Display` - // would JSON-quote them (`skipped "pkg:npm/x" ("forbidden")`). - for s in &skipped { - eprintln!( - " skipped {} ({})", - s["purl"].as_str().unwrap_or_default(), - s["reason"].as_str().unwrap_or_default() - ); - } - // Same warning set as the JSON envelope, same order: the - // rewriter's own warnings first (e.g. `no package-lock.json`), - // then the record and package-manager warnings. - for w in &rewrite.warnings { - eprintln!(" warning: {}", w.detail); - } - for w in &record_warnings { - eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); - } - for w in &rush_warnings { - eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); - } - for w in &pnpm_warnings { - eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); - } - for w in gem_stale.warnings.iter().chain(&python_stale.warnings) { - // Code included: the stale-install hazard is a silent-CVE - // state, so the stderr line must be greppable by its stable - // code in CI logs, same as the JSON envelope. - eprintln!( - " warning ({}): {}", - w["code"].as_str().unwrap_or_default(), - w["detail"].as_str().unwrap_or_default() - ); - } - for w in &takeover_pre_warnings { - eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); + // would JSON-quote them. + let skipped_pairs: Vec<(String, String)> = skipped + .iter() + .map(|s| { + ( + s["purl"].as_str().unwrap_or_default().to_string(), + s["reason"].as_str().unwrap_or_default().to_string(), + ) + }) + .collect(); + // Granted, but nothing in the project pins it (no lock entry, + // unreadable lock, ...): listed so it never vanishes silently. + // (A skipped uuid — e.g. unavailable wheel metadata — is already + // listed with its reason.) + let unconfirmed: Vec = candidates + .iter() + .filter(|c| { + !confirmed + .iter() + .any(|(cp, cu)| *cp == c.purl && *cu == c.dep.patch_uuid) + }) + .filter(|c| { + !skipped + .iter() + .any(|s| s["uuid"].as_str() == Some(c.dep.patch_uuid.as_str())) + }) + .map(|c| c.purl.clone()) + .collect(); + for line in format_unredirected( + &skipped_pairs, + &unconfirmed, + confirmed.is_empty(), + // Only the lockfile rewriters' own warnings explain a + // missing lock entry; unrelated guidance (pnpm trust, VEX, + // stale installs) is not what the hint points at. + rewrite.warnings.len(), + ) { + eprintln!("{line}"); } - for w in &takeover_warnings { - eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); + for (code, detail) in &human_warnings { + let detail = if *code == "redirect_pnpm_trust_lockfile" && pnpm_rerun_only { + pnpm_trust_rerun_reminder() + } else { + detail + }; + eprintln!("{}", format_warning(code, detail, width)); } if let Some(statements) = vex_statements { eprintln!( - "Wrote OpenVEX document with {} statement(s) to {} (redirected patches are \ - attested from the ledger, not hash-verified — their bytes are fetched at \ - install time; run `socket-patch vex` after installing to verify against \ - the installed tree).", - statements, + "Wrote OpenVEX document with {} to {} (redirected patches are attested \ + from the ledger, not hash-verified — their bytes are fetched at install \ + time; run `socket-patch vex` after installing to verify against the \ + installed tree).", + crate::ui::plural(statements, "statement", "statements"), vex.vex .as_ref() .expect("vex_statements is Some only when --vex was given") .display(), ); } else if vex.vex.is_some() && common.dry_run { - eprintln!("Skipping VEX generation (--dry-run)."); + eprintln!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("redirected") + ); + } + if !common.dry_run { + for line in + format_next_steps(&human_files, ledger_written, !takeover_migrated.is_empty()) + { + println!("{line}"); + } } } // Errors print even under --silent ("errors only", never @@ -2433,6 +2572,325 @@ pub(crate) async fn run_redirect_selected( vex_code } +// ── Human-output formatting ──────────────────────────────────────────────── +// +// Pure `String` builders for everything the hosted flow prints in human +// mode, so the exact text is unit-testable (see the tests module). JSON +// output never goes through these: its `detail`/`reason` strings are the +// stable, machine-facing spellings. + +/// Warning codes that report a SUCCESSFUL vendored→hosted migration. They +/// stay in the JSON `warnings[]` (additive contract), but a human run +/// prints them as plain progress lines ([`format_takeover_line`]), not as +/// warnings. +const TAKEOVER_INFO_CODES: &[&str] = &[ + "redirect_takeover_reverted_vendored", + "redirect_would_revert_vendored", +]; + +/// Lowercase tool names that must keep their spelling at the start of a +/// sentence (`pnpm >=11 rejects…` must not become `Pnpm`). +const LOWERCASE_TOOLS: &[&str] = &[ + "npm", "pnpm", "yarn", "bun", "cargo", "pip", "pipenv", "uv", "poetry", "pdm", "hatch", "go", + "gem", "bundler", "bundle", "composer", "mvn", "gradle", "dotnet", "deno", "rush", +]; + +/// Capitalize the first letter of a message for an `Error:`/`Warning:` +/// line, leaving it alone when the first word is an identifier rather than +/// an English word: a file name (`pnpm-lock.yaml`), a purl, a flag, a path, +/// or a lowercase tool name. +fn sentence_case(msg: &str) -> String { + let first_word = msg.split_whitespace().next().unwrap_or(""); + let is_word = !first_word.is_empty() + && first_word + .chars() + .all(|c| c.is_ascii_lowercase() || c == ',' || c == ';') + && !LOWERCASE_TOOLS.contains(&first_word.trim_end_matches([',', ';'])); + if !is_word { + return msg.to_string(); + } + let mut chars = msg.chars(); + match chars.next() { + Some(c) => c.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +/// `Error: ` for a hosted-flow failure. +fn format_error_line(msg: &str) -> String { + format!("Error: {}", sentence_case(msg)) +} + +/// Split `text` into wrap tokens at whitespace, except that a +/// backtick-delimited code span (`` `pnpm install --trust-lockfile` ``) +/// stays one token so a command the user copies is never broken across +/// lines. An unclosed span falls back to plain whitespace splitting. +fn wrap_tokens(text: &str) -> Vec { + let mut tokens: Vec = Vec::new(); + let mut span: Vec<&str> = Vec::new(); + for word in text.split_whitespace() { + span.push(word); + let open = span.iter().map(|w| w.matches('`').count()).sum::() % 2 == 1; + if !open { + tokens.push(span.join(" ")); + span.clear(); + } + } + tokens.extend(span.into_iter().map(str::to_string)); + tokens +} + +/// Greedy word wrap to `width` columns (characters, not bytes). The first +/// line starts with `first_prefix`, later lines with `indent`. A word +/// longer than the line (a URL) gets a line of its own, never split; a +/// backtick code span counts as one word (see [`wrap_tokens`]). +fn wrap_words(text: &str, width: usize, first_prefix: &str, indent: &str) -> Vec { + let mut lines: Vec = Vec::new(); + let mut line = first_prefix.to_string(); + let mut line_len = first_prefix.chars().count(); + let mut empty = true; + for word in wrap_tokens(text) { + let word = word.as_str(); + let wlen = word.chars().count(); + if !empty && line_len + 1 + wlen > width { + lines.push(std::mem::replace(&mut line, indent.to_string())); + line_len = indent.chars().count(); + empty = true; + } + if !empty { + line.push(' '); + line_len += 1; + } + line.push_str(word); + line_len += wlen; + empty = false; + } + lines.push(line); + lines +} + +/// Split a long guidance paragraph into its sentences, at every period +/// followed by a space (host names and versions such as `patch.socket.dev` +/// or `5.4` never contain one). Each sentence keeps its own period; the +/// last one is returned as written. +fn split_sentences(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text.trim(); + while let Some(i) = rest.find(". ") { + out.push(rest[..=i].to_string()); + rest = rest[i + 2..].trim_start(); + } + if !rest.is_empty() { + out.push(rest.to_string()); + } + out +} + +/// One human warning: `Warning (): `. The pnpm trustLockfile +/// guidance is a paragraph of separate instructions, so it renders as a +/// headline plus one ` - ` bullet per sentence. With `width` (stderr is a +/// terminal) every line is word-wrapped; without it (a pipe or a CI log) +/// each sentence stays on one line so the text remains greppable. +fn format_warning(code: &str, detail: &str, width: Option) -> String { + let prefix = format!("Warning ({code}): "); + let detail = sentence_case(detail.trim()); + let (headline, bullets) = if code == "redirect_pnpm_trust_lockfile" { + let mut sentences = split_sentences(&detail).into_iter(); + let head = sentences.next().unwrap_or_default(); + (head, sentences.collect::>()) + } else { + (detail, Vec::new()) + }; + let mut lines: Vec = Vec::new(); + match width { + Some(w) => { + lines.extend(wrap_words(&headline, w, &prefix, " ")); + for b in &bullets { + lines.extend(wrap_words(b, w, " - ", " ")); + } + } + None => { + lines.push(format!("{prefix}{headline}")); + lines.extend(bullets.iter().map(|b| format!(" - {b}"))); + } + } + lines.join("\n") +} + +/// The one-line re-run reminder that replaces the full pnpm trustLockfile +/// guidance in human mode when this run changed nothing pnpm-related (the +/// lock was redirected and trust configured by an earlier run, whose +/// output carried the full text; `--json` still carries it every time). +fn pnpm_trust_rerun_reminder() -> &'static str { + "pnpm-lock.yaml is already redirected and pnpm-workspace.yaml already sets \ + `trustLockfile: true`; keep both committed, and never rebuild the lockfile \ + (`pnpm clean --lockfile`), which discards the redirect" +} + +/// The stdout summary line. +/// +/// - wet: `Redirected 1 package; rewrote 1 file.` +/// - dry run: `Would redirect 1 package and rewrite 1 file (--dry-run: nothing was changed).` +/// - every redirected package was already in place (nothing to rewrite): +/// `1 package is already redirected; nothing to rewrite.` +fn format_redirect_summary(redirected: usize, files: usize, dry_run: bool) -> String { + use crate::ui::plural; + if redirected > 0 && files == 0 { + return format!( + "{} already redirected; nothing to rewrite.", + plural(redirected, "package is", "packages are") + ); + } + let pkgs = plural(redirected, "package", "packages"); + let files = plural(files, "file", "files"); + if dry_run { + format!("Would redirect {pkgs} and rewrite {files} (--dry-run: nothing was changed).") + } else { + format!("Redirected {pkgs}; rewrote {files}.") + } +} + +/// Readable text for a `skipped[].reason` code (the JSON keeps the code). +/// Unknown server statuses fall through verbatim. +fn describe_skip_reason(reason: &str) -> String { + match reason { + "not_found" => "the hosted patch server has no artifact for this patch".into(), + "forbidden" => "not entitled to this patch (paid plan or no org access)".into(), + "pending" | "pending_build" => { + "the hosted artifact is still being built; re-run later".into() + } + "build_failed" => "the hosted artifact failed to build".into(), + "withdrawn" => "the patch was withdrawn".into(), + "bad_purl" => "the server returned an unparseable package URL".into(), + "no_url" => "the server returned no artifact URL".into(), + "vendored_revert_failed" => { + "its vendored state could not be reverted (see the warning)".into() + } + "python_metadata_unavailable" => "the hosted wheel's metadata could not be fetched".into(), + "redirect_bun_lock_unsupported" | "redirect_bun_lockb_invalid" => { + "the Bun lockfile blocks the vendored-to-hosted migration (see the warning)".into() + } + other => format!("server status `{other}`"), + } +} + +/// The per-package "not redirected" lines, `skipped` (with a reason code) +/// first, then `unconfirmed` (granted, but nothing in the project's files +/// pins it). When nothing at all was redirected they sit under a +/// `No patches could be redirected:` headline; otherwise each line stands +/// alone (it prints on stderr, apart from the stdout summary). +fn format_unredirected( + skipped: &[(String, String)], + unconfirmed: &[String], + nothing_redirected: bool, + lock_warnings: usize, +) -> Vec { + if skipped.is_empty() && unconfirmed.is_empty() { + return Vec::new(); + } + let see = match lock_warnings { + 0 => "", + 1 => " (see the warning below)", + _ => " (see the warnings below)", + }; + // Under the headline every line is a package that was not redirected, + // so it needs no "Skipped"/"Not redirected" lead of its own. + let (skip_lead, unpinned_lead) = if nothing_redirected { + (" ", " ") + } else { + ("Skipped ", "Not redirected ") + }; + let mut lines = Vec::new(); + if nothing_redirected { + lines.push("No patches could be redirected:".to_string()); + } + for (purl, reason) in skipped { + lines.push(format!( + "{skip_lead}{purl}: {}", + describe_skip_reason(reason) + )); + } + for purl in unconfirmed { + lines.push(format!( + "{unpinned_lead}{purl}: no lockfile entry pinning it could be redirected{see}" + )); + } + lines +} + +/// The human line for a successful (or, on `--dry-run`, planned) +/// vendored→hosted migration. +fn format_takeover_line(purl: &str, dry_run: bool) -> String { + if dry_run { + format!( + "Would migrate {purl} from vendored to hosted (its vendored wiring, ledger entry, \ + and committed artifact would be reverted first)." + ) + } else { + format!( + "Migrated {purl} from vendored to hosted (reverted its vendored wiring, ledger \ + entry, and committed artifact)." + ) + } +} + +/// `a`, `a and b`, `a, b, and c`; past `max` names, `a, b, and 3 more`. +fn join_names(names: &[String], max: usize) -> String { + let shown: Vec<&str> = names.iter().take(max).map(String::as_str).collect(); + let more = names.len().saturating_sub(max); + let mut parts: Vec = shown.iter().map(|s| s.to_string()).collect(); + if more > 0 { + parts.push(format!("{more} more")); + } + match parts.len() { + 0 => String::new(), + 1 => parts.remove(0), + 2 => format!("{} and {}", parts[0], parts[1]), + n => format!("{}, and {}", parts[..n - 1].join(", "), parts[n - 1]), + } +} + +/// Next steps after a wet run that rewrote files (stdout, after the +/// summary — the same place vendored mode prints its own): commit the +/// ledger and the rewritten files, reinstall so the installed tree picks +/// up the patched artifacts, then verify with `vex`. After a +/// vendored→hosted takeover (`vendored_removed`) the commit also has to +/// carry the deleted vendored ledger entries and artifacts, so the whole +/// `.socket/vendor/` directory is named instead of the redirect ledger. +fn format_next_steps( + files: &[String], + ledger_written: bool, + vendored_removed: bool, +) -> Vec { + if files.is_empty() && !vendored_removed { + return Vec::new(); + } + let mut commit: Vec = Vec::new(); + if vendored_removed { + commit.push(if ledger_written { + ".socket/vendor/ (the redirect ledger, plus the removed vendored ledger entries and \ + artifacts)" + .to_string() + } else { + ".socket/vendor/ (the removed vendored ledger entries and artifacts)".to_string() + }); + } else if ledger_written { + commit.push(".socket/vendor/redirect-state.json".to_string()); + } + commit.extend(files.iter().cloned()); + let npm = files + .iter() + .any(|f| f == "package-lock.json" || f == "npm-shrinkwrap.json"); + let hint = if npm { " (e.g. `npm ci`)" } else { "" }; + vec![ + format!("Commit {} to keep the redirect.", join_names(&commit, 6)), + format!( + "Reinstall from the updated lockfile{hint} so the installed packages pick up the \ + patched artifacts, then run `socket-patch vex` to verify them." + ), + ] +} + /// Transient-frame boxed constructor for [`run_redirect_selected`] — the /// future embeds the whole hosted engine, and callers outside scan (`get /// --mode hosted`) must not materialize it in their own poll frame (Windows @@ -2465,6 +2923,12 @@ mod tests { pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail, prune_ignored_warning, read_workspace_for_trust, redirect_json_block, TrustPlan, REDIRECT_CANDIDATE_FILES, }; + use super::{ + describe_skip_reason, format_error_line, format_next_steps, format_redirect_summary, + format_takeover_line, format_unredirected, format_warning, join_names, + pnpm_lock_may_need_store_flag, pnpm_trust_rerun_reminder, sentence_case, split_sentences, + wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, + }; use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; @@ -2592,7 +3056,16 @@ mod tests { assert!(!configured.contains("would be"), "{configured}"); let dry = pnpm_trust_configured_detail(server, created, true); assert!(dry.contains("would be"), "{dry}"); - assert!(dry.contains("--dry-run"), "{dry}"); + // The summary line already says it is a dry run; a marker + // inside the noun phrase ("a new (--dry-run) pnpm-workspace") + // read as garbled. + assert!(!dry.contains("--dry-run"), "{dry}"); + let want = if created { + "so `trustLockfile: true` would be written to a new pnpm-workspace.yaml — commit" + } else { + "so `trustLockfile: true` would be merged into the existing pnpm-workspace.yaml — commit" + }; + assert!(dry.contains(want), "{dry}"); for text in [&configured, &dry] { assert!(text.contains("ALL lockfile entries"), "{text}"); assert!(text.contains("minimumReleaseAge"), "{text}"); @@ -3611,4 +4084,376 @@ mod tests { ); } } + // ── Human-output formatting ──────────────────────────────────────────── + + #[test] + fn redirect_summary_singular_plural_and_dry_run() { + assert_eq!( + format_redirect_summary(1, 1, false), + "Redirected 1 package; rewrote 1 file." + ); + assert_eq!( + format_redirect_summary(2, 3, false), + "Redirected 2 packages; rewrote 3 files." + ); + assert_eq!( + format_redirect_summary(0, 0, false), + "Redirected 0 packages; rewrote 0 files." + ); + assert_eq!( + format_redirect_summary(1, 1, true), + "Would redirect 1 package and rewrite 1 file (--dry-run: nothing was changed)." + ); + assert_eq!( + format_redirect_summary(0, 0, true), + "Would redirect 0 packages and rewrite 0 files (--dry-run: nothing was changed)." + ); + assert_eq!( + format_redirect_summary(2, 5, true), + "Would redirect 2 packages and rewrite 5 files (--dry-run: nothing was changed)." + ); + } + + #[test] + fn redirect_summary_already_redirected_is_not_redirected_n() { + // Confirmed but nothing to write: an idempotent re-run, never + // "Redirected 1 package(s); rewrote 0 file(s)". + for dry in [false, true] { + assert_eq!( + format_redirect_summary(1, 0, dry), + "1 package is already redirected; nothing to rewrite." + ); + assert_eq!( + format_redirect_summary(3, 0, dry), + "3 packages are already redirected; nothing to rewrite." + ); + } + } + + #[test] + fn skip_reasons_are_readable_and_unknown_codes_pass_through() { + assert_eq!( + describe_skip_reason("forbidden"), + "not entitled to this patch (paid plan or no org access)" + ); + assert_eq!( + describe_skip_reason("pending"), + "the hosted artifact is still being built; re-run later" + ); + assert_eq!( + describe_skip_reason("not_found"), + "the hosted patch server has no artifact for this patch" + ); + assert_eq!( + describe_skip_reason("vendored_revert_failed"), + "its vendored state could not be reverted (see the warning)" + ); + assert_eq!( + describe_skip_reason("redirect_bun_lockb_invalid"), + describe_skip_reason("redirect_bun_lock_unsupported") + ); + assert_eq!(describe_skip_reason("mystery"), "server status `mystery`"); + for code in [ + "not_found", + "forbidden", + "pending", + "pending_build", + "build_failed", + "withdrawn", + "bad_purl", + "no_url", + "python_metadata_unavailable", + ] { + let text = describe_skip_reason(code); + assert!(!text.contains('_'), "{code} → {text}"); + } + } + + #[test] + fn unredirected_lines_empty_partial_and_nothing_redirected() { + assert!(format_unredirected(&[], &[], true, 1).is_empty()); + let skipped = vec![( + "pkg:npm/lodash@4.17.20".to_string(), + "forbidden".to_string(), + )]; + let unconfirmed = vec!["pkg:npm/minimist@1.2.5".to_string()]; + assert_eq!( + format_unredirected(&skipped, &unconfirmed, false, 1), + vec![ + "Skipped pkg:npm/lodash@4.17.20: not entitled to this patch (paid plan or no \ + org access)" + .to_string(), + "Not redirected pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be \ + redirected (see the warning below)" + .to_string(), + ] + ); + assert_eq!( + format_unredirected(&[], &unconfirmed, false, 2), + vec![ + "Not redirected pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be \ + redirected (see the warnings below)" + .to_string(), + ] + ); + assert_eq!( + format_unredirected(&[], &unconfirmed, true, 0), + vec![ + "No patches could be redirected:".to_string(), + " pkg:npm/minimist@1.2.5: no lockfile entry pinning it could be redirected" + .to_string(), + ] + ); + assert_eq!( + format_unredirected(&skipped, &[], true, 0), + vec![ + "No patches could be redirected:".to_string(), + " pkg:npm/lodash@4.17.20: not entitled to this patch (paid plan or no org \ + access)" + .to_string(), + ] + ); + } + + #[test] + fn takeover_lines_wet_and_dry() { + assert_eq!( + format_takeover_line("pkg:npm/lodash@4.17.20", false), + "Migrated pkg:npm/lodash@4.17.20 from vendored to hosted (reverted its vendored \ + wiring, ledger entry, and committed artifact)." + ); + assert_eq!( + format_takeover_line("pkg:npm/lodash@4.17.20", true), + "Would migrate pkg:npm/lodash@4.17.20 from vendored to hosted (its vendored \ + wiring, ledger entry, and committed artifact would be reverted first)." + ); + assert!(TAKEOVER_INFO_CODES.contains(&"redirect_takeover_reverted_vendored")); + assert!(TAKEOVER_INFO_CODES.contains(&"redirect_would_revert_vendored")); + assert!(!TAKEOVER_INFO_CODES.contains(&"redirect_vendored_revert_failed")); + } + + #[test] + fn sentence_case_skips_identifiers_and_tool_names() { + assert_eq!( + sentence_case("failed to write x: y"), + "Failed to write x: y" + ); + assert_eq!( + sentence_case("the redirect ledger ./a is malformed"), + "The redirect ledger ./a is malformed" + ); + assert_eq!(sentence_case("pnpm >=11 rejects"), "pnpm >=11 rejects"); + assert_eq!( + sentence_case("pnpm-lock.yaml was repointed"), + "pnpm-lock.yaml was repointed" + ); + assert_eq!( + sentence_case("pkg:npm/x@1 redirected"), + "pkg:npm/x@1 redirected" + ); + assert_eq!(sentence_case("`vendor` refused"), "`vendor` refused"); + assert_eq!(sentence_case("Already upper"), "Already upper"); + assert_eq!(sentence_case(""), ""); + assert_eq!(sentence_case("é accent"), "é accent"); + assert_eq!( + format_error_line("failed to resolve patch references: boom"), + "Error: Failed to resolve patch references: boom" + ); + } + + #[test] + fn wrap_words_respects_width_prefix_and_long_words() { + assert_eq!( + wrap_words("alpha beta gamma delta", 16, "W: ", " "), + vec!["W: alpha beta", " gamma delta"] + ); + // A word wider than the line sits alone, unsplit. + let url = "https://patch.socket.dev/very/long/path/that/does/not/fit"; + assert_eq!( + wrap_words(&format!("see {url} now"), 20, "", " "), + vec!["see".to_string(), format!(" {url}"), " now".to_string()] + ); + assert_eq!(wrap_words("", 10, "W: ", " "), vec!["W: "]); + // Counts characters, not bytes. + let lines = wrap_words("ééé ééé ééé", 8, "", ""); + assert_eq!(lines, vec!["ééé ééé", "ééé"]); + for line in wrap_words(&"word ".repeat(50), 30, "Warning (x): ", " ") { + assert!(line.chars().count() <= 30, "{line}"); + } + } + + #[test] + fn wrap_words_keeps_code_spans_whole() { + // The span crosses the wrap column: it moves to the next line whole. + assert_eq!( + wrap_words( + "never rebuild it (`pnpm clean --lockfile`), ever", + 30, + "", + " " + ), + vec!["never rebuild it", " (`pnpm clean --lockfile`),", " ever"] + ); + // A span wider than the line gets a line of its own, unsplit. + assert_eq!( + wrap_words( + "use `pnpm install --frozen-lockfile --store-dir ` now", + 20, + "", + " " + ), + vec![ + "use", + " `pnpm install --frozen-lockfile --store-dir `", + " now" + ] + ); + // Two spans in one word, and a word with a closed span, split normally. + assert_eq!( + wrap_tokens("a `b` c `d e`f g"), + vec!["a", "`b`", "c", "`d e`f", "g"] + ); + // An unclosed span never swallows the rest of the text. + assert_eq!(wrap_tokens("a `b c d"), vec!["a", "`b", "c", "d"]); + } + + #[test] + fn split_sentences_keeps_hosts_and_versions_whole() { + assert_eq!( + split_sentences("Repointed at patch.socket.dev. Keep lock 5.4 committed. done"), + vec![ + "Repointed at patch.socket.dev.", + "Keep lock 5.4 committed.", + "done" + ] + ); + assert_eq!(split_sentences("one"), vec!["one"]); + assert!(split_sentences(" ").is_empty()); + } + + #[test] + fn warning_line_one_line_in_pipes_and_wrapped_on_terminals() { + assert_eq!( + format_warning( + "redirect_npm_no_lockfile", + "no package-lock.json present", + None + ), + "Warning (redirect_npm_no_lockfile): No package-lock.json present" + ); + let long = "word ".repeat(40); + let wrapped = format_warning("c", &long, Some(40)); + assert!(wrapped.lines().count() > 1, "{wrapped}"); + assert!( + wrapped.lines().all(|l| l.chars().count() <= 40), + "{wrapped}" + ); + assert!(wrapped.starts_with("Warning (c): Word word"), "{wrapped}"); + assert!( + wrapped.lines().skip(1).all(|l| l.starts_with(" ")), + "{wrapped}" + ); + } + + #[test] + fn pnpm_warning_renders_headline_plus_bullets() { + let detail = "pnpm-lock.yaml was repointed at the server; so it goes. Note: a tradeoff. \ + Do NOT rebuild the lockfile. Run `socket-patch vex` after installation."; + assert_eq!( + format_warning("redirect_pnpm_trust_lockfile", detail, None), + "Warning (redirect_pnpm_trust_lockfile): pnpm-lock.yaml was repointed at the \ + server; so it goes.\n - Note: a tradeoff.\n - Do NOT rebuild the lockfile.\n \ + - Run `socket-patch vex` after installation." + ); + let wrapped = format_warning("redirect_pnpm_trust_lockfile", detail, Some(60)); + for line in wrapped.lines() { + assert!(line.chars().count() <= 60, "{line:?}"); + } + assert_eq!( + wrapped, + "Warning (redirect_pnpm_trust_lockfile): pnpm-lock.yaml was\n \ + repointed at the server; so it goes.\n - Note: a tradeoff.\n - Do NOT \ + rebuild the lockfile.\n - Run `socket-patch vex` after installation." + ); + // Continuation lines of a long bullet are indented under its text. + let bullet = "Head. Do NOT follow the advice to rebuild the lockfile, which discards it."; + assert_eq!( + format_warning("redirect_pnpm_trust_lockfile", bullet, Some(40)), + "Warning (redirect_pnpm_trust_lockfile): Head.\n - Do NOT follow the advice to \ + rebuild\n the lockfile, which discards it." + ); + } + + #[test] + fn pnpm_rerun_reminder_keeps_the_rebuild_caution() { + let r = pnpm_trust_rerun_reminder(); + assert!(r.contains("trustLockfile: true"), "{r}"); + assert!(r.contains("pnpm clean --lockfile"), "{r}"); + assert!(r.chars().count() < 240, "a reminder, not the wall: {r}"); + } + + #[test] + fn store_flag_note_only_for_pnpm_one_to_four_locks() { + assert!(pnpm_lock_may_need_store_flag("shrinkwrapVersion: 3\n")); + assert!(pnpm_lock_may_need_store_flag("lockfileVersion: 5.1\n")); + assert!(pnpm_lock_may_need_store_flag("lockfileVersion: '5.2'\n")); + assert!(!pnpm_lock_may_need_store_flag("lockfileVersion: 5.3\n")); + assert!(!pnpm_lock_may_need_store_flag("lockfileVersion: 5.4\n")); + assert!(!pnpm_lock_may_need_store_flag("lockfileVersion: '6.0'\n")); + assert!(!pnpm_lock_may_need_store_flag("lockfileVersion: '9.0'\n")); + assert!(!pnpm_lock_may_need_store_flag("packages: {}\n")); + } + + #[test] + fn join_names_lists_and_caps() { + let n = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(join_names(&n(&[]), 6), ""); + assert_eq!(join_names(&n(&["a"]), 6), "a"); + assert_eq!(join_names(&n(&["a", "b"]), 6), "a and b"); + assert_eq!(join_names(&n(&["a", "b", "c"]), 6), "a, b, and c"); + assert_eq!(join_names(&n(&["a", "b", "c", "d"]), 2), "a, b, and 2 more"); + } + + #[test] + fn next_steps_name_the_ledger_files_and_reinstall() { + assert!(format_next_steps(&[], true, false).is_empty()); + assert_eq!( + format_next_steps(&["package-lock.json".to_string()], true, false), + vec![ + "Commit .socket/vendor/redirect-state.json and package-lock.json to keep the \ + redirect." + .to_string(), + "Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages \ + pick up the patched artifacts, then run `socket-patch vex` to verify them." + .to_string(), + ] + ); + let steps = format_next_steps( + &[ + "pnpm-lock.yaml".to_string(), + "pnpm-workspace.yaml".to_string(), + ], + false, + false, + ); + assert_eq!( + steps[0], + "Commit pnpm-lock.yaml and pnpm-workspace.yaml to keep the redirect." + ); + assert!(!steps[1].contains("npm ci"), "{}", steps[1]); + } + + #[test] + fn next_steps_after_a_takeover_name_the_removed_vendored_state() { + assert_eq!( + format_next_steps(&["package-lock.json".to_string()], true, true)[0], + "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ + entries and artifacts) and package-lock.json to keep the redirect." + ); + assert_eq!( + format_next_steps(&["pnpm-lock.yaml".to_string()], false, true)[0], + "Commit .socket/vendor/ (the removed vendored ledger entries and artifacts) and \ + pnpm-lock.yaml to keep the redirect." + ); + } } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 4c51ee29..19e0f034 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -25,17 +25,15 @@ use std::path::Path; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs}; use crate::ecosystem_dispatch::crawl_all_ecosystems; -use crate::output::{color, confirm, format_severity, print_json}; +use crate::ui::{self, plural, print_json, StatusLine}; -use super::get::{ - download_and_apply_patches_with, select_patches, truncate_with_ellipsis, DownloadParams, - DownloadRun, -}; +use super::get::{download_and_apply_patches_with, select_patches, DownloadParams, DownloadRun}; mod discovery; mod gc; mod hosted; -mod vendor_flow; +pub(crate) mod render; +pub(crate) mod vendor_flow; use self::discovery::{ collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, @@ -47,7 +45,7 @@ use self::discovery::{ // preview, and the PnP layout-refusal warning mapping. `pub(crate)` // re-exports because the submodules themselves stay private to scan. pub(crate) use self::discovery::unsupported_layout_warnings; -use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; +use self::gc::gc_json; pub(crate) use self::hosted::boxed_run_redirect_selected; use self::hosted::run_redirect; pub(crate) use self::vendor_flow::{ @@ -63,24 +61,30 @@ const DEFAULT_BATCH_SIZE: usize = 100; /// 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. +// +// The `///` docs on the variants are user-facing `--help` text (shared +// with `get --mode`); keep implementation notes in `//` comments. #[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] pub enum ScanMode { - /// Rewrite lockfiles so ONLY patched dependencies resolve to Socket's - /// hosted patch server (== `--redirect`): no artifact bytes land in the - /// repo, but installs must reach the patch server. Hidden value aliases - /// mirror the legacy flag spellings symmetrically: `host` matches the - /// old mode name, `redirect` matches the `--redirect` boolean (vendored - /// accepts `vendor` for the same reason; `apply` is NOT an alias of - /// agent — applying is not a scan mode name anywhere else). + /// 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. Hidden value aliases + // mirror the legacy flag spellings symmetrically: `host` matches the + // old mode name, `redirect` matches the `--redirect` boolean (vendored + // accepts `vendor` for the same reason; `apply` is NOT an alias of + // agent — applying is not a scan mode name anywhere else). #[value(alias = "host", alias = "redirect")] Hosted, - /// Commit patched artifacts to `.socket/vendor/` (== `--vendor`): - /// hermetic, offline-safe installs at the cost of repo size. + /// 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` + blobs and re-apply them - /// in place, e.g. from CI (== `--apply`): smallest repo footprint, but - /// every install environment must run the agent. + /// Record patches in `.socket/manifest.json` plus blobs and re-apply + /// them in place (e.g. from CI): smallest repo footprint, but every + /// install environment must run the agent + // Equivalent to `--apply`. Agent, } @@ -139,9 +143,15 @@ 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 (hosted == --redirect, vendored == --vendor, agent == --apply/--sync)", + modes ({meaning})", mode.cli_name(), )); } @@ -164,6 +174,17 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { args.mode.expect("checked Some above").cli_name(), )); } + if args.mode == Some(ScanMode::Hosted) + && (args.common.global || args.common.global_prefix.is_some()) + { + // Global installs have no project lockfile to repoint: the hosted + // flow would "redirect 0 packages" and exit 0, a silent no-op. + return Err(format!( + "{} cannot be used with --mode hosted: global installs have no project \ + lockfile to redirect", + if args.common.global { "--global" } else { "--global-prefix" }, + )); + } 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. @@ -177,16 +198,13 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { #[derive(Args)] pub struct ScanArgs { - /// Optional path globs scoping DISCOVERY to packages installed under - /// matching paths (e.g. `packages/foo`, `apps/**`). A bare directory - /// pattern scopes its whole subtree. Scoping selects which PACKAGES - /// are considered; the prune universe (`--prune`/`--sync`) always - /// stays the full crawl, so a scoped scan never prunes out-of-scope - /// manifest entries. Lockfile-only and vendor-ledger supplements have - /// no installed path and are excluded from a path-scoped scan (a - /// run-level warning carries the count). Applies to agent-mode and - /// read-only scans; rejected with `--mode hosted`/`--mode vendored` - /// (their lockfile rewiring is whole-project by construction). + /// Only scan packages installed under these path globs (e.g. + /// `packages/foo`, `apps/**`; a bare directory scopes its whole + /// subtree). `--prune` still considers the whole project, so a scoped + /// scan never prunes out-of-scope manifest entries. Lockfile-only + /// packages have no installed path and are left out (with a warning). + /// Not available with `--mode hosted` or `--mode vendored`, which + /// rewire the whole project pub paths: Vec, #[command(flatten)] @@ -196,58 +214,45 @@ pub struct ScanArgs { #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE", default_value_t = DEFAULT_BATCH_SIZE)] pub batch_size: usize, - /// Deprecated spelling of `--mode agent` (kept for compatibility; - /// prefer `--mode`). Download and apply selected patches in JSON mode - /// (non-interactive). Without a mode, `scan --json` is read-only — it - /// lists available patches plus an `updates` array but does not mutate - /// the manifest. Designed for unattended workflows (cron jobs, bots - /// that open PRs); pair with `--yes` for clarity though `--json` - /// already implies non-interactive confirmation. On the non-JSON path - /// it is an explicit intent flag: a TTY prompts before downloading and - /// a non-TTY run auto-proceeds, whereas a mode-less human `scan` - /// without `--yes` on a non-TTY stdin is report-only (exit 0, nothing - /// downloaded, no `.socket/` created). + /// Deprecated spelling of `--mode agent`. With `--json`, download and + /// apply the selected patches without prompting (without a mode, + /// `scan --json` only reports). Without `--json` it asks first on a + /// terminal and proceeds otherwise, whereas a scan with no mode and no + /// `--yes` only reports when stdin is not a terminal #[arg(long, default_value_t = false)] pub apply: bool, /// Garbage-collect after the scan: prune manifest entries for - /// packages no longer present in the crawl, then delete orphan - /// blob, diff, and package-archive files from `.socket/`. Off by - /// default to preserve manifest state across temporary uninstalls; - /// pair with `--apply` (or use `--sync`) for the auto-update - /// workflow. No effect in hosted mode (which runs no GC): the run - /// proceeds with an explicit `redirect_prune_ignored` warning. + /// packages that are no longer installed, then delete orphan blob, + /// diff and package-archive files from `.socket/`. Off by default so + /// a temporary uninstall does not lose manifest entries; combine with + /// `--mode agent` (or use `--sync`) for the auto-update workflow. + /// Ignored, with a warning, in hosted mode #[arg(long, default_value_t = false)] pub prune: bool, - /// Convenience flag for the auto-update workflow: implies both - /// `--apply` and `--prune`. Designed so a cron job or CI workflow - /// can run `socket-patch scan --json --sync --yes` and end up in a - /// fully-reconciled state in one invocation. + /// Shorthand for `--mode agent --prune`: a cron job or CI workflow can + /// run `socket-patch scan --json --sync --yes` to end up fully + /// reconciled in one invocation #[arg(long, default_value_t = false)] pub sync: bool, - /// Deprecated spelling of `--mode vendored` (kept for compatibility; - /// prefer `--mode`). Vendor every patched dependency the scan selects - /// into the committable `.socket/vendor/` tree instead of applying - /// patches in place: the selected patch records are fetched in memory - /// (never written to `.socket/manifest.json` — the vendor ledger, - /// `.socket/vendor/state.json`, embeds each record), then the vendored - /// artifacts are built + wired; a package vendored at an older patch - /// uuid is re-vendored automatically. Conflicts with `--apply`/`--sync` - /// (vendoring replaces the in-place apply); combine with `--prune` to - /// garbage-collect stale state. JSON mode is non-interactive like - /// `--apply`; the interactive path prompts before downloading. + /// Deprecated spelling of `--mode vendored`: vendor every patched + /// dependency the scan selects into the committable `.socket/vendor/` + /// tree instead of applying patches in place. The patch records live in + /// the vendor ledger (`.socket/vendor/state.json`), never in + /// `.socket/manifest.json`; a package vendored at an older patch is + /// re-vendored. Combine with `--prune` to garbage-collect stale state #[arg(long, default_value_t = false, conflicts_with_all = ["apply", "sync"])] pub vendor: bool, - /// Accepted for compatibility (hidden): vendored mode is always - /// manifest-free — the vendor ledger (`.socket/vendor/state.json`) - /// 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. + /// 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, @@ -263,34 +268,23 @@ pub struct ScanArgs { #[arg(long, default_value_t = false, hide = true, conflicts_with_all = ["apply", "sync", "vendor"])] pub redirect: bool, - /// How discovered patches are consumed — the documented selector for - /// the three modes (each is equivalent to one boolean flag, kept as an - /// alias): - /// - /// * `hosted` (== `--redirect`): rewrite lockfiles so only patched - /// dependencies resolve to Socket's hosted patch server — no - /// artifact bytes in the repo, but installs must reach the server. - /// * `vendored` (== `--vendor`): commit patched artifacts under - /// `.socket/vendor/` — hermetic, offline-safe installs at the cost - /// of repo size. - /// * `agent` (== `--apply`): record patches in `.socket/manifest.json` - /// plus blobs and re-apply in place — smallest repo footprint, but - /// every environment must run the agent. - /// - /// Combining `--mode` with a boolean flag from a DIFFERENT mode is - /// rejected (see `resolve_mode_flags`); the same mode spelled both - /// ways is accepted. + /// How discovered patches are consumed. Without a mode, an interactive + /// scan offers to apply them in place and `scan --json` only reports. + /// `--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. #[arg(long = "mode", value_enum)] pub mode: Option, - /// Download patches for every release/distribution variant of a - /// matched package, not just the one(s) matching the locally- - /// installed distribution. Affects ecosystems with per-release - /// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems - /// (`platform`), and Maven (`classifier`). Off by default: narrow - /// scans store only the patch(es) for the installed dist, keeping - /// `.socket/` small; `--all-releases` makes the manifest portable - /// across environments (e.g. cross-platform CI caches). + /// Download patches for every release variant of a matched package, + /// not just the ones matching the locally installed distribution. + /// Affects ecosystems with per-release variants: PyPI (wheel/sdist), + /// RubyGems (`platform`) and Maven (`classifier`). Off by default to + /// keep `.socket/` small; turn it on to make the manifest portable + /// across environments (e.g. cross-platform CI caches) #[arg( long = "all-releases", env = "SOCKET_ALL_RELEASES", @@ -379,7 +373,7 @@ async fn embed_vex_human( // Dry-run twin of the JSON guard above: no generation, no file write. if common.dry_run { if !common.silent { - println!("[dry-run] VEX generation skipped. No attestation written."); + println!("{}", crate::commands::vex::format_vex_dry_run_skip("applied")); } return base_code; } @@ -388,13 +382,14 @@ async fn embed_vex_human( Ok(summary) => { if !common.silent { println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, - vex_args - .vex - .as_ref() - .expect("--vex is Some: guarded by the early return above") - .display(), + "{}", + crate::commands::vex::format_vex_written( + summary.statements, + vex_args + .vex + .as_ref() + .expect("--vex is Some: guarded by the early return above"), + ) ); } 0 @@ -415,9 +410,9 @@ async fn embed_vex_human( /// trustworthy patch data at all, and reporting the empty set would be /// indistinguishable from a genuine "no patches" result (the same masking /// the batch loop in `run` guards against), so that surfaces as `Err(1)` -/// with the failure on stderr. Passes `is_json = false` to -/// `select_patches`: scan-driven workflows have no "specify --id" option, -/// so non-TTY runs auto-select the newest patch rather than erroring with +/// with the failure on stderr. Selects with [`selection_args`]: +/// scan-driven workflows have no "specify --id" option, so non-TTY runs +/// auto-select the newest patch rather than erroring with /// `selection_required`. `Err` carries the exit code AND the message: the /// JSON callers must fold it into their envelope (every `--json` /// invocation emits exactly one JSON object — see CLI_CONTRACT.md), so @@ -430,13 +425,18 @@ async fn discover_selected( api_client: &socket_patch_core::api::client::ApiClient, packages: &[BatchPackagePatches], can_access_paid_patches: bool, + common: &GlobalArgs, show_progress: bool, warn: bool, ) -> Result, (i32, String)> { - let (all_search_results, error_count, last_error) = + let (all_search_results, failures) = fetch_patch_details(api_client, packages, show_progress, warn).await; + let error_count = failures.len(); if error_count > 0 && error_count == packages.len() { - let err = last_error.unwrap_or_else(|| "all patch-detail queries failed".to_string()); + let err = failures + .into_iter() + .last() + .map_or_else(|| "all patch-detail queries failed".to_string(), |(_, e)| e); let message = format!("all {error_count} patch-detail queries failed: {err}"); eprintln!("Error: {message}"); return Err((1, message)); @@ -444,49 +444,89 @@ async fn discover_selected( if all_search_results.is_empty() { return Ok(Vec::new()); } - select_patches(&all_search_results, can_access_paid_patches, false) - .map_err(|code| (code, "patch selection failed".to_string())) + if common.json { + // A `--json` run must never open the interactive menu (it would + // pop up over a machine-read stream on a TTY): pick the top-ranked + // accessible patch per PURL, exactly what a non-TTY run does. + // `select_patches` takes the top-ranked patch without prompting + // when every candidate is accessible, so pre-filter to those. + let accessible: Vec = all_search_results + .into_iter() + .filter(|p| can_access_paid_patches || p.tier == "free") + .collect(); + return select_patches(&accessible, true, &selection_args(common)) + .map_err(|code| (code, "patch selection failed".to_string())); + } + select_patches( + &all_search_results, + can_access_paid_patches, + &selection_args(common), + ) + .map_err(|code| (code, "patch selection failed".to_string())) +} + +/// `common` with `json` off, for `select_patches`: scan has no "re-run +/// with the chosen UUID" path, so it must never get `selection_required`. +/// A `--json` run also counts as `--yes`: it must never stop at the +/// interactive patch menu (on a TTY that menu would block a machine +/// consumer), so it takes the menu's default, the top-ranked patch. +/// (It still keeps the non-interactive note off stderr: the process-wide +/// quiet switch mutes it.) +fn selection_args(common: &GlobalArgs) -> GlobalArgs { + GlobalArgs { + json: false, + yes: common.yes || common.json, + ..common.clone() + } +} + +/// Print the blank stdout line that opens a paragraph, once: `opened` +/// flips on the first call. +fn open_paragraph(opened: &mut bool) { + if !std::mem::replace(opened, true) { + println!(); + } } /// One `search_patches_by_package` query per package with patches, merged /// into one result list — the detail-fetch loop the apply, vendor, redirect -/// and human-preview flows share. Returns the merged results plus the -/// number of failed queries and the last error text; the CALLERS own the -/// failure rule ([`discover_selected`] bails only when every query errored, -/// the human arm treats an empty merged set as a fetch failure). The two -/// output knobs are human-only: `show_progress` renders the -/// `\r`-overwriting counter on stderr, `warn` the per-package failure line. +/// and human-preview flows share. Returns the merged results plus every +/// failed query as `(purl, error)`; the CALLERS own the failure rule +/// ([`discover_selected`] bails only when every query errored, the human +/// arm treats an empty merged set as a fetch failure). The two output +/// knobs are human-only: `show_progress` shows the status-line counter on +/// stderr, `warn` prints a warning per failed package once the loop is +/// done — only when some query succeeded (when every one failed, the +/// caller's error line carries the cause instead, so nothing repeats). async fn fetch_patch_details( api_client: &socket_patch_core::api::client::ApiClient, packages: &[BatchPackagePatches], show_progress: bool, warn: bool, -) -> (Vec, usize, Option) { +) -> (Vec, Vec<(String, String)>) { let mut results: Vec = Vec::new(); - let mut error_count = 0usize; - let mut last_error: Option = None; - if show_progress && !packages.is_empty() { - eprint!("\nFetching patch details..."); - } + let mut failures: Vec<(String, String)> = Vec::new(); + // `show_progress` off reads as `--json` to the status line: never + // drawn. On, it is live only on a terminal; it never prints a result. + let mut status = StatusLine::stderr(!show_progress, false); for (i, pkg) in packages.iter().enumerate() { - if show_progress { - eprint!("\rFetching patch details... ({}/{})", i + 1, packages.len()); - } + status.set(format!( + "Fetching patch details... ({}/{})", + i + 1, + packages.len() + )); match api_client.search_patches_by_package(&pkg.purl).await { Ok(response) => results.extend(response.patches), - Err(e) => { - if warn { - eprintln!("\n Warning: could not fetch details for {}: {e}", pkg.purl); - } - error_count += 1; - last_error = Some(e.to_string()); - } + Err(e) => failures.push((pkg.purl.clone(), e.to_string())), } } - if show_progress && !packages.is_empty() { - eprintln!(); + status.finish(); + if warn && !results.is_empty() { + for (purl, e) in &failures { + eprintln!("Warning: could not fetch details for {purl}: {e}"); + } } - (results, error_count, last_error) + (results, failures) } /// The human hosted arm's stand-in for the lenient loader's advisory: a @@ -513,19 +553,6 @@ fn emit_discovery_error_json(result: &mut serde_json::Value, message: &str) { print_json(result); } -/// The report-only / declined-prompt hint: how to consume one patch -/// explicitly. Hosted runs name their mode (`get` defaults to agent mode). -fn print_get_hint(hosted: bool) { - let (action, mode) = if hosted { - ("redirect a package", " --mode hosted") - } else { - ("apply a patch", "") - }; - println!("\nTo {action}, run:"); - println!(" socket-patch get {mode}"); - println!(" socket-patch get {mode}"); -} - /// The agent-flow selection split both arms (JSON + human) share. Vendor- /// owned purls leave first (any uuid: the committed artifact IS the patch, /// and a manifest moved past the vendored uuid would break VEX verification @@ -1510,6 +1537,27 @@ fn push_scan_json_warning(result: &mut serde_json::Value, code: &str, detail: &s } } +/// Print the scan error envelope for a refusal before any scanning +/// (`--offline`): the all-batches-failed shape with every count at +/// zero, so JSON consumers see one consistent scan-error schema. +fn print_zero_error_envelope(err: &str, paths: &[String]) { + let result = serde_json::json!({ + "status": "error", + "error": err, + "scannedPackages": 0, + "lockfileOnlyPackages": 0, + "packagesWithPatches": 0, + "totalPatches": 0, + "freePatches": 0, + "paidPatches": 0, + "canAccessPaidPatches": false, + "packages": [], + "updates": [], + "paths": paths, + }); + print_json(&result); +} + pub async fn run(mut args: ScanArgs) -> i32 { apply_env_toggles(&args.common); @@ -1519,17 +1567,20 @@ pub async fn run(mut args: ScanArgs) -> i32 { // combinations get a usage-style error (exit 2, matching clap's // conflict exit code) — see `resolve_mode_flags` for why clap itself // can't express them. + // Usage errors (exit 2) print no JSON envelope, even under --json: + // they behave like clap's own usage errors, which cannot print one + // either (pinned by scan_paths_e2e::paths_with_hosted_or_vendored_mode_exit_2). if let Err(message) = resolve_mode_flags(&mut args) { - eprintln!("error: {message}"); + eprintln!("Error: {message}"); return 2; } // Positional PATH globs (see `ScanArgs::paths`). An unparseable glob - // is a usage error, same exit-2 stderr shape as the mode conflicts. + // is a usage error, same exit-2 shape as the mode conflicts. let path_scope = match crate::path_scope::PathScope::parse(&args.paths) { Ok(s) => s, Err(message) => { - eprintln!("error: {message}"); + eprintln!("Error: {message}"); return 2; } }; @@ -1547,20 +1598,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { if args.common.json { // Mirror the all-batches-failed error envelope shape so JSON // consumers see one consistent scan-error schema. - let result = serde_json::json!({ - "status": "error", - "error": err, - "scannedPackages": 0, - "packagesWithPatches": 0, - "totalPatches": 0, - "freePatches": 0, - "paidPatches": 0, - "canAccessPaidPatches": false, - "packages": [], - "updates": [], - "paths": path_scope.raw(), - }); - print_json(&result); + print_zero_error_envelope(err, path_scope.raw()); } else { eprintln!("Error: {err}"); } @@ -1625,11 +1663,10 @@ pub async fn run(mut args: ScanArgs) -> i32 { // summary, the results table, and the per-patch listing are all // suppressed below, mirroring `list`/`get`/`repair`/`remove`. Errors // and the JSON envelope are unaffected. - let show_progress = !args.common.json && !args.common.silent && std::io::stderr().is_terminal(); - - if show_progress { - eprint!("Scanning {scan_target}..."); - } + // Live only on a terminal; its result lines print whenever `human`. + let human = !args.common.json && !args.common.silent; + let mut status = StatusLine::stderr(args.common.json, args.common.silent); + status.set(format!("Scanning {scan_target}...")); // Crawl packages let (mut all_crawled, mut eco_counts, skipped_bundle_config_path) = @@ -1752,8 +1789,13 @@ pub async fn run(mut args: ScanArgs) -> i32 { layout_refusals.push(( "path_scope_excluded_supplements".to_string(), format!( - "{excluded_supplements} lockfile-only/vendor-ledger package(s) have \ - no installed path and were excluded from the path-scoped scan" + "{} no installed path and {} excluded from the path-scoped scan", + if excluded_supplements == 1 { + "1 lockfile-only/vendor-ledger package has".to_string() + } else { + format!("{excluded_supplements} lockfile-only/vendor-ledger packages have") + }, + if excluded_supplements == 1 { "was" } else { "were" }, ), )); } @@ -1774,13 +1816,17 @@ pub async fn run(mut args: ScanArgs) -> i32 { let package_count = all_purls.len(); if package_count == 0 { - if show_progress { - eprintln!(); - } - if !args.common.json && !args.common.silent { + status.finish(); + if human { for (code, detail) in &layout_refusals { eprintln!("Warning ({code}): {detail}"); } + // The JSON path skips the GC here too (see below); the human + // path says so instead of silently dropping `--prune`. Hosted + // mode already printed its own prune-ignored warning. + if prune && !hosted { + eprintln!("{}", render::PRUNE_SKIPPED_EMPTY); + } } // Telemetry: empty-scan still counts as a successful scan. track_patch_scanned( @@ -1869,12 +1915,17 @@ pub async fn run(mut args: ScanArgs) -> i32 { embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; print_json(&result); return code; - } else if args.common.silent { - // Errors only: the empty-scan hint is informational. - } else if args.common.global || args.common.global_prefix.is_some() { - println!("No global packages found."); - } else { - println!("No packages found. Run your package manager's install first."); + } else if !args.common.silent { + // Errors only under --silent: the empty-scan hint is + // informational. + println!( + "{}", + render::no_packages_message( + args.common.global || args.common.global_prefix.is_some(), + args.common.ecosystems.as_deref(), + &args.paths, + ) + ); } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } @@ -1904,17 +1955,13 @@ pub async fn run(mut args: ScanArgs) -> i32 { format!(" ({})", eco_parts.join(", ")) }; - // With progress on, a done-line overwrites the in-progress `eprint!` - // line before it (`\r`); otherwise it prints plain. - let cr = if show_progress { "\r" } else { "" }; - - if !args.common.json && !args.common.silent { - eprintln!("{cr}Found {package_count} packages{eco_summary}"); + status.finish_with(format!( + "Found {}{eco_summary}", + plural(package_count, "package", "packages") + )); + if human { if !lockfile_only.purls.is_empty() { - eprintln!( - "Note: {} package(s) from project lockfiles are not yet installed (lockfile-only).", - lockfile_only.purls.len(), - ); + eprintln!("{}", render::lockfile_only_note(lockfile_only.purls.len())); } // Polyglot PnP repos (e.g. a PnP frontend + a python venv) reach // this non-empty path: the refusal still prints so the invisible @@ -1931,18 +1978,11 @@ pub async fn run(mut args: ScanArgs) -> i32 { let mut batch_error_count = 0usize; let mut last_batch_error: Option = None; - if show_progress { - eprint!("Querying API for patches... (batch 1/{total_batches})"); - } - for (batch_idx, chunk) in all_purls.chunks(batch_size).enumerate() { - if show_progress { - eprint!( - "\rQuerying API for patches... (batch {}/{})", - batch_idx + 1, - total_batches - ); - } + status.set(format!( + "Querying API for patches... (batch {}/{total_batches})", + batch_idx + 1 + )); let mut result = api_client.search_patches_batch(chunk).await; @@ -1955,10 +1995,14 @@ pub async fn run(mut args: ScanArgs) -> i32 { if !use_public_proxy { if let Err(ref e) = result { if is_fallback_candidate(e) { - eprintln!( - "Warning: authenticated API returned {e}; \ - falling back to public patch API proxy (free patches only)." - ); + // Errors-only under --silent; --json keeps it on stderr + // (the envelope has no slot for a mid-run downgrade). + if !args.common.silent { + status.println(format!( + "Warning: authenticated API returned {e}; \ + falling back to public patch API proxy (free patches only)." + )); + } api_client = build_proxy_fallback_client(&overrides); use_public_proxy = true; fallback_to_proxy = true; @@ -1981,8 +2025,14 @@ pub async fn run(mut args: ScanArgs) -> i32 { Err(e) => { batch_error_count += 1; last_batch_error = Some(e.to_string()); - if !args.common.json { - eprintln!("\nError querying batch {}: {e}", batch_idx + 1); + // Not fatal by itself: the scan goes on with the other + // batches. A one-batch scan says it once, below. + if !args.common.json && !args.common.silent && total_batches > 1 { + status.println(render::batch_failed_warning( + batch_idx + 1, + total_batches, + &e.to_string(), + )); } } } @@ -1999,6 +2049,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { // than silently reporting zero patches (which historically looked // identical to "no patches for these packages"). if total_batches > 0 && batch_error_count == total_batches { + status.finish(); let err = last_batch_error.unwrap_or_else(|| "all batches failed".to_string()); track_patch_scan_failed( &err, @@ -2018,6 +2069,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { "status": "error", "error": err, "scannedPackages": package_count, + "lockfileOnlyPackages": lockfile_only.purls.len(), "packagesWithPatches": 0, "totalPatches": 0, "freePatches": 0, @@ -2029,7 +2081,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { }); print_json(&result); } else { - eprintln!("Error: all {total_batches} API batch queries failed: {err}"); + eprintln!("{}", render::all_batches_failed(total_batches, &err)); } return 1; } @@ -2039,15 +2091,16 @@ pub async fn run(mut args: ScanArgs) -> i32 { .map(|p| p.patches.len()) .sum(); - if !args.common.json && !args.common.silent { - if total_patches_found > 0 { - eprintln!( - "{cr}Found {total_patches_found} patches for {} packages", - all_packages_with_patches.len() - ); - } else { - eprintln!("{cr}API query complete"); - } + if total_patches_found > 0 { + status.finish_with(format!( + "Found {} for {}", + plural(total_patches_found, "patch", "patches"), + plural(all_packages_with_patches.len(), "package", "packages") + )); + } else { + // The result line ("No patches available for installed packages.") + // is printed on stdout below; saying it here too would repeat it. + status.finish(); } // Calculate patch counts @@ -2237,6 +2290,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { &api_client, &all_packages_with_patches, can_access_paid_patches, + &args.common, false, false, ) @@ -2410,7 +2464,30 @@ pub async fn run(mut args: ScanArgs) -> i32 { return final_code; } - let use_color = std::io::stdout().is_terminal(); + let use_color = ui::stdout_color(); + let verbose = args.common.verbose; + let silent = args.common.silent; + + // Every human-path exit that did not fail: the `--prune` GC first + // (agent mode only: the vendored step runs its own GC and hosted mode + // runs none), then the embedded VEX. The JSON path runs the GC whether + // or not anything was applied, and so does this one: an early "nothing + // to apply" exit must not silently drop `--prune`. + let (args_ref, manifest_ref, socket_ref) = (&args, &manifest_path, &socket_dir); + let (scanned_ref, vendored_ref) = (&scanned_purls, &vendored_purls); + let finish_human = move |code: i32| async move { + if prune && !vendor && !hosted && code == 0 { + gc::run_human_gc( + &args_ref.common, + manifest_ref, + socket_ref, + scanned_ref, + vendored_ref, + ) + .await; + } + embed_vex_human(&args_ref.common, &args_ref.vex, manifest_ref, code).await + }; // Every mode stops on an empty discovery — vendored mode included: scan // vendors what THIS discovery selects (a fresh clone or wiped @@ -2418,17 +2495,17 @@ pub async fn run(mut args: ScanArgs) -> i32 { // there is nothing for its vendor step to do and reaching it would only // take the apply lock for a no-op. if all_packages_with_patches.is_empty() { - if !args.common.silent { + if !silent { println!("\nNo patches available for installed packages."); } warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + return finish_human(0).await; } // The whole table + summary section is presentational only (nothing // computed inside is consumed downstream), so `--silent` skips it // wholesale. - if !args.common.silent { + if !silent { let mut updates_available = 0usize; // Canonical set of PURLs with a newer patch available, computed once via @@ -2440,21 +2517,18 @@ pub async fn run(mut args: ScanArgs) -> i32 { // patches also appear in the batch. let update_purls: HashSet<&str> = updates.iter().map(|u| u.purl.as_str()).collect(); - // Print table - println!("\n{}", "=".repeat(100)); - println!( - "{} {} {} VULNERABILITIES", - "PACKAGE".to_string() + &" ".repeat(33), - "PATCHES".to_string() + " ", - "SEVERITY".to_string() + &" ".repeat(8), - ); - println!("{}", "=".repeat(100)); + // Human display only: the decoded PURL (`%40scope` → `@scope`), like + // the "Patches to apply" preview. The PACKAGE column is as wide as + // the longest one (capped); longer ones keep their `@version`. + let shown_purls: Vec = all_packages_with_patches + .iter() + .map(|p| normalize_purl(&p.purl).into_owned()) + .collect(); + let purl_w = render::purl_col_width(shown_purls.iter().map(String::as_str)); - for pkg in &all_packages_with_patches { - // Char-safe truncation: a byte slice (`&pkg.purl[..37]`) panics - // when the cut lands mid-codepoint. PURLs can carry non-ASCII - // names/qualifiers, so route through the shared helper. - let display_purl = truncate_with_ellipsis(&pkg.purl, 40); + let mut rows: Vec = Vec::with_capacity(all_packages_with_patches.len()); + for (pkg, shown) in all_packages_with_patches.iter().zip(&shown_purls) { + let display_purl = render::elide_purl(shown, purl_w); let pkg_free = pkg.patches.iter().filter(|p| p.tier == "free").count(); let pkg_paid = pkg.patches.iter().filter(|p| p.tier == "paid").count(); @@ -2466,7 +2540,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { format!( "{}+{}", pkg_free, - color(&pkg_paid.to_string(), "33", use_color) + ui::paint(&pkg_paid.to_string(), "33", use_color) ) } } else { @@ -2482,15 +2556,8 @@ pub async fn run(mut args: ScanArgs) -> i32 { .unwrap_or("unknown"); // Collect vuln IDs (deterministic: deduped, CVEs then GHSAs, - // each group sorted — see collect_vuln_ids). - let vuln_ids = collect_vuln_ids(pkg); - let vuln_str = if vuln_ids.len() > 2 { - format!("{} (+{})", vuln_ids[..2].join(", "), vuln_ids.len() - 2) - } else if vuln_ids.is_empty() { - "-".to_string() - } else { - vuln_ids.join(", ") - }; + // each group sorted, aliases not counted — see collect_vuln_ids). + let vuln_str = render::vuln_cell(&collect_vuln_ids(pkg), verbose); // Check for updates — consult the canonical `detect_updates` result // (mirrored into `update_purls`) so the human table and JSON `updates` @@ -2501,58 +2568,64 @@ pub async fn run(mut args: ScanArgs) -> i32 { } let update_marker = if has_update { - color(" [UPDATE]", "33", use_color) + ui::paint(" [UPDATE]", "33", use_color) } else { String::new() }; - // Lockfile-only packages can be patched by `scan --vendor` + // Lockfile-only packages can be patched by `scan --mode vendored` // (which fetches them pristine) but not applied in place. // `normalize_purl` bridges the API's percent-encoded spelling // to the supplement's literal form, like the JSON flag and the // apply-path skip partitions. let not_installed_marker = if lockfile_only_contains(&lockfile_only.purls, &pkg.purl) { - color(" [NOT INSTALLED]", "33", use_color) + ui::paint(" [NOT INSTALLED]", "33", use_color) } else { String::new() }; - println!( - "{:<40} {:>8} {:<16} {}{}{}", - display_purl, - count_str, - format_severity(severity, use_color), - vuln_str, - update_marker, - not_installed_marker, - ); + rows.push(render::table_row( + purl_w, + &display_purl, + &count_str, + &ui::severity(severity, use_color), + &vuln_str, + &format!("{update_marker}{not_installed_marker}"), + )); } - println!("{}", "=".repeat(100)); + // The rule is as wide as the table, but never wraps a terminal. + let header = render::table_header(purl_w); + let cap = std::io::stdout() + .is_terminal() + .then(ui::stdout_width); + let rule = render::ruler( + std::iter::once(header.as_str()).chain(rows.iter().map(String::as_str)), + cap, + ); + println!("\n{rule}"); + println!("{header}"); + println!("{rule}"); + for row in &rows { + println!("{row}"); + } + println!("{rule}"); // Summary + let with_patches = all_packages_with_patches.len(); if can_access_paid_patches { println!( - "\nSummary: {} package(s) with {} available patch(es)", - all_packages_with_patches.len(), - total_patches, + "\n{}", + render::summary_line(with_patches, total_patches, true) ); } else { println!( - "\nSummary: {} package(s) with {} free patch(es)", - all_packages_with_patches.len(), - free_patches, + "\n{}", + render::summary_line(with_patches, free_patches, false) ); if paid_patches > 0 { println!( "{}", - color( - &format!( - " + {} additional patch(es) available with paid subscription", - paid_patches - ), - "33", - use_color, - ), + ui::paint(&render::paid_extra_line(paid_patches), "33", use_color), ); println!( "\nUpgrade to Socket's paid plan to access all patches: https://socket.dev/pricing" @@ -2563,11 +2636,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { if updates_available > 0 { println!( "\n{}", - color( - &format!("{updates_available} package(s) have newer patches available."), - "33", - use_color, - ), + ui::paint(&render::updates_line(updates_available), "33", use_color), ); } } @@ -2584,7 +2653,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { // agent/vendored arms: a free-tier org whose every offer is paid-tier has // nothing any mode could select, so every human arm stops here with the // same paid-subscription line instead of entering its engine for a - // no-op (hosted would otherwise print `Redirected 0 package(s)`). + // no-op (hosted would otherwise print `Redirected 0 packages`). let downloadable_count = if can_access_paid_patches { all_packages_with_patches.len() } else { @@ -2595,11 +2664,11 @@ pub async fn run(mut args: ScanArgs) -> i32 { }; if downloadable_count == 0 { - if !args.common.silent { + if !silent { println!("\nNo downloadable patches (paid subscription required)."); } warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + return finish_human(0).await; } if hosted { @@ -2607,8 +2676,9 @@ pub async fn run(mut args: ScanArgs) -> i32 { &api_client, &all_packages_with_patches, can_access_paid_patches, - show_progress, - !args.common.silent, + &args.common, + human, + !silent, ) .await { @@ -2624,13 +2694,18 @@ pub async fn run(mut args: ScanArgs) -> i32 { // intent, so a non-TTY run auto-proceeds like every other mode — // only the mode-less scan below is report-only. if !selected.is_empty() && !args.common.dry_run { - let prompt = format!( - "Redirect {} package(s) to the hosted patch server?", - selected.len() - ); - if !confirm(&prompt, true, args.common.yes, false) { - if !args.common.silent { - print_get_hint(true); + let prompt = render::hosted_confirm_prompt(selected.len()); + // The prompt (or the non-TTY note) opens its own paragraph + // under the table's Summary, on the prompt's stream. + if !silent && !args.common.yes { + eprintln!(); + } + if !ui::confirm(&prompt, true, &args.common) { + if !silent { + println!(); + for line in render::hosted_decline_hint() { + println!("{line}"); + } } warn_unreported_corrupt_ledger(&args.common, hosted_corrupt_ledger.as_deref()); return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; @@ -2655,168 +2730,195 @@ pub async fn run(mut args: ScanArgs) -> i32 { // run through `discover_selected`, here with progress + per-package // warnings. Discovery said these packages HAVE patches, so an empty // merged set is a fetch failure. - let (all_search_results, _, _) = fetch_patch_details( - &api_client, - &all_packages_with_patches, - show_progress, - !args.common.silent, - ) - .await; + let (all_search_results, detail_failures) = + fetch_patch_details(&api_client, &all_packages_with_patches, human, !silent).await; if all_search_results.is_empty() { - eprintln!("Could not fetch patch details."); + eprintln!("{}", render::fetch_details_failed(&detail_failures)); return 1; } - // Smart selection + // Prompt to download. A MODE-LESS human scan (no `--mode`/`--apply`/ + // `--sync`/`--vendor`/`--redirect` and no `--prune`) with a non-TTY + // stdin and no `--yes` is report-only: it stops before the prompt with + // exit 0 and a hint, never downloads, never creates `.socket/`. This is + // a scan-side pre-check — `confirm()` itself keeps its non-TTY + // auto-accept, so every explicit-intent flag (and every other command's + // prompt) still proceeds unattended, and a TTY always prompts. + let report_only = args.mode.is_none() && !args.prune && !args.common.yes && !ui::stdin_is_tty(); + + // Smart selection. A report-only run picks without the non-interactive + // note: it never downloads, so there is no pick to announce. + let mut select_common = selection_args(&args.common); + select_common.silent |= report_only; + // A menu or the non-interactive note opens its own paragraph under the + // table's Summary (stderr, like the prompt). + if !select_common.silent + && super::get::selection_has_choice( + &all_search_results, + can_access_paid_patches, + &select_common, + ) + { + eprintln!(); + } let selected: Vec = - match select_patches(&all_search_results, can_access_paid_patches, false) { + match select_patches(&all_search_results, can_access_paid_patches, &select_common) { Ok(s) => s, Err(code) => return code, }; + // The skip / already-recorded lines below open their own paragraph + // under the table's Summary: one blank line before the first of them. + let mut skip_paragraph = false; + // Agent flow (mirrors the JSON arm): vendor-owned and lockfile-only // purls leave the selection as calm skips. In vendored mode nothing is - // partitioned — re-vendoring a stale uuid is exactly what the flag is + // partitioned — re-vendoring a stale uuid is exactly what the mode is // for, and the vendor engine fetches lockfile-resolved packages // pristine. let selected = if vendor { selected } else { let split = partition_agent_selection(selected, &vendored_purls, &lockfile_only); - if !args.common.silent { + if !silent { for purl in &split.vendored_purls { - println!( - " [skip] {} (vendored — run scan --vendor to update)", - normalize_purl(purl) - ); + open_paragraph(&mut skip_paragraph); + println!("{}", render::vendored_skip_line(&normalize_purl(purl))); } for purl in &split.not_installed_purls { - println!( - " [skip] {} (not installed — run your package manager's install first, \ - or `scan --vendor` to vendor it from the lockfile)", - normalize_purl(purl) - ); + open_paragraph(&mut skip_paragraph); + println!("{}", render::not_installed_skip_line(&normalize_purl(purl))); } } split.kept }; + // A selection the manifest already records at the same uuid would be + // downloaded only to be skipped ("already in manifest") — don't offer + // it. Agent mode only: vendored mode never reads the manifest. + let recorded = |p: &PatchSearchResult| { + existing_manifest + .as_ref() + .and_then(|m| m.patches.get(&p.purl)) + .is_some_and(|r| r.uuid == p.uuid) + }; + let (already_recorded, selected): (Vec<_>, Vec<_>) = if vendor { + (Vec::new(), selected) + } else { + selected.into_iter().partition(|p| recorded(p)) + }; + if !silent { + for p in &already_recorded { + open_paragraph(&mut skip_paragraph); + println!( + "{}", + render::already_recorded_line(&normalize_purl(&p.purl), &p.uuid) + ); + } + } + if selected.is_empty() { - if !args.common.silent { - println!("No patches selected."); + if !silent { + open_paragraph(&mut skip_paragraph); + if already_recorded.is_empty() { + println!("No patches selected."); + } else { + println!("{}", render::ALL_ALREADY_RECORDED); + } } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + return finish_human(0).await; } // Display detailed summary of selected patches before confirming // (presentational only — skipped wholesale under --silent). - if !args.common.silent { + if !silent { if vendor { println!("\nPatches to vendor:\n"); } else { println!("\nPatches to apply:\n"); } for patch in &selected { - // Collect CVE/GHSA IDs and highest severity from vulnerabilities - let mut vuln_ids: Vec = Vec::new(); - let mut highest_severity: Option<&str> = None; - for (id, vuln) in &patch.vulnerabilities { - if vuln.cves.is_empty() { - vuln_ids.push(id.clone()); - } else { - for cve in &vuln.cves { - vuln_ids.push(cve.clone()); + let severity = + ui::severity(render::highest_severity(patch).unwrap_or("unknown"), use_color); + // The manifest already records a different patch for this + // package: say so, and warn when the new one fixes less. Agent + // mode only: vendored mode never writes the manifest. + let replaces = existing_manifest + .as_ref() + .filter(|_| !vendor) + .and_then(|m| m.patches.get(&patch.purl)) + .filter(|r| r.uuid != patch.uuid) + .map(|r| render::Replaces { + uuid: &r.uuid, + vuln_ids: r.vulnerabilities.keys().map(String::as_str).collect(), + }); + let block = render::PatchBlock { + patch, + severity: &severity, + replaces, + verbose, + }; + // The block is a result (stdout); a downgrade warning goes to + // stderr, right under the block's first line. + let warning = render::replacement_warning(&block); + for (i, line) in render::patch_block(&block).iter().enumerate() { + println!("{line}"); + if i == 0 { + if let Some(w) = &warning { + eprintln!("{w}"); } } - let sev = vuln.severity.as_str(); - if highest_severity.is_none_or(|cur| severity_order(sev) < severity_order(cur)) { - highest_severity = Some(sev); - } - } - - let sev_display = highest_severity.unwrap_or("unknown"); - let sev_colored = format_severity(sev_display, use_color); - - // Char-safe: descriptions come straight from the API and routinely - // contain non-ASCII text; a `&desc[..69]` byte slice would panic. - let desc = truncate_with_ellipsis(&patch.description, 72); - - println!( - " {} [{}] {}", - // Human display only: show the decoded form of an - // API-encoded purl (`%40scope` → `@scope`). JSON output - // keeps the verbatim key. - normalize_purl(&patch.purl), - patch.tier.to_uppercase(), - sev_colored, - ); - if !vuln_ids.is_empty() { - println!(" Fixes: {}", vuln_ids.join(", ")); - } - // Show per-vulnerability summaries - for vuln in patch.vulnerabilities.values() { - if !vuln.summary.is_empty() { - // Char-safe: vulnerability summaries are API-sourced free - // text; a `&summary[..73]` byte slice would panic mid-codepoint. - let summary = truncate_with_ellipsis(&vuln.summary, 76); - let cve_label = if vuln.cves.is_empty() { - String::new() - } else { - format!("{}: ", vuln.cves.join(", ")) - }; - println!(" - {cve_label}{summary}"); - } - } - if !desc.is_empty() { - println!(" {desc}"); } - println!(); } } + // What the prompt / dry-run line offers. + let plan = if vendor { + render::Plan::Vendor(selected.len()) + } else { + render::Plan::Apply(selected.len()) + }; + // `--dry-run` is a non-mutating preview (see the global flag's doc and // the JSON path's `dryRun` envelope). The interactive path must honor it // too: stop here, having printed the table and the per-patch plan above, // before the confirm prompt, the download/apply, and the prune GC — all - // of which mutate the manifest and `.socket/` on disk. + // of which mutate the manifest and `.socket/` on disk (the GC runs as a + // read-only preview instead). if args.common.dry_run { - if !args.common.silent { - let action = if vendor { - "download and vendor" - } else { - "download and apply" - }; - println!( - "\n[dry-run] Would {action} {} patch(es). No changes made.", - selected.len() - ); + if !silent { // Vendored preview: the same ledger classification the JSON arm // nests under `vendor`, rendered as `[would-refuse]` lines so a // human preview never advertises vendoring the wet run's Bun // preflight is known to refuse (the `get --mode vendored - // --dry-run` arms print the identical lines). - if vendor { - let preview = preview_vendor_json(&args.common.cwd, &selected).await; - print_dry_run_refusals(&preview); + // --dry-run` arms print the identical lines). The headline + // counts them too. + let preview = if vendor { + Some(preview_vendor_json(&args.common.cwd, &selected).await) + } else { + None + }; + let refused = preview + .as_ref() + .and_then(|p| p["patches"].as_array()) + .map_or(0, |a| { + a.iter().filter(|p| p["action"] == "would_refuse").count() + }); + println!("{}", render::dry_run_line(plan, refused)); + if let Some(preview) = &preview { + print_dry_run_refusals(preview); } } - return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; + return finish_human(0).await; } - // Prompt to download. A MODE-LESS human scan (no `--mode`/`--apply`/ - // `--sync`/`--vendor`/`--redirect` and no `--prune`) with a non-TTY - // stdin and no `--yes` is report-only: it stops here with exit 0 and - // the hint below, never downloads, never creates `.socket/`. This is a - // scan-side pre-check — `confirm()` itself keeps its non-TTY - // auto-accept, so every explicit-intent flag (and every other command's - // prompt) still proceeds unattended, and a TTY always prompts. - let verb = if vendor { "vendor" } else { "apply" }; - let prompt = format!("Download and {verb} {} patch(es)?", selected.len()); - let report_only = - args.mode.is_none() && !args.prune && !args.common.yes && !crate::output::stdin_is_tty(); + // Report-only (see `report_only` above): stop before the prompt. if report_only { - if !args.common.silent { - print_get_hint(false); + // The "Patches to apply:" listing already ends with a blank line. + if !silent { + for line in render::decline_hint(false) { + println!("{line}"); + } } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } @@ -2826,40 +2928,53 @@ pub async fn run(mut args: ScanArgs) -> i32 { // stage force-applies the verified patched content). Runs after the // dry-run return above so a preview fetches no views; the views it // does fetch seed the download phase, which never fetches them again. - let prefetched = if vendor && !args.common.silent { + let prefetched = if vendor && !silent { let (mismatched, views) = preverify_vendor_baselines( &api_client, &selected, &filtered_crawled, &lockfile_only.purls, vendor_state.as_ref().ok().map(|s| &s.entries), + &mut status, ) .await; + let mut any_mismatch = false; for patch in selected.iter().filter(|p| mismatched.contains(&p.uuid)) { println!( - " {}: installed content differs from patch baseline — will vendor patched content", - normalize_purl(&patch.purl) + "{}", + render::baseline_mismatch_line(&normalize_purl(&patch.purl)) ); + any_mismatch = true; + } + // Keep the prompt its own paragraph, as in the other flows. + if any_mismatch { + println!(); } views } else { HashMap::new() }; - if !confirm(&prompt, true, args.common.yes, false) { - if !args.common.silent { - print_get_hint(false); + if !ui::confirm(&render::confirm_prompt(plan), true, &args.common) { + if !silent { + println!(); + for line in render::decline_hint(vendor) { + println!("{line}"); + } + if prune { + eprintln!("{}", render::PRUNE_SKIPPED_DECLINED); + } } return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await; } - // Download, then apply in place — or vendor (`--vendor`, where the + // Download, then apply in place — or vendor (vendored mode, where the // download only saves and the vendor step below does the rest). let params = download_params( &args, /*save_only=*/ vendor, /*json=*/ false, - args.common.silent, + silent, ); let code = if vendor { @@ -2894,7 +3009,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { // reads as a completed hosted→agent conversion that never happened. // (The vendored-ownership counterpart is already printed per package // by the `[skip] … (vendored …)` lines above.) - if !vendor && !args.common.silent { + if !vendor && !silent { let hosted_retained = hosted_wiring_retained_purls( &args.common.cwd, redirect_state.as_ref(), @@ -2916,7 +3031,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { // run `socket-patch gc` (or `repair`) explicitly. (Vendor mode runs // its own GC after the vendor step, inside `vendor_flow`.) if prune && !vendor { - let gc = run_apply_gc( + gc::run_human_gc( &args.common, &manifest_path, &socket_dir, @@ -2924,20 +3039,6 @@ pub async fn run(mut args: ScanArgs) -> i32 { &vendored_purls, ) .await; - let total = gc.blobs.blobs_removed + gc.diffs.blobs_removed + gc.packages.blobs_removed; - if !args.common.silent && (!gc.pruned.is_empty() || total > 0) { - println!( - "\nGC: pruned {} manifest entr{} and removed {} orphan file{} ({}).", - gc.pruned.len(), - if gc.pruned.len() == 1 { "y" } else { "ies" }, - total, - if total == 1 { "" } else { "s" }, - socket_patch_core::manifest::cleanup_blobs::format_bytes(gc.total_bytes()), - ); - } - if !args.common.silent { - print_gc_vendored_line(&gc); - } } embed_vex_human(&args.common, &args.vex, &manifest_path, code).await @@ -2985,39 +3086,6 @@ mod tests { m } - // ---- truncate_with_ellipsis (scan's display columns) ------------------- - // scan.rs renders PURLs, descriptions, and vulnerability summaries — all - // API-sourced and potentially non-ASCII — into fixed-width columns. These - // pin scan's use of the char-safe helper; a raw `&s[..n]` byte slice - // would panic when the cut lands mid-codepoint. - - #[test] - fn truncate_multibyte_purl_does_not_panic() { - // 30 three-byte chars (90 bytes, 30 chars). The old purl path sliced - // `&purl[..37]` once `len() > 40`; byte 37 splits a codepoint here. - let purl = format!("pkg:npm/{}", "日".repeat(30)); - let out = truncate_with_ellipsis(&purl, 40); - assert!(out.chars().count() <= 40); - } - - #[test] - fn truncate_multibyte_description_truncates_on_char_boundary() { - // 100 two-byte chars; description column truncates at 72. - let desc = "é".repeat(100); - let out = truncate_with_ellipsis(&desc, 72); - assert_eq!(out.chars().count(), 72); - assert!(out.ends_with("...")); - } - - #[test] - fn truncate_multibyte_summary_truncates_on_char_boundary() { - // Summary column truncates at 76. - let summary = "—".repeat(100); // em dash, 3 bytes each - let out = truncate_with_ellipsis(&summary, 76); - assert_eq!(out.chars().count(), 76); - assert!(out.ends_with("...")); - } - // ---- cross-mode ledger takeover (hosted ⇄ vendored) -------------------- // Switching a project's patch mode rewires the lockfile to the new mode // but leaves the OLD mode's ledger on disk asserting stale wiring. These @@ -3127,6 +3195,22 @@ mod tests { assert!(overlapping_ledger_purls(root).await.is_empty()); } + #[test] + fn selection_args_never_leaves_json_at_the_patch_menu() { + let json = selection_args(&GlobalArgs { + json: true, + ..GlobalArgs::default() + }); + assert!(!json.json && json.yes, "--json selects like --yes"); + let human = selection_args(&GlobalArgs::default()); + assert!(!human.json && !human.yes, "a human run keeps its menu"); + let yes = selection_args(&GlobalArgs { + yes: true, + ..GlobalArgs::default() + }); + assert!(yes.yes); + } + #[test] fn takeover_detail_names_direction_package_and_remediation() { let purls = vec!["pkg:npm/minimist@1.2.2".to_string()]; diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs new file mode 100644 index 00000000..65af51bc --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -0,0 +1,987 @@ +//! Pure text builders for `scan`'s human output: the results table, the +//! summary and prompt lines, the per-patch preview, and the hints. No I/O +//! and no terminal state (color is decided by the caller and passed in), +//! so every string is unit-testable byte for byte. + +use std::collections::HashMap; + +use socket_patch_core::api::types::{PatchSearchResult, VulnerabilityResponse}; + +use super::discovery::severity_order; +use crate::ui::{self, plural, Align}; + +/// Visible widths of the table's PATCHES and SEVERITY columns. +pub(super) const PATCHES_COL: usize = 8; +pub(super) const SEVERITY_COL: usize = 16; +/// The PACKAGE column grows to the longest PURL, up to this many columns; +/// longer PURLs are middle-elided (keeping `@version`). +pub(super) const MAX_PURL_COL: usize = 60; +/// Vulnerability ids shown per table row before `(+N)` (unless `--verbose`). +const VULN_IDS_SHOWN: usize = 2; +/// Per-patch preview limits for API free text (unless `--verbose`). +const SUMMARY_MAX: usize = 76; +const DESCRIPTION_MAX: usize = 72; + +const HEADER_PACKAGE: &str = "PACKAGE"; + +/// Width of the PACKAGE column for these (display) PURLs: the longest, +/// never narrower than the header and never wider than [`MAX_PURL_COL`]. +pub(super) fn purl_col_width<'a>(purls: impl IntoIterator) -> usize { + purls + .into_iter() + .map(|p| p.chars().count()) + .max() + .unwrap_or(0) + .clamp(HEADER_PACKAGE.len(), MAX_PURL_COL) +} + +/// Fit a PURL into `max` characters, keeping the `@version` (and any +/// `?qualifiers`) tail and eliding the middle of the name: +/// `pkg:npm/@typescript-eslint/typescript-estree@6.0.0` at 40 becomes +/// `pkg:npm/@typ...t/typescript-estree@6.0.0`. Falls back to a plain +/// truncation when the tail alone does not fit. +pub(super) fn elide_purl(purl: &str, max: usize) -> String { + let chars: Vec = purl.chars().collect(); + if chars.len() <= max { + return purl.to_string(); + } + const DOTS: &str = "..."; + // The version starts at the last `@` that is not the scope marker + // right after the type (`pkg:npm/@scope/...`). + let at = chars + .iter() + .rposition(|&c| c == '@') + .filter(|&i| i > 0 && chars[i - 1] != '/'); + let Some(at) = at else { + return ui::truncate(purl, max); + }; + let tail = &chars[at..]; + // Keep at least a few characters of the name on each side. + let budget = max.saturating_sub(tail.len() + DOTS.len()); + if budget < 8 { + return ui::truncate(purl, max); + } + // The end of the name (the package's own name) says more than the + // start (type and scope), so it gets the larger share. + let name = &chars[..at]; + let head_len = budget * 2 / 5; + let end_len = budget - head_len; + let head: String = name[..head_len].iter().collect(); + let end: String = name[name.len() - end_len..].iter().collect(); + let tail: String = tail.iter().collect(); + format!("{head}{DOTS}{end}{tail}") +} + +/// The table's column header, aligned like [`table_row`]. +pub(super) fn table_header(purl_w: usize) -> String { + table_row( + purl_w, + HEADER_PACKAGE, + "PATCHES", + "SEVERITY", + "VULNERABILITIES", + "", + ) +} + +/// One table row. Cells may carry color: padding counts visible +/// characters only, so colored and plain rows line up. `count` is +/// right-aligned; `markers` (`[UPDATE]`, ...) trail the vulnerabilities. +pub(super) fn table_row( + purl_w: usize, + purl: &str, + count: &str, + severity: &str, + vulns: &str, + markers: &str, +) -> String { + format!( + "{} {} {} {vulns}{markers}", + ui::pad(purl, purl_w, Align::Left), + ui::pad(count, PATCHES_COL, Align::Right), + ui::pad(severity, SEVERITY_COL, Align::Left), + ) +} + +/// The `=` rule above and below the table: as wide as its widest line, +/// capped at `cap` columns (the terminal width) so it never wraps. +pub(super) fn ruler<'a>(lines: impl IntoIterator, cap: Option) -> String { + let widest = lines.into_iter().map(ui::visible_width).max().unwrap_or(0); + let width = cap.map_or(widest, |c| widest.min(c)).max(1); + "=".repeat(width) +} + +/// The VULNERABILITIES cell: the first two ids plus `(+N)` for the rest of +/// `vulns.count`, every id under `--verbose`, `-` when there are none. +pub(super) fn vuln_cell(vulns: &super::discovery::VulnIds, verbose: bool) -> String { + if verbose { + return if vulns.all.is_empty() { + "-".to_string() + } else { + vulns.all.join(", ") + }; + } + let shown = &vulns.primary[..vulns.primary.len().min(VULN_IDS_SHOWN)]; + let extra = vulns.count.saturating_sub(shown.len()); + match (shown.is_empty(), extra) { + (true, 0) => "-".to_string(), + (true, n) => format!("(+{n})"), + (false, 0) => shown.join(", "), + (false, n) => format!("{} (+{n})", shown.join(", ")), + } +} + +/// The line under the table: how many packages have patches and how many +/// of those patches this account can download. +pub(super) fn summary_line(packages: usize, patches: usize, all_accessible: bool) -> String { + let kind = if all_accessible { + ("available patch", "available patches") + } else { + ("free patch", "free patches") + }; + format!( + "Summary: {} with {}", + plural(packages, "package", "packages"), + plural(patches, kind.0, kind.1) + ) +} + +/// The indented follow-up to [`summary_line`] for free accounts. +pub(super) fn paid_extra_line(paid: usize) -> String { + let verb = if paid == 1 { "is" } else { "are" }; + format!( + " + {} {verb} available with a paid subscription", + plural(paid, "additional patch", "additional patches") + ) +} + +/// How many table rows carry `[UPDATE]`. +pub(super) fn updates_line(n: usize) -> String { + if n == 1 { + "1 package has a newer patch available.".to_string() + } else { + format!("{n} packages have newer patches available.") + } +} + +/// The note after the crawl summary for lockfile-only packages. +pub(super) fn lockfile_only_note(n: usize) -> String { + let verb = if n == 1 { "is" } else { "are" }; + format!( + "Note: {} from project lockfiles {verb} not yet installed (lockfile-only).", + plural(n, "package", "packages") + ) +} + +/// What an empty crawl says, naming the filter that emptied it when there +/// was one (`--ecosystems`, PATH scoping) instead of a generic +/// "install first" hint. +pub(crate) fn no_packages_message( + global: bool, + ecosystems: Option<&[String]>, + paths: &[String], +) -> String { + if global { + return "No global packages found.".to_string(); + } + if !paths.is_empty() { + return format!( + "No installed packages found under {}.", + quoted_list(paths, "the given path", "the given paths") + ); + } + if let Some(list) = ecosystems.filter(|l| !l.is_empty()) { + return format!("No {} packages found.", list.join("/")); + } + "No packages found. Run your package manager's install first.".to_string() +} + +/// `the given path: a` / `the given paths: a, b`. +fn quoted_list(items: &[String], one: &str, many: &str) -> String { + let label = if items.len() == 1 { one } else { many }; + format!("{label}: {}", items.join(", ")) +} + +/// Warning printed when `--prune` cannot run because nothing was crawled +/// (pruning every manifest entry is too destructive to do implicitly). +/// The warning for one failed API batch of several (the scan goes on +/// with the others). A one-batch scan prints only [`all_batches_failed`]. +pub(super) fn batch_failed_warning(batch: usize, total: usize, err: &str) -> String { + format!("Warning: API batch {batch} of {total} failed: {err}") +} + +/// The error when every API batch failed. +pub(super) fn all_batches_failed(total: usize, err: &str) -> String { + if total == 1 { + format!("Error: The API query failed: {err}") + } else { + format!("Error: All {total} API batch queries failed (last error: {err})") + } +} + +pub(super) const PRUNE_SKIPPED_EMPTY: &str = "Warning: --prune skipped: no installed packages \ + were found, and pruning every manifest entry is too destructive to do implicitly; run \ + `socket-patch repair` to clean up .socket/ explicitly."; + +/// Note printed when the user declines the download prompt of a +/// `--prune` run: nothing is changed, the GC included. +pub(super) const PRUNE_SKIPPED_DECLINED: &str = "Note: --prune skipped (download declined)."; + +/// What the confirm prompt / dry-run line is about to do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Plan { + /// Agent mode: download `n` patches and apply them in place. + Apply(usize), + /// Vendored mode: download `n` patches and vendor them. + Vendor(usize), +} + +/// The confirm prompt for `plan` (the `[Y/n]` hint is added by the prompt). +pub(super) fn confirm_prompt(plan: Plan) -> String { + match plan { + Plan::Apply(n) => format!("Download and apply {}?", plural(n, "patch", "patches")), + Plan::Vendor(n) => format!("Download and vendor {}?", plural(n, "patch", "patches")), + } +} + +/// The hosted-mode confirm prompt (the `[Y/n]` hint is added by the prompt). +pub(super) fn hosted_confirm_prompt(n: usize) -> String { + format!( + "Redirect {} to the hosted patch server?", + plural(n, "package", "packages") + ) +} + +/// The `--dry-run` headline. `refused` counts the patches the vendored +/// preflight would refuse (listed right after as `[would-refuse]` lines), +/// so the headline never promises to vendor what the wet run refuses. +pub(super) fn dry_run_line(plan: Plan, refused: usize) -> String { + let what = match plan { + Plan::Apply(n) => format!("download and apply {}", plural(n, "patch", "patches")), + Plan::Vendor(n) if refused > 0 => format!( + "download and vendor {} of {} ({refused} would be refused)", + n.saturating_sub(refused), + plural(n, "patch", "patches") + ), + Plan::Vendor(n) => format!("download and vendor {}", plural(n, "patch", "patches")), + }; + format!("[dry-run] Would {what}. No changes made.") +} + +/// Lines printed after the user declines the prompt: how to pick patches +/// one at a time in the same mode. +pub(super) fn decline_hint(vendor: bool) -> [String; 3] { + if vendor { + [ + "To vendor a single patch, run:".to_string(), + " socket-patch get --mode vendored".to_string(), + " socket-patch get --mode vendored".to_string(), + ] + } else { + [ + "To apply a single patch, run:".to_string(), + " socket-patch get ".to_string(), + " socket-patch get ".to_string(), + ] + } +} + +/// Lines printed after the user declines the hosted-mode prompt: how to +/// redirect packages one at a time (`get` defaults to agent mode, so the +/// mode is named). +pub(super) fn hosted_decline_hint() -> [String; 3] { + [ + "To redirect a package, run:".to_string(), + " socket-patch get --mode hosted".to_string(), + " socket-patch get --mode hosted".to_string(), + ] +} + +/// Printed (vendored mode, before the prompt) for a selected package whose +/// installed bytes differ from the patch baseline: vendoring still +/// proceeds, with the verified patched content. +pub(super) fn baseline_mismatch_line(purl: &str) -> String { + format!( + " {purl}: installed content differs from patch baseline; the patched content will be vendored" + ) +} + +/// `[skip]` line for a package owned by the vendored mode. +pub(super) fn vendored_skip_line(purl: &str) -> String { + format!(" [skip] {purl} (vendored; run `socket-patch scan --mode vendored` to update it)") +} + +/// `[skip]` line for a lockfile-only package in agent mode. +pub(super) fn not_installed_skip_line(purl: &str) -> String { + format!( + " [skip] {purl} (not installed; run your package manager's install first, \ + or `socket-patch scan --mode vendored` to vendor it from the lockfile)" + ) +} + +/// `[skip]` line for a selection the manifest already records. +pub(super) fn already_recorded_line(purl: &str, uuid: &str) -> String { + format!( + " [skip] {purl} (already recorded: {})", + super::super::get::short_uuid(uuid) + ) +} + +/// Printed when every selection is already recorded (agent mode). +pub(super) const ALL_ALREADY_RECORDED: &str = + "All selected patches are already recorded in the manifest; run `socket-patch apply` to re-apply them."; + +/// The terminal error when no package's patch details could be fetched. +pub(super) fn fetch_details_failed(failed: &[(String, String)]) -> String { + match failed { + [] => "Error: could not fetch patch details.".to_string(), + [(purl, err)] => format!("Error: could not fetch patch details for {purl}: {err}"), + [.., (_, last)] => format!( + "Error: could not fetch patch details for any of the {} (last error: {last})", + plural(failed.len(), "package", "packages") + ), + } +} + +/// What the manifest already records for a package the preview offers a +/// different patch for. +pub(super) struct Replaces<'a> { + pub uuid: &'a str, + /// Vulnerability ids the recorded patch fixes. + pub vuln_ids: Vec<&'a str>, +} + +/// Everything [`patch_block`] needs about one selected patch. +pub(super) struct PatchBlock<'a> { + pub patch: &'a PatchSearchResult, + /// The severity label, already colored by the caller. + pub severity: &'a str, + pub replaces: Option>, + /// `--verbose`: no truncation of API free text. + pub verbose: bool, +} + +/// The patch's vulnerabilities, worst first, then by id: a stable order +/// (the API map is a `HashMap`, whose order changes between runs). +fn sorted_vulns( + vulns: &HashMap, +) -> Vec<(&String, &VulnerabilityResponse)> { + let mut v: Vec<_> = vulns.iter().collect(); + v.sort_by(|a, b| { + severity_order(&a.1.severity) + .cmp(&severity_order(&b.1.severity)) + .then_with(|| vuln_label(a.0, a.1).cmp(&vuln_label(b.0, b.1))) + }); + v +} + +/// A vulnerability's display id(s): its CVEs, or the advisory id when it +/// has none. +fn vuln_label(id: &str, vuln: &VulnerabilityResponse) -> String { + if vuln.cves.is_empty() { + id.to_string() + } else { + let mut cves = vuln.cves.clone(); + cves.sort(); + cves.join(", ") + } +} + +/// The worst severity among the patch's vulnerabilities, if any. +pub(super) fn highest_severity(patch: &PatchSearchResult) -> Option<&str> { + patch + .vulnerabilities + .values() + .map(|v| v.severity.as_str()) + .min_by_key(|s| severity_order(s)) +} + +/// The warning for a replacement that leaves some of the recorded patch's +/// vulnerabilities unfixed (fewer, or different ones). The caller prints +/// it on stderr, right under the block's first line. `None` when the new +/// patch fixes everything the recorded one does. +pub(super) fn replacement_warning(b: &PatchBlock) -> Option { + let r = b.replaces.as_ref()?; + let old: std::collections::HashSet<&str> = r.vuln_ids.iter().copied().collect(); + let missing = old + .iter() + .filter(|id| !b.patch.vulnerabilities.contains_key(**id)) + .count(); + if missing == 0 { + None + } else if old.len() == 1 { + Some( + " Warning: this patch does not fix the vulnerability the recorded patch fixes" + .to_string(), + ) + } else if missing == old.len() { + Some(format!( + " Warning: this patch fixes none of the {} vulnerabilities the recorded patch fixes", + old.len() + )) + } else { + Some(format!( + " Warning: this patch does not fix {missing} of the {} vulnerabilities the recorded patch fixes", + old.len() + )) + } +} + +/// One patch in the "Patches to apply/vendor" preview, ending with a +/// blank line. +pub(super) fn patch_block(b: &PatchBlock) -> Vec { + let p = b.patch; + let fit = |s: &str, max: usize| ui::truncate(s, if b.verbose { usize::MAX } else { max }); + let mut lines = Vec::new(); + let replaces = b + .replaces + .as_ref() + .map(|r| format!(" (replaces {})", super::super::get::short_uuid(r.uuid))) + .unwrap_or_default(); + lines.push(format!( + " {} [{}] {}{replaces}", + socket_patch_core::utils::purl::normalize_purl(&p.purl), + p.tier.to_uppercase(), + b.severity, + )); + let vulns = sorted_vulns(&p.vulnerabilities); + if !vulns.is_empty() { + let ids: Vec = vulns.iter().map(|(id, v)| vuln_label(id, v)).collect(); + lines.push(format!(" Fixes: {}", ids.join(", "))); + } + for (id, v) in &vulns { + if !v.summary.trim().is_empty() { + lines.push(format!( + " - {}: {}", + vuln_label(id, v), + fit(&v.summary, SUMMARY_MAX) + )); + } + } + let desc = fit(&p.description, DESCRIPTION_MAX); + if !desc.is_empty() { + lines.push(format!(" {desc}")); + } + lines.push(String::new()); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn batch_failure_lines() { + assert_eq!( + batch_failed_warning(2, 3, "Network error: refused"), + "Warning: API batch 2 of 3 failed: Network error: refused" + ); + assert_eq!( + all_batches_failed(1, "Network error: refused"), + "Error: The API query failed: Network error: refused" + ); + assert_eq!( + all_batches_failed(3, "Network error: refused"), + "Error: All 3 API batch queries failed (last error: Network error: refused)" + ); + } + + fn vuln(cves: &[&str], summary: &str, severity: &str) -> VulnerabilityResponse { + VulnerabilityResponse { + cves: cves.iter().map(|s| s.to_string()).collect(), + summary: summary.to_string(), + severity: severity.to_string(), + description: String::new(), + } + } + + fn patch(vulns: &[(&str, VulnerabilityResponse)], description: &str) -> PatchSearchResult { + PatchSearchResult { + uuid: "884e9f6d-0000-0000-0000-000000000000".to_string(), + purl: "pkg:npm/%40scope/nuxt@4.5.0".to_string(), + published_at: String::new(), + description: description.to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + vulnerabilities: vulns + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(), + } + } + + fn block<'a>(p: &'a PatchSearchResult, verbose: bool) -> PatchBlock<'a> { + PatchBlock { + patch: p, + severity: "HIGH", + replaces: None, + verbose, + } + } + + // ---- table ------------------------------------------------------------ + + #[test] + fn purl_col_width_tracks_longest_within_bounds() { + assert_eq!(purl_col_width([]), HEADER_PACKAGE.len()); + assert_eq!(purl_col_width(["a"]), HEADER_PACKAGE.len()); + assert_eq!(purl_col_width(["pkg:npm/minimist@1.2.5"]), 22); + let long = "x".repeat(100); + assert_eq!(purl_col_width([long.as_str()]), MAX_PURL_COL); + // Chars, not bytes. + assert_eq!(purl_col_width(["pkg:npm/日本語@1.0.0"]), 17); + } + + #[test] + fn elide_purl_keeps_version() { + let purl = "pkg:npm/@typescript-eslint/typescript-estree@6.0.0"; + assert_eq!(elide_purl(purl, 60), purl); + let out = elide_purl(purl, 40); + assert_eq!(out, "pkg:npm/@typ...t/typescript-estree@6.0.0"); + assert_eq!(out.chars().count(), 40); + } + + #[test] + fn elide_purl_never_exceeds_max() { + let purl = "pkg:npm/@typescript-eslint/typescript-estree@6.0.0"; + for max in 0..60 { + let out = elide_purl(purl, max); + assert!(out.chars().count() <= max, "max={max} out={out:?}"); + } + } + + #[test] + fn elide_purl_without_version_or_room_truncates() { + let no_version = format!("pkg:generic/{}", "a".repeat(60)); + assert_eq!(elide_purl(&no_version, 20), ui::truncate(&no_version, 20)); + // Only the scope `@`: not a version marker. + let scoped = format!("pkg:npm/@{}", "s".repeat(60)); + assert_eq!(elide_purl(&scoped, 20), ui::truncate(&scoped, 20)); + // A huge version leaves no room for the name: plain truncation. + let big = format!("pkg:npm/foo@{}", "9".repeat(40)); + assert_eq!(elide_purl(&big, 30), ui::truncate(&big, 30)); + } + + #[test] + fn elide_purl_multibyte_is_char_safe() { + let purl = format!("pkg:npm/{}@1.0.0", "日".repeat(50)); + let out = elide_purl(&purl, 30); + assert!(out.ends_with("@1.0.0"), "{out}"); + assert_eq!(out.chars().count(), 30); + } + + #[test] + fn table_colored_and_plain_rows_align_identically() { + let w = 24; + let vulns_at = w + 2 + PATCHES_COL + 2 + SEVERITY_COL + 2; + let header = table_header(w); + assert_eq!(header.find("VULNERABILITIES"), Some(vulns_at)); + assert_eq!( + header.find("PATCHES").map(|i| i + "PATCHES".len()), + Some(w + 2 + PATCHES_COL) + ); + let row = |on: bool, sev: &str, paid: bool| { + let count = if paid { + format!("0+{}", ui::paint("2", "33", on)) + } else { + "1".to_string() + }; + table_row( + w, + "pkg:npm/minimist@1.2.5", + &count, + &ui::severity(sev, on), + "CVE-2021-44906", + &ui::paint(" [UPDATE]", "33", on), + ) + }; + for sev in ["CRITICAL", "HIGH", "MODERATE", "LOW", "unknown"] { + for paid in [false, true] { + let plain = row(false, sev, paid); + assert_eq!(ui::strip_ansi(&row(true, sev, paid)), plain); + assert_eq!(plain.find("CVE-"), Some(vulns_at), "{plain:?}"); + assert!(plain.ends_with("CVE-2021-44906 [UPDATE]"), "{plain:?}"); + let count_end = w + 2 + PATCHES_COL; + let want = if paid { "0+2" } else { "1" }; + assert_eq!(&plain[count_end - want.len()..count_end], want); + } + } + } + + #[test] + fn table_row_exact_string() { + assert_eq!( + table_row(10, "pkg:a@1", "1", "HIGH", "CVE-1", " [UPDATE]"), + "pkg:a@1 1 HIGH CVE-1 [UPDATE]" + ); + } + + #[test] + fn ruler_fits_widest_line_and_cap() { + assert_eq!(ruler(["abc", "abcdef"], None), "======"); + assert_eq!(ruler(["abc", "abcdef"], Some(4)), "===="); + assert_eq!(ruler(["\x1b[31mab\x1b[0m"], None), "=="); + assert_eq!(ruler([], None), "="); + } + + fn vids(primary: &[&str], all: &[&str], count: usize) -> super::super::discovery::VulnIds { + super::super::discovery::VulnIds { + primary: primary.iter().map(|s| s.to_string()).collect(), + all: all.iter().map(|s| s.to_string()).collect(), + count, + } + } + + #[test] + fn vuln_cell_caps_unless_verbose() { + let four = ["CVE-1", "CVE-2", "CVE-3", "CVE-4"]; + assert_eq!( + vuln_cell(&vids(&four, &four, 4), false), + "CVE-1, CVE-2 (+2)" + ); + assert_eq!( + vuln_cell(&vids(&four, &four, 4), true), + "CVE-1, CVE-2, CVE-3, CVE-4" + ); + assert_eq!( + vuln_cell(&vids(&four[..2], &four[..2], 2), false), + "CVE-1, CVE-2" + ); + assert_eq!(vuln_cell(&vids(&four[..1], &four[..1], 1), false), "CVE-1"); + assert_eq!(vuln_cell(&vids(&[], &[], 0), false), "-"); + assert_eq!(vuln_cell(&vids(&[], &[], 0), true), "-"); + } + + #[test] + fn vuln_cell_counts_vulnerabilities_not_ids() { + // 1 CVE + its alias + a GHSA-only advisory: two vulnerabilities. + let v = vids(&["CVE-1"], &["CVE-1", "GHSA-a", "GHSA-b"], 2); + assert_eq!(vuln_cell(&v, false), "CVE-1 (+1)"); + assert_eq!(vuln_cell(&v, true), "CVE-1, GHSA-a, GHSA-b"); + let v = vids(&["CVE-1", "CVE-2", "GHSA-c"], &[], 5); + assert_eq!(vuln_cell(&v, false), "CVE-1, CVE-2 (+3)"); + } + + // ---- summary lines ------------------------------------------------------- + + #[test] + fn summary_line_singular_and_plural() { + assert_eq!( + summary_line(1, 1, true), + "Summary: 1 package with 1 available patch" + ); + assert_eq!( + summary_line(2, 4, true), + "Summary: 2 packages with 4 available patches" + ); + assert_eq!( + summary_line(1, 0, false), + "Summary: 1 package with 0 free patches" + ); + assert_eq!( + summary_line(3, 1, false), + "Summary: 3 packages with 1 free patch" + ); + } + + #[test] + fn paid_extra_line_agrees_in_number() { + assert_eq!( + paid_extra_line(1), + " + 1 additional patch is available with a paid subscription" + ); + assert_eq!( + paid_extra_line(3), + " + 3 additional patches are available with a paid subscription" + ); + } + + #[test] + fn updates_line_agrees_in_number() { + assert_eq!(updates_line(1), "1 package has a newer patch available."); + assert_eq!(updates_line(2), "2 packages have newer patches available."); + } + + #[test] + fn lockfile_only_note_agrees_in_number() { + assert_eq!( + lockfile_only_note(1), + "Note: 1 package from project lockfiles is not yet installed (lockfile-only)." + ); + assert_eq!( + lockfile_only_note(3), + "Note: 3 packages from project lockfiles are not yet installed (lockfile-only)." + ); + } + + #[test] + fn no_packages_message_names_the_filter() { + assert_eq!( + no_packages_message(true, None, &[]), + "No global packages found." + ); + assert_eq!( + no_packages_message(false, None, &["apps/**".to_string()]), + "No installed packages found under the given path: apps/**." + ); + assert_eq!( + no_packages_message(false, None, &["a".to_string(), "b".to_string()]), + "No installed packages found under the given paths: a, b." + ); + let ecos = vec!["pypi".to_string(), "cargo".to_string()]; + assert_eq!( + no_packages_message(false, Some(&ecos), &[]), + "No pypi/cargo packages found." + ); + let generic = no_packages_message(false, Some(&[]), &[]); + assert_eq!( + generic, + "No packages found. Run your package manager's install first." + ); + assert_eq!(generic, no_packages_message(false, None, &[])); + } + + // ---- prompt / dry-run / hints ------------------------------------------- + + #[test] + fn confirm_prompt_counts_patches() { + assert_eq!( + confirm_prompt(Plan::Apply(1)), + "Download and apply 1 patch?" + ); + assert_eq!( + confirm_prompt(Plan::Apply(3)), + "Download and apply 3 patches?" + ); + assert_eq!( + confirm_prompt(Plan::Vendor(2)), + "Download and vendor 2 patches?" + ); + assert_eq!( + hosted_confirm_prompt(1), + "Redirect 1 package to the hosted patch server?" + ); + assert_eq!( + hosted_confirm_prompt(2), + "Redirect 2 packages to the hosted patch server?" + ); + } + + #[test] + fn dry_run_line_counts_refusals() { + assert_eq!( + dry_run_line(Plan::Apply(1), 0), + "[dry-run] Would download and apply 1 patch. No changes made." + ); + assert_eq!( + dry_run_line(Plan::Vendor(2), 0), + "[dry-run] Would download and vendor 2 patches. No changes made." + ); + assert_eq!( + dry_run_line(Plan::Vendor(2), 2), + "[dry-run] Would download and vendor 0 of 2 patches (2 would be refused). No changes made." + ); + assert_eq!( + dry_run_line(Plan::Vendor(1), 1), + "[dry-run] Would download and vendor 0 of 1 patch (1 would be refused). No changes made." + ); + } + + #[test] + fn decline_hint_matches_mode() { + assert_eq!(decline_hint(false)[0], "To apply a single patch, run:"); + assert!(decline_hint(false).iter().all(|l| !l.contains("--mode"))); + assert_eq!(decline_hint(true)[0], "To vendor a single patch, run:"); + assert!(decline_hint(true)[1..] + .iter() + .all(|l| l.ends_with(" --mode vendored"))); + assert_eq!(hosted_decline_hint()[0], "To redirect a package, run:"); + assert!(hosted_decline_hint()[1..] + .iter() + .all(|l| l.ends_with(" --mode hosted"))); + } + + #[test] + fn skip_lines_use_documented_flag() { + assert_eq!( + vendored_skip_line("pkg:npm/x@1"), + " [skip] pkg:npm/x@1 (vendored; run `socket-patch scan --mode vendored` to update it)" + ); + let l = not_installed_skip_line("pkg:npm/x@1"); + assert!(l.contains("`socket-patch scan --mode vendored`"), "{l}"); + assert!(!l.contains("--vendor`"), "{l}"); + assert_eq!( + already_recorded_line("pkg:npm/x@1", "884e9f6d-aaaa"), + " [skip] pkg:npm/x@1 (already recorded: 884e9f6d)" + ); + } + + #[test] + fn fetch_details_failed_names_cause() { + assert_eq!( + fetch_details_failed(&[]), + "Error: could not fetch patch details." + ); + assert_eq!( + fetch_details_failed(&[("pkg:npm/a@1".into(), "404".into())]), + "Error: could not fetch patch details for pkg:npm/a@1: 404" + ); + assert_eq!( + fetch_details_failed(&[ + ("pkg:npm/a@1".into(), "404".into()), + ("pkg:npm/b@1".into(), "502".into()) + ]), + "Error: could not fetch patch details for any of the 2 packages (last error: 502)" + ); + } + + // ---- patch block --------------------------------------------------------- + + #[test] + fn patch_block_exact_and_sorted() { + let p = patch( + &[ + ("GHSA-zzzz", vuln(&["CVE-2026-2"], "low one", "low")), + ("GHSA-cccc", vuln(&[], "no-cve issue", "high")), + ( + "GHSA-aaaa", + vuln(&["CVE-2026-9", "CVE-2026-1"], "crit", "critical"), + ), + ("GHSA-bbbb", vuln(&["CVE-2026-5"], "", "high")), + ], + "Fixes things", + ); + assert_eq!( + patch_block(&block(&p, false)), + vec![ + " pkg:npm/@scope/nuxt@4.5.0 [FREE] HIGH", + " Fixes: CVE-2026-1, CVE-2026-9, CVE-2026-5, GHSA-cccc, CVE-2026-2", + " - CVE-2026-1, CVE-2026-9: crit", + " - GHSA-cccc: no-cve issue", + " - CVE-2026-2: low one", + " Fixes things", + "", + ] + ); + } + + #[test] + fn patch_block_is_deterministic_across_hash_orders() { + // Build the same map many times: HashMap iteration order varies + // per instance, the rendered block must not. + let make = || { + patch( + &(0..12) + .map(|i| { + ( + Box::leak(format!("GHSA-{i:02}").into_boxed_str()) as &str, + vuln(&[&format!("CVE-2026-{i:02}")], "s", "high"), + ) + }) + .collect::>(), + "", + ) + }; + let first = patch_block(&block(&make(), false)); + for _ in 0..20 { + assert_eq!(patch_block(&block(&make(), false)), first); + } + } + + #[test] + fn patch_block_truncates_unless_verbose() { + let long = "word ".repeat(40); + let p = patch(&[("GHSA-a", vuln(&[], &long, "low"))], &long); + let lines = patch_block(&block(&p, false)); + assert!(lines[2].ends_with("..."), "{lines:?}"); + assert!(lines[2].chars().count() <= " - GHSA-a: ".len() + SUMMARY_MAX); + assert!(lines[3].chars().count() <= 4 + DESCRIPTION_MAX); + let lines = patch_block(&block(&p, true)); + assert!(!lines[2].ends_with("..."), "{lines:?}"); + assert_eq!(lines[3], format!(" {}", long.trim_end())); + } + + #[test] + fn patch_block_multibyte_summary_is_char_safe() { + let p = patch(&[("GHSA-a", vuln(&[], &"漏".repeat(200), "low"))], ""); + let lines = patch_block(&block(&p, false)); + assert_eq!( + lines[2].chars().count(), + " - GHSA-a: ".chars().count() + SUMMARY_MAX + ); + } + + #[test] + fn patch_block_marks_replacement() { + let p = patch(&[("GHSA-a", vuln(&["CVE-1"], "", "high"))], ""); + let mut b = block(&p, false); + b.replaces = Some(Replaces { + uuid: "11111111-2222", + vuln_ids: vec!["GHSA-a", "GHSA-b", "GHSA-c"], + }); + assert_eq!( + patch_block(&b), + vec![ + " pkg:npm/@scope/nuxt@4.5.0 [FREE] HIGH (replaces 11111111)", + " Fixes: CVE-1", + "", + ] + ); + } + + #[test] + fn replacement_warning_fires_whenever_a_recorded_vuln_is_dropped() { + let p = patch( + &[ + ("GHSA-a", vuln(&["CVE-1"], "", "high")), + ("GHSA-x", vuln(&[], "", "low")), + ], + "", + ); + let mut b = block(&p, false); + assert_eq!(replacement_warning(&b), None); + let with = |ids: Vec<&'static str>| Replaces { + uuid: "11111111-2222", + vuln_ids: ids, + }; + // Fewer (subset). + b.replaces = Some(with(vec!["GHSA-a", "GHSA-b", "GHSA-c"])); + assert_eq!( + replacement_warning(&b).as_deref(), + Some(" Warning: this patch does not fix 2 of the 3 vulnerabilities the recorded patch fixes") + ); + // Different ones (not a subset, same size). + b.replaces = Some(with(vec!["GHSA-a", "GHSA-b"])); + assert_eq!( + replacement_warning(&b).as_deref(), + Some(" Warning: this patch does not fix 1 of the 2 vulnerabilities the recorded patch fixes") + ); + // Disjoint. + b.replaces = Some(with(vec!["GHSA-z"])); + assert_eq!( + replacement_warning(&b).as_deref(), + Some(" Warning: this patch does not fix the vulnerability the recorded patch fixes") + ); + b.replaces = Some(with(vec!["GHSA-y", "GHSA-z"])); + assert_eq!( + replacement_warning(&b).as_deref(), + Some(" Warning: this patch fixes none of the 2 vulnerabilities the recorded patch fixes") + ); + // Superset or equal: no regression. + b.replaces = Some(with(vec!["GHSA-a"])); + assert_eq!(replacement_warning(&b), None); + } + + #[test] + fn highest_severity_picks_worst() { + let p = patch( + &[ + ("a", vuln(&[], "", "low")), + ("b", vuln(&[], "", "critical")), + ], + "", + ); + assert_eq!(highest_severity(&p), Some("critical")); + assert_eq!(highest_severity(&patch(&[], "")), None); + } +} 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 3509658a..212abbe0 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -21,11 +21,11 @@ use socket_patch_core::api::client::ApiClient; use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSearchResult}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; -use socket_patch_core::patch::apply_lock; use socket_patch_core::telemetry::track_patch_vendor_failed; use socket_patch_core::utils::purl::strip_purl_qualifiers; use socket_patch_core::vendor::{load_state, lookup_entry, save_state, VendorState}; use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; use std::path::Path; use std::time::Duration; @@ -38,7 +38,7 @@ use crate::commands::vendor::{ note_classic_migration_risk, track_outcomes_for_vendor, vendor_records, }; use crate::json_envelope::{Command as EnvelopeCommand, Envelope}; -use crate::output::print_json; +use crate::ui::{plural, print_json}; use super::gc::{gc_json, print_gc_vendored_line, run_apply_gc}; use super::{ @@ -174,10 +174,17 @@ async fn run_scan_vendor_step( seed: HashMap>, client: ApiClient, use_public_proxy: bool, + // Print "No vendorable patches in scope." when there are no records at + // all (the step is a silent no-op then). `get --mode vendored` wants + // it; scan's interactive arm prints its own closing line instead. + report_empty: bool, ) -> VendorStepResult { let mut env = Envelope::new(EnvelopeCommand::Vendor); env.dry_run = common.dry_run; if records.is_empty() { + if report_empty && !common.json && !common.silent { + println!("No vendorable patches in scope."); + } return Ok((false, env)); } // The one socket-dir / manifest-path derivation every caller shares. @@ -188,10 +195,11 @@ async fn run_scan_vendor_step( // it as `LockError::Io` (→ `lock_io`). The guard lives to the end of // the step so the ledger migration and the redirect-ledger reconcile // inside `note_vendor_supersedes_redirect` run under the lock too. - let _guard = apply_lock::acquire(&socket_dir, timeout).map_err(|e| { - let (code, message) = lock_failure(&e, timeout); - (code, message, None) - })?; + let _guard = + crate::commands::lock_cli::acquire_with_status(&socket_dir, timeout).map_err(|e| { + let (code, message) = lock_failure(&e, timeout); + (code, message, None) + })?; // Staging probes blobs by the records' hashes; a manifest VIEW over the // in-memory records (a move, not a clone) is all it needs. @@ -413,10 +421,8 @@ async fn migrate_legacy_manifest_records( common, VENDOR_MANIFEST_RECORD_MIGRATED, format!( - "{} manifest record{} moved to the vendor ledger (vendored mode is \ - manifest-free): {}", - dropped.len(), - if dropped.len() == 1 { "" } else { "s" }, + "{} moved to the vendor ledger (vendored mode is manifest-free): {}", + plural(dropped.len(), "manifest record", "manifest records"), dropped.join(", ") ), ), @@ -466,6 +472,7 @@ async fn run_vendor_json_path( api_client, all_packages_with_patches, can_access_paid_patches, + &args.common, false, false, ) @@ -616,10 +623,28 @@ async fn run_vendor_interactive_path( telemetry_token: Option<&str>, telemetry_org: Option<&str>, ) -> i32 { - let (dl_code, _, records, blobs) = + // The download phase is quiet about its own header in vendored mode + // (only the manifest-mode download prints it), so this arm does. + if !args.common.silent && !selected.is_empty() { + // A blank line after an answered prompt; otherwise the listing's + // trailing blank line already separates the sections. + if !args.common.yes && std::io::stdin().is_terminal() { + eprintln!(); + } + eprintln!( + "Downloading {}...", + plural(selected.len(), "patch", "patches") + ); + } + let (dl_code, dl_json, records, blobs) = boxed_download_patch_records(selected, params, api_client, prefetched).await; let mut has_errors = dl_code != 0; - let code = match boxed_scan_vendor_step( + // Patches the download phase could not get (it reported each one). + let download_failed = dl_json["failed"].as_u64().unwrap_or(0); + // The vendor step is a silent no-op on an empty record set (it can't + // know why it is empty); this arm can. + let nothing_to_vendor = records.is_empty(); + let code = match boxed_scan_vendor_step_quiet_empty( &args.common, records, blobs, @@ -630,6 +655,9 @@ async fn run_vendor_interactive_path( { Ok((vendor_errors, venv)) => { has_errors |= vendor_errors; + if nothing_to_vendor && !args.common.silent { + println!("{}", format_nothing_vendored(download_failed)); + } // Run-outcome telemetry, same as the JSON arm above. track_outcomes_for_vendor( has_errors, @@ -641,6 +669,9 @@ async fn run_vendor_interactive_path( .await; i32::from(has_errors) } + // Human mode prints no per-event lines even on success, so the + // carried envelope has no human rendering to feed — JSON mode is + // where the reconcile events must survive (see the JSON fold above). Err((code, message, _envelope)) => { track_patch_vendor_failed( &message, @@ -649,7 +680,7 @@ async fn run_vendor_interactive_path( telemetry_org, ) .await; - eprintln!("Error ({code}): {message}"); + eprintln!("{}", format_vendor_step_error(code, &message)); return 1; } }; @@ -663,20 +694,53 @@ async fn run_vendor_interactive_path( vendored_purls, ) .await; - if !args.common.silent && !gc.pruned.is_empty() { - println!( - "GC: pruned {} manifest entr{}.", - gc.pruned.len(), - if gc.pruned.len() == 1 { "y" } else { "ies" }, - ); - } if !args.common.silent { + // The agent arm's GC line: pruned entries AND swept files. + if let Some(line) = super::gc::format_gc_line(&gc, false) { + println!("{line}"); + } print_gc_vendored_line(&gc); } } code } +/// The closing line when the vendor step had no patch records at all: +/// either the download phase failed or refused every patch (and listed +/// why), or there was nothing to vendor in the first place. +fn format_nothing_vendored(download_failed: u64) -> String { + if download_failed > 0 { + format!( + "Nothing was vendored: {} failed (see above).", + plural(download_failed as usize, "patch", "patches") + ) + } else { + "No vendorable patches in scope.".to_string() + } +} + +/// The human error line (plus any remediation hint) for a failed vendor +/// step: `Error (): .`. The code and message are the ones +/// the JSON envelope carries. +pub(crate) fn format_vendor_step_error(code: &str, message: &str) -> String { + let mut chars = message.trim_end_matches('.').chars(); + let message: String = match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + }; + let mut out = if message.is_empty() { + format!("Error ({code}).") + } else { + format!("Error ({code}): {message}.") + }; + if code == "lock_held" { + // Same advice as the other commands' lock error (lock_cli). + out.push_str("\n "); + out.push_str(crate::commands::lock_cli::HELD_RETRY_HINT); + } + out +} + /// Partition purls matching `skip` out of the selected set and pre-render /// their skip records (sorted by purl) with the contract `error_code`. /// Two skip classes ride this, both removed BEFORE download: @@ -834,6 +898,26 @@ pub(crate) fn boxed_scan_vendor_step<'a>( seed, client, use_public_proxy, + true, + )) +} + +/// [`boxed_scan_vendor_step`] without the empty-run line, for scan's +/// interactive arm, which prints its own (see [`format_nothing_vendored`]). +fn boxed_scan_vendor_step_quiet_empty<'a>( + common: &'a GlobalArgs, + records: HashMap, + seed: HashMap>, + client: ApiClient, + use_public_proxy: bool, +) -> std::pin::Pin + 'a>> { + Box::pin(run_scan_vendor_step( + common, + records, + seed, + client, + use_public_proxy, + false, )) } @@ -1394,3 +1478,51 @@ mod fold_vendored_skips_tests { ); } } + +/// Exact-string tests for the scan-driven vendor step's human lines. +#[cfg(test)] +mod ui_format_tests { + use super::{format_nothing_vendored, format_vendor_step_error}; + + #[test] + fn nothing_vendored_line() { + assert_eq!( + format_nothing_vendored(0), + "No vendorable patches in scope." + ); + assert_eq!( + format_nothing_vendored(1), + "Nothing was vendored: 1 patch failed (see above)." + ); + assert_eq!( + format_nothing_vendored(2), + "Nothing was vendored: 2 patches failed (see above)." + ); + } + + #[test] + fn step_error_is_capitalized_with_one_period() { + assert_eq!( + format_vendor_step_error( + "no_local_source", + "patch artifacts unavailable (offline or download failure)" + ), + "Error (no_local_source): Patch artifacts unavailable (offline or download failure)." + ); + assert_eq!(format_vendor_step_error("x", "done."), "Error (x): Done."); + assert_eq!(format_vendor_step_error("x", ""), "Error (x)."); + assert_eq!(format_vendor_step_error("x", "état"), "Error (x): État."); + } + + #[test] + fn lock_held_carries_the_wait_hint() { + assert_eq!( + format_vendor_step_error( + "lock_held", + "another socket-patch process is operating in this directory" + ), + "Error (lock_held): Another socket-patch process is operating in this directory.\n \ + Wait for it to finish, or retry with --lock-timeout to wait for the lock." + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 855c3e73..e46785a9 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -5,13 +5,13 @@ use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{PatchManifest, SetupConfig}; use socket_patch_core::package_json::detect::{is_setup_configured_str, PackageManager}; use socket_patch_core::package_json::find::{ - detect_package_manager, find_package_json_files, PackageJsonLocation, WorkspaceType, + detect_package_manager, find_package_json_files, PackageJsonFindResult, PackageJsonLocation, + WorkspaceType, }; use socket_patch_core::package_json::update::{ remove_package_json, update_package_json, RemoveResult, RemoveStatus, UpdateResult, UpdateStatus, }; -use socket_patch_core::patch::apply_lock::acquire; use socket_patch_core::setup::composer::{self, ComposerSetupStatus}; use socket_patch_core::setup::gem::{self, GemSetupStatus}; use socket_patch_core::setup::pypi::detect::{ @@ -23,13 +23,13 @@ use socket_patch_core::setup::pypi::edit::{ }; use socket_patch_core::telemetry::track_patch_setup; use socket_patch_core::vex::applied_patches_with_vendor; -use std::io::{self, Write}; +use std::io; use std::path::{Path, PathBuf}; use std::time::Duration; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::ecosystem_dispatch::find_manifest_package_paths; -use crate::output::{read_yes_no, stdin_is_tty}; +use crate::ui::plural; /// Stringify the detected npm-family manager for telemetry. fn manager_name(pm: PackageManager) -> &'static str { @@ -81,8 +81,8 @@ pub struct SetupArgs { pub check: bool, /// Revert the install hooks that `setup` added: npm `package.json` scripts, - /// the Python `socket-patch[hook]` dependency, and the gem Bundler plugin - /// wiring. + /// the Python `socket-patch[hook]` dependency, the gem Bundler plugin + /// wiring, and the Composer `composer.json` script. #[arg( long = "remove", default_value_t = false, @@ -92,8 +92,8 @@ pub struct SetupArgs { /// Workspace-member path(s) to exclude from setup (comma-separated, relative /// to the repo root). The exclusion is persisted in `.socket/manifest.json` - /// so `setup --check` and a fresh clone honor it without re-passing the flag - /// (CLI_CONTRACT property 9). + /// so `setup --check` and a fresh clone honor it without re-passing the flag. + // CLI_CONTRACT property 9. #[arg(long = "exclude", env = "SOCKET_SETUP_EXCLUDE", value_delimiter = ',')] pub exclude: Vec, @@ -116,27 +116,76 @@ pub async fn run(args: SetupArgs) -> i32 { /// applying the pnpm "root-only" filtering. Returns an empty vec when none are /// found (callers also consider Python before reporting `no_files`). async fn discover(args: &SetupArgs, excludes: &[String]) -> Vec { - if !eco_in_scope(&args.common, Ecosystem::Npm) { + let Some(found) = find_members(args).await else { return Vec::new(); + }; + warn_unmatched_excludes( + &args.common, + &unmatched_excludes(&found, &args.common.cwd, excludes), + ); + select_members(found, &args.common.cwd, excludes) +} + +/// Walk for package.json files; `None` when npm is out of `--ecosystems` scope. +async fn find_members(args: &SetupArgs) -> Option { + if !eco_in_scope(&args.common, Ecosystem::Npm) { + return None; + } + Some(find_package_json_files(&args.common.cwd).await) +} + +/// The exclude values (normalized) that cover no discovered member. Such a +/// value is almost always a typo. Checked against every member, before the +/// pnpm root-only filter. +fn unmatched_excludes( + found: &PackageJsonFindResult, + cwd: &Path, + excludes: &[String], +) -> Vec { + let mut out: Vec = Vec::new(); + for e in excludes { + let n = normalize_rel_path(e); + if n.is_empty() || out.contains(&n) { + continue; + } + let matched = found.files.iter().any(|loc| { + !loc.is_root && is_member_excluded(&loc.path, cwd, std::slice::from_ref(&n)) + }); + if !matched { + out.push(n); + } } - let find_result = find_package_json_files(&args.common.cwd).await; + out +} +/// Say so (human mode only) rather than silently doing nothing. +fn warn_unmatched_excludes(common: &GlobalArgs, unmatched: &[String]) { + if common.json || common.silent { + return; + } + for e in unmatched { + eprintln!("Warning: {}", format_unmatched_exclude(e)); + } +} + +/// Apply the pnpm root-only rule and drop excluded members. +fn select_members( + found: PackageJsonFindResult, + cwd: &Path, + excludes: &[String], +) -> Vec { // For pnpm monorepos, only update root package.json. pnpm runs root // postinstall on `pnpm install`, so workspace-level postinstall scripts are // unnecessary and would fail under pnpm's strict module isolation. - let files: Vec = match find_result.workspace_type { - WorkspaceType::Pnpm => find_result - .files - .into_iter() - .filter(|loc| loc.is_root) - .collect(), - _ => find_result.files, + let files: Vec = match found.workspace_type { + WorkspaceType::Pnpm => found.files.into_iter().filter(|loc| loc.is_root).collect(), + _ => found.files, }; // Property 9: drop excluded workspace members (the root is never excludable). files .into_iter() - .filter(|loc| loc.is_root || !is_member_excluded(&loc.path, &args.common.cwd, excludes)) + .filter(|loc| loc.is_root || !is_member_excluded(&loc.path, cwd, excludes)) .collect() } @@ -161,11 +210,59 @@ fn report_no_files(args: &SetupArgs, counts: &[(&str, i64)]) -> i32 { .expect("serializing an in-memory JSON value cannot fail") ); } else if !args.common.silent { - println!("No package.json, Python, Bundler, or Composer project found"); + println!("{}", no_files_message(&args.common)); } 0 } +/// The setup-capable ecosystems (their `--ecosystems` tokens) and the name +/// of the project each one looks for, in discovery order. +const SETUP_ECOSYSTEMS: &[(Ecosystem, &str)] = &[ + (Ecosystem::Npm, "package.json"), + (Ecosystem::Pypi, "Python"), + (Ecosystem::Gem, "Bundler"), + (Ecosystem::Composer, "Composer"), +]; + +/// The human `no_files` line for this run's `--ecosystems` scope. +fn no_files_message(common: &GlobalArgs) -> String { + let in_scope: Vec<&str> = SETUP_ECOSYSTEMS + .iter() + .filter(|(eco, _)| eco_in_scope(common, *eco)) + .map(|(_, label)| *label) + .collect(); + format_no_files(&in_scope, common.ecosystems.as_deref().unwrap_or(&[])) +} + +/// `No package.json, Python, Bundler, or Composer project found`, narrowed +/// to the in-scope ecosystems. When `--ecosystems` names none that `setup` +/// can wire, "no project found" would be false (the project may well +/// exist), so say that setup has no hook for them instead. +fn format_no_files(in_scope: &[&str], requested: &[String]) -> String { + if in_scope.is_empty() { + return format!( + "Setup has no install hook for: {} (supported: npm, pypi, gem, composer)", + requested.join(", ") + ); + } + format!("No {} project found", join_or(in_scope)) +} + +/// `a`, `a or b`, `a, b, or c`. +fn join_or(items: &[&str]) -> String { + match items { + [] => String::new(), + [one] => (*one).to_string(), + [a, b] => format!("{a} or {b}"), + [init @ .., last] => format!("{}, or {last}", init.join(", ")), + } +} + +/// The warning for an `--exclude` value that matches no workspace member. +fn format_unmatched_exclude(value: &str) -> String { + format!("--exclude {:?} matched no workspace member", value.trim()) +} + fn pathdiff(path: &str, base: &Path) -> String { let p = Path::new(path); p.strip_prefix(base) @@ -173,24 +270,6 @@ fn pathdiff(path: &str, base: &Path) -> String { .unwrap_or_else(|_| path.to_string()) } -/// The setup/remove mutation gate (shared verbatim by both flows): default-no -/// prompt on a TTY, auto-proceed with a stderr note when stdin is not -/// interactive. Returns whether to go ahead. (Deliberately NOT -/// `output::confirm`, whose semantics differ: stderr prompt, `default_yes` -/// honored on non-TTY and empty input.) -fn confirm_proceed(prompt: &str) -> bool { - if !stdin_is_tty() { - eprintln!("Non-interactive mode detected, proceeding automatically."); - return true; - } - print!("{prompt}"); - io::stdout() - .flush() - .expect("failed to write the confirmation prompt to stdout"); - // Only an explicit yes proceeds: empty and unreadable answers abort. - read_yes_no() == Some(true) -} - /// Whether an ecosystem is in scope for this run, honoring the global /// `--ecosystems` filter (`CLI_CONTRACT.md` → "Setup command contract", /// property 2). With no filter (or an empty one) every ecosystem is in scope. @@ -327,7 +406,8 @@ async fn persist_setup_excludes( let path = common.resolved_manifest_path(); let timeout = Duration::from_secs(common.lock_timeout.unwrap_or(0)); - let _lock = match acquire(&common.socket_dir(), timeout) { + let _lock = match crate::commands::lock_cli::acquire_with_status(&common.socket_dir(), timeout) + { Ok(guard) => guard, Err(err) => { let (code, message) = crate::commands::lock_cli::lock_failure(&err, timeout); @@ -707,9 +787,10 @@ async fn build_gem_outcome( "Gem: add the socket-patch Bundler plugin wiring to:" }; out.preview.push(header.to_string()); + let marker = if remove { "-" } else { "+" }; for p in &added_paths { out.preview - .push(format!(" + {}", pathdiff(p, &common.cwd))); + .push(format!(" {marker} {}", pathdiff(p, &common.cwd))); } } @@ -788,9 +869,10 @@ async fn build_composer_outcome( "Composer: add the socket-patch re-apply hook to:" }; out.preview.push(header.to_string()); + let marker = if remove { "-" } else { "+" }; for p in &added_paths { out.preview - .push(format!(" + {}", pathdiff(p, &common.cwd))); + .push(format!(" {marker} {}", pathdiff(p, &common.cwd))); } } @@ -1020,7 +1102,10 @@ async fn append_patch_consistency_entries( let package_paths = find_manifest_package_paths(&purls, common, common.silent || common.json).await; - let vendor = crate::commands::vex::vendor_context_from(common, &manifest, ledger).await; + // The ledger passed here is always readable (an unreadable one was + // reported above and replaced by an empty one), so there is no + // degrade warning left to surface. + let (vendor, _) = crate::commands::vex::vendor_context_from(common, &manifest, ledger).await; let outcome = applied_patches_with_vendor(&manifest, &package_paths, vendor.as_ref()).await; for failed in &outcome.failed { match failed.reason.as_str() { @@ -1053,24 +1138,71 @@ fn merge_outcomes(mut a: SetupOutcome, b: SetupOutcome) -> SetupOutcome { // check // ───────────────────────────────────────────────────────────────────────── -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Debug)] enum CheckState { Configured, NeedsConfiguration, Error, } +/// One `setup --check` status line. A drifted patch (`kind == "patch"`) +/// shows why it is not applied instead of "needs setup", which re-running +/// `setup` would not fix. +fn format_check_line(kind: &str, rel: &str, state: CheckState, err: Option<&str>) -> String { + match (state, err) { + (CheckState::Configured, _) => format!(" ✓ {rel} (configured)"), + (CheckState::NeedsConfiguration, _) if kind == "patch" => { + format!(" ✗ {rel}: {}", err.unwrap_or("patch not applied on disk")) + } + (CheckState::NeedsConfiguration, Some(e)) => format!(" ✗ {rel} (needs setup: {e})"), + (CheckState::NeedsConfiguration, None) => format!(" ✗ {rel} (needs setup)"), + (CheckState::Error, e) => format!(" ! {rel}: {}", e.unwrap_or("unknown error")), + } +} + +/// The `setup --check` verdict: what is wrong, then the command that fixes +/// each kind of problem (`setup` for missing hooks, `apply` for drifted +/// patches; invalid files need a hand edit). +fn format_check_footer(hooks: usize, drifted: usize, errors: usize) -> String { + if hooks + drifted + errors == 0 { + return "All manifests are configured with socket-patch.".to_string(); + } + let mut problems = Vec::new(); + let mut advice = Vec::new(); + if hooks > 0 { + problems.push(format!( + "{} configuration", + plural(hooks, "manifest needs", "manifests need") + )); + advice.push("Run `socket-patch setup` to add the missing install hooks."); + } + if drifted > 0 { + problems.push(format!( + "{} not applied on disk", + plural(drifted, "patch is", "patches are") + )); + advice.push("Run `socket-patch apply` to re-apply the patches."); + } + if errors > 0 { + problems.push(plural(errors, "error", "errors")); + advice.push("Fix the errors above, then re-run `socket-patch setup --check`."); + } + format!("{}. {}", problems.join(", "), advice.join(" ")) +} + /// Read-only verification that every discovered manifest (npm package.json and /// the Python dependency manifest) is configured for socket-patch. Never writes /// (so `--dry-run` is a harmless no-op here). Exits 0 only when all are /// configured and none failed to parse. +/// The status line while `setup --check` / `--remove` discover manifests. +const SEARCHING: &str = "Searching for package.json / Python / Bundler / Composer manifests..."; + async fn run_check(args: &SetupArgs) -> i32 { // `--silent` is "errors only" (CLI_CONTRACT.md): suppress the entire // human-readable report, mirroring `list`/`repair`/`get`/`remove`/`scan`. // The exit code still distinguishes the configuration states. - if !args.common.json && !args.common.silent { - println!("Searching for package.json / Python / Bundler / Composer manifests..."); - } + let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); + status.set(SEARCHING); // Excluded members (persisted in the manifest + any passed via `--exclude`) // are skipped by discovery. Read-only: `--check` never persists. @@ -1091,8 +1223,13 @@ async fn run_check(args: &SetupArgs) -> i32 { // or a BOM'd configured file fails `--check` as "Invalid // package.json" while `setup` calls it already_configured. let json = content.strip_prefix('\u{feff}').unwrap_or(&content); - if serde_json::from_str::(json).is_err() { - (CheckState::Error, Some("Invalid package.json".to_string())) + if let Err(e) = serde_json::from_str::(json) { + // Keep the parser's detail (line/column): "Invalid + // package.json" alone leaves the user hunting. + ( + CheckState::Error, + Some(format!("Invalid package.json: {e}")), + ) } else if is_setup_configured_str(&content).needs_update { (CheckState::NeedsConfiguration, None) } else { @@ -1133,6 +1270,7 @@ async fn run_check(args: &SetupArgs) -> i32 { // every in-scope manifest patch must be applied on disk (`apply --check` // invariant). Drifted/un-applied patches add `needs_configuration` entries. append_patch_consistency_entries(&args.common, existing.ok().flatten(), &mut entries).await; + status.finish(); if entries.is_empty() { return report_no_files( @@ -1149,6 +1287,12 @@ async fn run_check(args: &SetupArgs) -> i32 { .iter() .filter(|(_, _, s, _)| *s == CheckState::NeedsConfiguration) .count(); + // Drifted patches need `apply`, not `setup`: counted apart so the + // footer can say which command fixes what. + let drifted = entries + .iter() + .filter(|(k, _, s, _)| *k == "patch" && *s == CheckState::NeedsConfiguration) + .count(); let errs = entries .iter() .filter(|(_, _, s, _)| *s == CheckState::Error) @@ -1188,23 +1332,14 @@ async fn run_check(args: &SetupArgs) -> i32 { ); } else if !args.common.silent { println!("\nConfiguration status:\n"); - for (_, path, state, err) in &entries { + for (kind, path, state, err) in &entries { let rel = pathdiff(path, &args.common.cwd); - match state { - CheckState::Configured => println!(" ✓ {rel} (configured)"), - CheckState::NeedsConfiguration => println!(" ✗ {rel} (needs setup)"), - CheckState::Error => { - println!(" ! {rel}: {}", err.as_deref().unwrap_or("unknown error")) - } - } + println!("{}", format_check_line(kind, &rel, *state, err.as_deref())); } println!(); - if all_ok { - println!("All manifests are configured with socket-patch."); - } else { - println!( - "{needs} manifest(s) need configuration, {errs} error(s). Run `socket-patch setup` to fix." - ); + println!("{}", format_check_footer(needs - drifted, drifted, errs)); + if errs > 0 { + eprintln!("{}", format_items_failed(errs)); } } else { // `--silent` is "errors only": the status report is muted, but @@ -1233,6 +1368,19 @@ async fn run_check(args: &SetupArgs) -> i32 { // remove // ───────────────────────────────────────────────────────────────────────── +/// One script's before/after pair in the remove preview, the two values +/// aligned in one column: +/// +/// ```text +/// postinstall: "socket-patch apply && echo hi" +/// -> postinstall: "echo hi" +/// ``` +fn format_script_change(key: &str, old: &str, new: &str) -> String { + let label = format!("{key}:"); + let width = label.len() + 3; // the width of "-> :" + format!(" {label: {label} {new}\n") +} + /// Render a removed script value: `None` means the key is being deleted. fn render_removed(new: &Option) -> String { match new { @@ -1241,17 +1389,17 @@ fn render_removed(new: &Option) -> String { } } -/// Revert the install hooks `setup` added (npm package.json scripts + the -/// Python `socket-patch-hook` dependency). Honors `--dry-run`, `--yes`, `--json`. +/// Revert the install hooks `setup` added (npm package.json scripts, the +/// Python `socket-patch-hook` dependency, the gem Bundler plugin wiring and +/// the Composer script). Honors `--dry-run`, `--yes`, `--json`. async fn run_remove(args: &SetupArgs) -> i32 { let common = &args.common; // `--silent` is "errors only" (CLI_CONTRACT.md): mute the human-readable // chatter just like `--json` does; the mutation and exit code are // unaffected, and prompting follows the shared `confirm()` semantics. let quiet = common.json || common.silent; - if !quiet { - println!("Searching for package.json / Python / Bundler / Composer manifests..."); - } + let mut status = crate::ui::StatusLine::stderr(common.json, common.silent); + status.set(SEARCHING); // Honor the persisted/`--exclude` member set so we never touch a member that // was deliberately excluded from setup. Remove does not change the set. @@ -1271,6 +1419,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { .await; let composer_preview = build_composer_outcome(common, composer_json.as_deref(), true, true).await; + status.finish(); if npm_files.is_empty() && py_plan.is_none() && !gem_preview.present @@ -1292,7 +1441,10 @@ async fn run_remove(args: &SetupArgs) -> i32 { }; if !quiet { - print_remove_preview(&npm_preview, &py_preview, &extra_preview, common); + print!( + "{}", + format_remove_preview(&npm_preview, &py_preview, &extra_preview, &common.cwd) + ); } let n_remove = npm_preview @@ -1330,14 +1482,21 @@ async fn run_remove(args: &SetupArgs) -> i32 { ); } else if !common.silent { if preview_errs > 0 { - println!("Nothing removed; {preview_errs} item(s) could not be processed (see errors above)."); + println!( + "\nNothing removed; {} (see errors above).", + plural( + preview_errs, + "item could not be processed", + "items could not be processed" + ) + ); } else { println!("No socket-patch install hooks found to remove."); } } - eprint_errors_when_silent( + eprint_errors( common, - &remove_error_messages(&npm_preview, &py_preview, &extra_preview), + &remove_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), ); return if preview_errs > 0 { 1 } else { 0 }; } @@ -1347,25 +1506,39 @@ async fn run_remove(args: &SetupArgs) -> i32 { if common.json { print_remove_envelope("dry_run", &npm_preview, &py_preview, &extra_preview, &[]); } else if !common.silent { - println!("\nSummary:"); - println!(" {n_remove} item(s) would have socket-patch removed"); + println!("\nSummary (dry run):"); + println!( + " {}", + plural( + n_remove, + "item would have socket-patch removed", + "items would have socket-patch removed" + ) + ); } - eprint_errors_when_silent( + eprint_errors( common, - &remove_error_messages(&npm_preview, &py_preview, &extra_preview), + &remove_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), ); return if preview_errs > 0 { 1 } else { 0 }; } // Confirm before mutating. - if !common.yes && !common.json && !confirm_proceed("Remove these install hooks? (y/N): ") { - println!("Aborted"); + // Default-no on a terminal; proceeds when stdin is not interactive. + // Keep the prompt (or its non-interactive note) off the last preview + // line. With --yes nothing is printed there, and the progress line below + // already opens with its own blank line. + if !quiet && !common.yes { + eprintln!(); + } + if !crate::ui::confirm_or_proceed("Remove these install hooks?", common) { + if !common.silent { + eprintln!("{REMOVE_CANCELLED}"); + } return 0; } - if !quiet { - println!("\nRemoving changes..."); - } + status.set("Removing install hooks..."); let mut npm_results = Vec::new(); for loc in &npm_files { npm_results.push(remove_package_json(&loc.path, false).await); @@ -1387,6 +1560,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { .await, build_composer_outcome(common, composer_json.as_deref(), true, false).await, ); + status.finish(); let errs = npm_results .iter() @@ -1421,12 +1595,16 @@ async fn run_remove(args: &SetupArgs) -> i32 { .count() + extra_results.changed; println!("\nSummary:"); - println!(" {removed} item(s) had socket-patch removed"); + println!( + " {}", + plural( + removed, + "item had socket-patch removed", + "items had socket-patch removed" + ) + ); if errs > 0 { - println!(" {errs} error(s)"); - } - for w in &warnings { - println!(" warning: {w}"); + println!(" {}", plural(errs, "error", "errors")); } if py_plan.is_some() { println!("\nAlso run `pip uninstall socket-patch-hook` to remove the installed .pth."); @@ -1439,9 +1617,10 @@ async fn run_remove(args: &SetupArgs) -> i32 { } } - eprint_errors_when_silent( + print_warnings(common, &warnings); + eprint_errors( common, - &remove_error_messages(&npm_results, &py_results, &extra_results), + &remove_error_messages(&npm_results, &py_results, &extra_results, &common.cwd), ); if errs > 0 { @@ -1455,29 +1634,86 @@ async fn run_remove(args: &SetupArgs) -> i32 { /// entries — the only place per-edit errors for those ecosystems are retained. /// The setup/remove previews use this so their human-mode "Errors:" sections /// actually list gem/composer failures, honoring the "(see errors above)" line -/// both flows print when `preview_errors > 0`. -fn outcome_error_messages(o: &SetupOutcome) -> Vec { +/// both flows print when `preview_errors > 0`. Each message is prefixed with +/// the file's path (relative to `cwd`). +fn outcome_error_messages(o: &SetupOutcome, cwd: &Path) -> Vec { o.json_files .iter() .filter(|f| f.get("status").and_then(|s| s.as_str()) == Some("error")) - .filter_map(|f| f.get("error").and_then(|e| e.as_str()).map(str::to_string)) + .filter_map(|f| { + let err = f.get("error").and_then(|e| e.as_str())?; + let path = f.get("path").and_then(|p| p.as_str()).unwrap_or(""); + Some(format_item_error(path, err, cwd)) + }) .collect() } +/// `packages/a/package.json: Invalid package.json: ...` — in a workspace +/// there can be dozens of manifests, so an error must say which one. +fn format_item_error(path: &str, err: &str, cwd: &Path) -> String { + if path.is_empty() { + err.to_string() + } else { + format!("{}: {err}", pathdiff(path, cwd)) + } +} + +/// Print run warnings to stderr (`Warning: ...`) in human mode; `--json` +/// carries them in the envelope and `--silent` mutes them. +fn print_warnings(common: &GlobalArgs, warnings: &[String]) { + if common.json || common.silent { + return; + } + for w in warnings { + eprintln!("{}", format_warning(w)); + } +} + +/// `Warning: ` — the warning texts start lowercase because they +/// double as JSON `warnings[]` strings; the human line capitalizes them. +fn format_warning(w: &str) -> String { + let mut chars = w.chars(); + match chars.next() { + Some(first) => format!("Warning: {}{}", first.to_uppercase(), chars.as_str()), + None => "Warning:".to_string(), + } +} + /// `--silent` is "errors only" (CLI_CONTRACT.md): the previews, summaries, /// and status report that normally carry per-item failures are muted, so /// before an error exit the failures themselves must still reach stderr — /// mirroring `remove`/`scan`, whose error paths keep their stderr output. /// JSON mode is exempt: its envelope already carries the errors. -fn eprint_errors_when_silent(common: &GlobalArgs, errs: &[String]) { - if !common.silent || common.json { +/// +/// Without `--silent` the per-item errors are in the stdout report +/// already, so stderr gets one closing [`format_items_failed`] line: the +/// run exits 1, and the error stream says so. +fn eprint_errors(common: &GlobalArgs, errs: &[String]) { + if common.json || errs.is_empty() { return; } - for e in errs { - eprintln!("Error: {e}"); + if common.silent { + for e in errs { + eprintln!("Error: {e}"); + } + } else { + eprintln!("{}", format_items_failed(errs.len())); } } +/// The closing stderr error of a human run whose report lists per-item +/// errors. +fn format_items_failed(n: usize) -> String { + format!( + "Error: {}.", + plural( + n, + "item could not be processed", + "items could not be processed" + ) + ) +} + /// Per-item error messages across the three remove result families (npm + /// Python + gem/composer) — the preview "Errors:" section and the /// silent-mode stderr reporting share this. @@ -1485,18 +1721,19 @@ fn remove_error_messages( npm: &[RemoveResult], py: &[PthEditResult], extra: &SetupOutcome, + cwd: &Path, ) -> Vec { let mut errs: Vec = npm .iter() .filter(|r| r.status == RemoveStatus::Error) - .filter_map(|r| r.error.clone()) + .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))) .chain( py.iter() .filter(|r| r.status == PthStatus::Error) - .filter_map(|r| r.error.clone()), + .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))), ) .collect(); - errs.extend(outcome_error_messages(extra)); + errs.extend(outcome_error_messages(extra, cwd)); errs } @@ -1507,74 +1744,95 @@ fn setup_error_messages( npm: &[UpdateResult], py: &[PthEditResult], extra: &SetupOutcome, + cwd: &Path, ) -> Vec { let mut errs: Vec = npm .iter() .filter(|r| r.status == UpdateStatus::Error) - .filter_map(|r| r.error.clone()) + .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))) .chain( py.iter() .filter(|r| r.status == PthStatus::Error) - .filter_map(|r| r.error.clone()), + .filter_map(|r| Some(format_item_error(&r.path, r.error.as_deref()?, cwd))), ) .collect(); - errs.extend(outcome_error_messages(extra)); + errs.extend(outcome_error_messages(extra, cwd)); errs } -fn print_remove_preview( +/// The `setup --remove` preview. Every section starts with a blank line (so +/// the block never ends in a stray one before the summary or prompt). +fn format_remove_preview( npm: &[RemoveResult], py: &[PthEditResult], extra: &SetupOutcome, - common: &GlobalArgs, -) { + cwd: &Path, +) -> String { + let mut out = String::new(); let to_remove: Vec<_> = npm .iter() .filter(|r| r.status == RemoveStatus::Removed) .collect(); - let py_remove: Vec<_> = py - .iter() - .filter(|r| r.status == PthStatus::Updated) - .collect(); - println!("\nProposed changes:\n"); if !to_remove.is_empty() { - println!("Will remove socket-patch from:"); + out.push_str("\nWill remove socket-patch from:\n"); for r in &to_remove { - let rel = pathdiff(&r.path, &common.cwd); - println!(" - {rel}"); - println!(" postinstall: \"{}\"", r.old_script); - println!(" -> postinstall: {}", render_removed(&r.new_script)); - println!(" dependencies: \"{}\"", r.old_dependencies_script); - println!( - " -> dependencies: {}", - render_removed(&r.new_dependencies_script) - ); + out.push_str(&format!(" - {}\n", pathdiff(&r.path, cwd))); + // Only the scripts the file actually has: a missing hook has + // nothing to remove. + for (key, old, new) in [ + ("postinstall", &r.old_script, &r.new_script), + ( + "dependencies", + &r.old_dependencies_script, + &r.new_dependencies_script, + ), + ] { + if !old.is_empty() { + out.push_str(&format_script_change(key, old, &render_removed(new))); + } + } } - println!(); } + let py_remove: Vec<_> = py + .iter() + .filter(|r| r.status == PthStatus::Updated) + .collect(); if !py_remove.is_empty() { - println!("Will remove the socket-patch-hook dependency from:"); + out.push_str("\nWill remove the socket-patch-hook dependency from:\n"); for r in &py_remove { - println!(" - {}", pathdiff(&r.path, &common.cwd)); + out.push_str(&format!(" - {}\n", pathdiff(&r.path, cwd))); } - println!(); } + push_extra_preview(&mut out, extra); + // Surface failures so the "(see errors above)" line `run_remove` prints when + // nothing could be removed actually points at something. + push_errors(&mut out, &remove_error_messages(npm, py, extra, cwd)); + // No header over an empty preview: `run_remove` then says there is + // nothing to remove. + if out.is_empty() { + return out; + } + format!("\nProposed changes:\n{out}") +} + +/// The gem/composer preview lines, as their own blank-line-led section. +fn push_extra_preview(out: &mut String, extra: &SetupOutcome) { if !extra.preview.is_empty() { + out.push('\n'); for line in &extra.preview { - println!("{line}"); + out.push_str(line); + out.push('\n'); } - println!(); } +} - // Surface failures so the "(see errors above)" line `run_remove` prints when - // nothing could be removed actually points at something. - let errs = remove_error_messages(npm, py, extra); +/// The preview's "Errors:" section (nothing when there are none). +fn push_errors(out: &mut String, errs: &[String]) { if !errs.is_empty() { - println!("Errors:"); - for e in &errs { - println!(" ! {e}"); + out.push_str("\nErrors:\n"); + for e in errs { + out.push_str(&format!(" ! {e}\n")); } - println!(); } } @@ -1662,15 +1920,22 @@ fn print_remove_envelope( // setup (npm package.json + Python .pth hook, combined) // ───────────────────────────────────────────────────────────────────────── +/// Declining the `setup` / `setup --remove` prompt (the " +/// cancelled." wording every other command uses). +const SETUP_CANCELLED: &str = "Setup cancelled."; +const REMOVE_CANCELLED: &str = "Hook removal cancelled."; + +/// The status line while `setup` discovers what to configure. +const CONFIGURING: &str = "Configuring socket-patch install hooks..."; + async fn run_setup(args: &SetupArgs) -> i32 { let common = &args.common; // `--silent` is "errors only" (CLI_CONTRACT.md): mute the human-readable // chatter just like `--json` does; the mutation and exit code are // unaffected, and prompting follows the shared `confirm()` semantics. let quiet = common.json || common.silent; - if !quiet { - println!("Configuring socket-patch install hooks..."); - } + let mut status = crate::ui::StatusLine::stderr(common.json, common.silent); + status.set(CONFIGURING); // Resolve the effective exclude set (persisted + `--exclude`); excluded // members are skipped by discovery. Persisting it waits for the mutation @@ -1678,7 +1943,30 @@ async fn run_setup(args: &SetupArgs) -> i32 { // directory or an aborted run leaves no `.socket/` behind. let existing = read_setup_manifest(common).await; let excludes = effective_excludes(manifest_view(&existing), &args.exclude); - let npm_files = discover(args, &excludes).await; + let found = find_members(args).await; + let unmatched = found + .as_ref() + .map(|f| unmatched_excludes(f, &common.cwd, &excludes)) + .unwrap_or_default(); + if !unmatched.is_empty() { + // A permanent line: take the status down, then put it back. + status.finish(); + warn_unmatched_excludes(common, &unmatched); + status.set(CONFIGURING); + } + // A new `--exclude` value that matches no member is warned about above + // and not persisted, so a typo does not ride into every later run and + // clone. Values already persisted stay (they warn on every run instead + // of being silently dropped from the user's manifest). + let persisted = effective_excludes(manifest_view(&existing), &[]); + let to_persist: Vec = excludes + .iter() + .filter(|e| !unmatched.contains(e) || persisted.contains(e)) + .cloned() + .collect(); + let npm_files = found + .map(|f| select_members(f, &common.cwd, &excludes)) + .unwrap_or_default(); let py_plan = plan_python(common).await; // Gem + Composer projects are discovered ONCE and bundler probed ONCE: // the preview and the real edit below share both. @@ -1692,6 +1980,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { let gem_preview = build_gem_outcome(common, gem_add(), true).await; let composer_preview = build_composer_outcome(common, composer_json.as_deref(), false, true).await; + status.finish(); if npm_files.is_empty() && py_plan.is_none() @@ -1734,10 +2023,6 @@ async fn run_setup(args: &SetupArgs) -> i32 { None => Vec::new(), }; - if !quiet { - print_setup_preview(&npm_preview, &py_preview, &extra_preview, common); - } - let n_changes = npm_preview .iter() .filter(|r| r.status == UpdateStatus::Updated) @@ -1747,6 +2032,19 @@ async fn run_setup(args: &SetupArgs) -> i32 { .filter(|r| r.status == PthStatus::Updated) .count() + extra_preview.changed; + if !quiet { + print!( + "{}", + format_setup_preview( + &npm_preview, + &py_preview, + &extra_preview, + &common.cwd, + n_changes + ) + ); + } + let preview_errors = npm_preview .iter() .filter(|r| r.status == UpdateStatus::Error) @@ -1764,7 +2062,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { // preview). A skipped (fail-closed) persistence rides the warnings // channel exactly like on the mutating path. let warnings: Vec = if !common.dry_run && !args.exclude.is_empty() { - persist_setup_excludes(common, &existing, &excludes) + persist_setup_excludes(common, &existing, &to_persist) .await .into_iter() .collect() @@ -1787,17 +2085,22 @@ async fn run_setup(args: &SetupArgs) -> i32 { ); } else if !common.silent { if preview_errors > 0 { - println!("No hooks were changed; {preview_errors} item(s) could not be processed (see errors above)."); + println!( + "\nNo hooks were changed; {} (see errors above).", + plural( + preview_errors, + "item could not be processed", + "items could not be processed" + ) + ); } else { println!("All install hooks are already configured with socket-patch!"); } - for w in &warnings { - println!(" warning: {w}"); - } } - eprint_errors_when_silent( + print_warnings(common, &warnings); + eprint_errors( common, - &setup_error_messages(&npm_preview, &py_preview, &extra_preview), + &setup_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), ); if preview_errors > 0 { return 1; @@ -1821,27 +2124,37 @@ async fn run_setup(args: &SetupArgs) -> i32 { ); } else if !common.silent { println!("\nSummary (dry run):"); - println!(" {n_changes} item(s) would be updated"); + println!( + " {}", + plural(n_changes, "item would be updated", "items would be updated") + ); } - eprint_errors_when_silent( + eprint_errors( common, - &setup_error_messages(&npm_preview, &py_preview, &extra_preview), + &setup_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), ); return if preview_errors > 0 { 1 } else { 0 }; } - if !common.yes && !common.json && !confirm_proceed("Proceed with these changes? (y/N): ") { - println!("Aborted"); + // Default-no on a terminal; proceeds when stdin is not interactive. + // Keep the prompt (or its non-interactive note) off the last preview + // line. With --yes nothing is printed there, and the progress line below + // already opens with its own blank line. + if !quiet && !common.yes { + eprintln!(); + } + if !crate::ui::confirm_or_proceed("Proceed with these changes?", common) { + if !common.silent { + eprintln!("{SETUP_CANCELLED}"); + } return 0; } // Past the mutation gate: persist the exclude set now (a dry run // returned above; an aborted or no-project run never gets here). - let persist_warning = persist_setup_excludes(common, &existing, &excludes).await; + let persist_warning = persist_setup_excludes(common, &existing, &to_persist).await; - if !quiet { - println!("\nApplying changes..."); - } + status.set("Applying changes..."); let mut npm_results = Vec::new(); for loc in &npm_files { @@ -1868,6 +2181,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { if gem_present { warnings.extend(finalize_gem(common).await); } + status.finish(); let errors = npm_results .iter() @@ -1907,12 +2221,9 @@ async fn run_setup(args: &SetupArgs) -> i32 { .count() + extra_results.changed; println!("\nSummary:"); - println!(" {updated} item(s) updated"); + println!(" {}", plural(updated, "item updated", "items updated")); if errors > 0 { - println!(" {errors} error(s)"); - } - for w in &warnings { - println!(" warning: {w}"); + println!(" {}", plural(errors, "error", "errors")); } if let Some(plan) = &py_plan { println!( @@ -1931,9 +2242,10 @@ async fn run_setup(args: &SetupArgs) -> i32 { } } - eprint_errors_when_silent( + print_warnings(common, &warnings); + eprint_errors( common, - &setup_error_messages(&npm_results, &py_results, &extra_results), + &setup_error_messages(&npm_results, &py_results, &extra_results, &common.cwd), ); if errors > 0 { @@ -1963,63 +2275,63 @@ async fn track_setup_success( track_patch_setup(&manager, token.as_deref(), org.as_deref()).await; } -fn print_setup_preview( +/// The `setup` preview (same blank-line-led sections as +/// [`format_remove_preview`]). `n_changes == 0` leaves out the "already +/// configured" count: the caller then says everything is configured, and +/// the count would only repeat it. +fn format_setup_preview( npm: &[UpdateResult], py: &[PthEditResult], extra: &SetupOutcome, - common: &GlobalArgs, -) { + cwd: &Path, + n_changes: usize, +) -> String { + let mut out = String::new(); let npm_changes: Vec<_> = npm .iter() .filter(|r| r.status == UpdateStatus::Updated) .collect(); - let py_changes: Vec<_> = py - .iter() - .filter(|r| r.status == PthStatus::Updated) - .collect(); - if !npm_changes.is_empty() { - println!("\npackage.json files to update:"); + out.push_str("\npackage.json files to update:\n"); for r in &npm_changes { - println!(" + {}", pathdiff(&r.path, &common.cwd)); - println!(" -> postinstall: \"{}\"", r.new_script); + out.push_str(&format!(" + {}\n", pathdiff(&r.path, cwd))); + out.push_str(&format!(" -> postinstall: \"{}\"\n", r.new_script)); + // The run writes the `dependencies` hook too; show it when it + // changes, so the preview matches what is written. + if r.new_dependencies_script != r.old_dependencies_script { + out.push_str(&format!( + " -> dependencies: \"{}\"\n", + r.new_dependencies_script + )); + } } } + let py_changes: Vec<_> = py + .iter() + .filter(|r| r.status == PthStatus::Updated) + .collect(); if !py_changes.is_empty() { - println!("\nPython manifests to update (socket-patch-hook):"); + out.push_str("\nPython manifests to update (socket-patch-hook):\n"); for r in &py_changes { - println!(" + {}", pathdiff(&r.path, &common.cwd)); - } - } - if !extra.preview.is_empty() { - println!(); - for line in &extra.preview { - println!("{line}"); + out.push_str(&format!(" + {}\n", pathdiff(&r.path, cwd))); } } + push_extra_preview(&mut out, extra); - let npm_already = npm + let already = npm .iter() .filter(|r| r.status == UpdateStatus::AlreadyConfigured) - .count(); - let py_already = py - .iter() - .filter(|r| r.status == PthStatus::AlreadyConfigured) - .count(); - if npm_already + py_already + extra.already > 0 { - println!( - "\nAlready configured (will skip): {}", - npm_already + py_already + extra.already - ); + .count() + + py.iter() + .filter(|r| r.status == PthStatus::AlreadyConfigured) + .count() + + extra.already; + if already > 0 && n_changes > 0 { + out.push_str(&format!("\nAlready configured (will skip): {already}\n")); } - let errs = setup_error_messages(npm, py, extra); - if !errs.is_empty() { - println!("\nErrors:"); - for e in &errs { - println!(" ! {e}"); - } - } + push_errors(&mut out, &setup_error_messages(npm, py, extra, cwd)); + out } #[allow(clippy::too_many_arguments)] @@ -2106,3 +2418,332 @@ fn print_setup_envelope( .expect("serializing an in-memory JSON value cannot fail") ); } + +#[cfg(test)] +mod tests { + //! Exact-string tests for setup's human output builders. + use super::*; + + fn cwd() -> PathBuf { + PathBuf::from("/proj") + } + + fn update(path: &str, status: UpdateStatus, err: Option<&str>) -> UpdateResult { + UpdateResult { + path: path.to_string(), + status, + old_script: String::new(), + new_script: "npx @socketsecurity/socket-patch apply --silent".to_string(), + old_dependencies_script: "npx @socketsecurity/socket-patch apply --silent".to_string(), + new_dependencies_script: "npx @socketsecurity/socket-patch apply --silent".to_string(), + error: err.map(str::to_string), + } + } + + fn remove(path: &str, status: RemoveStatus) -> RemoveResult { + RemoveResult { + path: path.to_string(), + status, + old_script: "socket-patch apply && echo hi".to_string(), + new_script: Some("echo hi".to_string()), + old_dependencies_script: "socket-patch apply".to_string(), + new_dependencies_script: None, + error: None, + } + } + + #[test] + fn no_files_message_follows_scope() { + let all = ["package.json", "Python", "Bundler", "Composer"]; + assert_eq!( + format_no_files(&all, &[]), + "No package.json, Python, Bundler, or Composer project found" + ); + assert_eq!( + format_no_files(&["package.json"], &["npm".to_string()]), + "No package.json project found" + ); + assert_eq!( + format_no_files(&["Python", "Bundler"], &[]), + "No Python or Bundler project found" + ); + assert_eq!( + format_no_files(&[], &["cargo".to_string(), "maven".to_string()]), + "Setup has no install hook for: cargo, maven (supported: npm, pypi, gem, composer)" + ); + } + + #[test] + fn no_files_message_reads_the_ecosystems_filter() { + let mut common = GlobalArgs::default(); + assert_eq!( + no_files_message(&common), + "No package.json, Python, Bundler, or Composer project found" + ); + common.ecosystems = Some(vec!["cargo".to_string()]); + assert!(no_files_message(&common).starts_with("Setup has no install hook for: cargo")); + common.ecosystems = Some(vec!["cargo".to_string(), "pypi".to_string()]); + assert_eq!(no_files_message(&common), "No Python project found"); + } + + #[test] + fn warnings_are_capitalized_for_humans() { + assert_eq!( + format_warning("not persisting --exclude: x"), + "Warning: Not persisting --exclude: x" + ); + assert_eq!( + format_warning("`uv lock` failed"), + "Warning: `uv lock` failed" + ); + assert_eq!(format_warning("écrit"), "Warning: Écrit"); + assert_eq!(format_warning(""), "Warning:"); + } + + #[test] + fn unmatched_exclude_message_is_trimmed() { + assert_eq!( + format_unmatched_exclude(" nope"), + "--exclude \"nope\" matched no workspace member" + ); + assert_eq!( + format_unmatched_exclude("pkgs/ü"), + "--exclude \"pkgs/ü\" matched no workspace member" + ); + } + + #[test] + fn check_lines_render_each_state() { + use CheckState::*; + assert_eq!( + format_check_line("package_json", "package.json", Configured, None), + " ✓ package.json (configured)" + ); + assert_eq!( + format_check_line("package_json", "package.json", NeedsConfiguration, None), + " ✗ package.json (needs setup)" + ); + assert_eq!( + format_check_line( + "patch", + "pkg:npm/minimist@1.2.5", + NeedsConfiguration, + Some("patch not applied on disk (hash_mismatch)") + ), + " ✗ pkg:npm/minimist@1.2.5: patch not applied on disk (hash_mismatch)" + ); + assert_eq!( + format_check_line("gemfile", "Gemfile", NeedsConfiguration, Some("x")), + " ✗ Gemfile (needs setup: x)" + ); + assert_eq!( + format_check_line( + "package_json", + "a/package.json", + Error, + Some("Invalid package.json: EOF") + ), + " ! a/package.json: Invalid package.json: EOF" + ); + assert_eq!( + format_check_line("pth", "req.txt", Error, None), + " ! req.txt: unknown error" + ); + } + + #[test] + fn check_footer_names_the_fixing_command() { + assert_eq!( + format_check_footer(0, 0, 0), + "All manifests are configured with socket-patch." + ); + assert_eq!( + format_check_footer(1, 0, 0), + "1 manifest needs configuration. Run `socket-patch setup` to add the missing \ + install hooks." + ); + assert_eq!( + format_check_footer(0, 1, 0), + "1 patch is not applied on disk. Run `socket-patch apply` to re-apply the patches." + ); + assert_eq!( + format_check_footer(0, 0, 2), + "2 errors. Fix the errors above, then re-run `socket-patch setup --check`." + ); + assert_eq!( + format_check_footer(3, 2, 1), + "3 manifests need configuration, 2 patches are not applied on disk, 1 error. \ + Run `socket-patch setup` to add the missing install hooks. Run `socket-patch \ + apply` to re-apply the patches. Fix the errors above, then re-run `socket-patch \ + setup --check`." + ); + for f in [format_check_footer(1, 1, 1), format_check_footer(2, 2, 2)] { + assert!(!f.contains("(s)"), "{f}"); + } + } + + #[test] + fn item_errors_name_the_file() { + assert_eq!( + format_item_error( + "/proj/packages/a/package.json", + "Invalid package.json: x", + &cwd() + ), + "packages/a/package.json: Invalid package.json: x" + ); + assert_eq!(format_item_error("", "boom", &cwd()), "boom"); + assert_eq!( + format_item_error("/elsewhere/p.json", "boom", &cwd()), + "/elsewhere/p.json: boom" + ); + } + + #[test] + fn setup_preview_layout() { + let npm = vec![ + update("/proj/package.json", UpdateStatus::Updated, None), + update( + "/proj/packages/b/package.json", + UpdateStatus::AlreadyConfigured, + None, + ), + update( + "/proj/packages/bad/package.json", + UpdateStatus::Error, + Some("Invalid package.json: EOF"), + ), + ]; + let out = format_setup_preview(&npm, &[], &SetupOutcome::default(), &cwd(), 1); + assert_eq!( + out, + "\npackage.json files to update:\n + package.json\n -> postinstall: \"npx \ + @socketsecurity/socket-patch apply --silent\"\n\nAlready configured (will skip): \ + 1\n\nErrors:\n ! packages/bad/package.json: Invalid package.json: EOF\n" + ); + assert!(!out.contains("\n\n\n"), "{out:?}"); + } + + #[test] + fn setup_preview_shows_a_changed_dependencies_script() { + let mut r = update("/proj/package.json", UpdateStatus::Updated, None); + r.old_dependencies_script = String::new(); + r.new_dependencies_script = "socket-patch apply --silent".to_string(); + assert_eq!( + format_setup_preview(&[r], &[], &SetupOutcome::default(), &cwd(), 1), + "\npackage.json files to update:\n + package.json\n -> postinstall: \"npx \ + @socketsecurity/socket-patch apply --silent\"\n -> dependencies: \ + \"socket-patch apply --silent\"\n" + ); + } + + #[test] + fn setup_preview_skips_already_count_when_nothing_changes() { + let npm = vec![update( + "/proj/package.json", + UpdateStatus::AlreadyConfigured, + None, + )]; + assert_eq!( + format_setup_preview(&npm, &[], &SetupOutcome::default(), &cwd(), 0), + "" + ); + } + + #[test] + fn setup_preview_lists_gem_and_composer_lines() { + let extra = SetupOutcome { + preview: vec![ + "Gem: add the socket-patch Bundler plugin wiring to:".to_string(), + " + Gemfile".to_string(), + ], + changed: 1, + ..Default::default() + }; + assert_eq!( + format_setup_preview(&[], &[], &extra, &cwd(), 1), + "\nGem: add the socket-patch Bundler plugin wiring to:\n + Gemfile\n" + ); + } + + #[test] + fn cancel_lines_name_the_action() { + assert_eq!(SETUP_CANCELLED, "Setup cancelled."); + assert_eq!(REMOVE_CANCELLED, "Hook removal cancelled."); + assert_eq!( + SEARCHING, + "Searching for package.json / Python / Bundler / Composer manifests..." + ); + assert_eq!(CONFIGURING, "Configuring socket-patch install hooks..."); + } + + #[test] + fn remove_preview_is_empty_when_nothing_would_change() { + let npm = vec![remove("/proj/package.json", RemoveStatus::NotConfigured)]; + assert_eq!( + format_remove_preview(&npm, &[], &SetupOutcome::default(), &cwd()), + "" + ); + assert_eq!( + format_items_failed(1), + "Error: 1 item could not be processed." + ); + assert_eq!( + format_items_failed(2), + "Error: 2 items could not be processed." + ); + } + + #[test] + fn remove_preview_lists_only_the_scripts_the_file_has() { + let mut only_postinstall = remove("/proj/package.json", RemoveStatus::Removed); + only_postinstall.old_script = + "npx @socketsecurity/socket-patch apply --silent --ecosystems npm".to_string(); + only_postinstall.new_script = None; + only_postinstall.old_dependencies_script = String::new(); + only_postinstall.new_dependencies_script = None; + let out = format_remove_preview(&[only_postinstall], &[], &SetupOutcome::default(), &cwd()); + assert_eq!( + out, + "\nProposed changes:\n\nWill remove socket-patch from:\n - package.json\n \ + postinstall: \"npx @socketsecurity/socket-patch apply --silent --ecosystems \ + npm\"\n -> postinstall: (removed)\n" + ); + assert_eq!( + format_script_change("dependencies", "socket-patch apply", "(removed)"), + " dependencies: \"socket-patch apply\"\n -> dependencies: (removed)\n" + ); + } + + #[test] + fn remove_preview_layout_has_no_double_blank_lines() { + let npm = vec![remove("/proj/package.json", RemoveStatus::Removed)]; + let py = vec![PthEditResult { + path: "/proj/requirements.txt".to_string(), + status: PthStatus::Updated, + error: None, + }]; + let extra = SetupOutcome { + preview: vec![ + "Gem: remove the socket-patch Bundler plugin wiring from:".to_string(), + " - Gemfile".to_string(), + ], + ..Default::default() + }; + let out = format_remove_preview(&npm, &py, &extra, &cwd()); + assert_eq!( + out, + "\nProposed changes:\n\nWill remove socket-patch from:\n - package.json\n \ + postinstall: \"socket-patch apply && echo hi\"\n -> postinstall: \"echo \ + hi\"\n dependencies: \"socket-patch apply\"\n -> dependencies: \ + (removed)\n\nWill remove the socket-patch-hook dependency from:\n - \ + requirements.txt\n\nGem: remove the socket-patch Bundler plugin wiring from:\n \ + - Gemfile\n" + ); + assert!(!out.contains("\n\n\n"), "{out:?}"); + assert!( + out.ends_with("Gemfile\n"), + "no trailing blank line: {out:?}" + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index 881c6086..fa6c384b 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -10,21 +10,32 @@ use clap::Args; use socket_patch_core::update::{ self as core_update, asset_name_for_target, channel_label, current_version, detect_channel, - fetch_latest_version, is_newer, upgrade_hint, ChannelEnv, InstallChannel, UpdateEndpoints, + fetch_latest_version, is_newer, upgrade_hint_for, ChannelEnv, InstallChannel, UpdateEndpoints, UpdateError, UpdateRequest, UpdateTimeouts, }; use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::commands::lock_cli::error_envelope; use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent, RunWarning}; -use crate::output; /// The target triple this binary was compiled for, embedded by `build.rs`. /// Passed into core as a parameter so core stays testable with arbitrary /// triples. pub const UPDATE_TARGET: &str = env!("SOCKET_PATCH_TARGET"); +// `socket-patch --update --help` must describe the public `--update` +// flag, not the hidden `self-update` subcommand it is rewritten to. The +// variant's doc comment in lib.rs (developer notes) becomes the +// subcommand's `about` and is applied after these attributes, so the help +// text is fixed through a template that never renders `{about}`. +// (The usage line still reads `socket-patch self-update ...`: lib.rs's +// `update_help_shows_self_update_help` pins that; overriding it to +// `socket-patch --update [VERSION] [OPTIONS]` belongs with that test.) #[derive(Args)] +#[command( + help_template = "Update socket-patch itself to the latest release (or to VERSION).\n\n\ + {usage-heading} {usage}\n\n{all-args}{after-help}" +)] pub struct UpdateArgs { #[command(flatten)] pub common: GlobalArgs, @@ -34,10 +45,10 @@ pub struct UpdateArgs { /// version even if it is older than the current one. Also settable via /// SOCKET_PATCH_VERSION — the same pin install.sh and the gem launcher /// honor. - /// - /// Not named `version`: under `propagate_version` clap already owns a - /// `--version` arg id on every subcommand, and the collision panics at - /// parser construction. + // + // Not named `version`: under `propagate_version` clap already owns a + // `--version` arg id on every subcommand, and the collision panics at + // parser construction. #[arg( value_name = "VERSION", env = "SOCKET_PATCH_VERSION", @@ -67,16 +78,103 @@ fn parse_version_pin(raw: &str) -> Result { } /// Emit an error in the mode-appropriate shape and return the exit code. +/// The envelope keeps the message verbatim; the human line capitalizes it +/// (`Error: Could not check for updates: ...`). fn fail(args: &UpdateArgs, code: &str, message: &str) -> i32 { if args.common.json { let env = error_envelope(Command::Update, args.common.dry_run, code, message); println!("{}", env.to_pretty_json()); } else { - eprintln!("Error: {message}"); + eprintln!("Error: {}", capitalize_first(message)); } 1 } +/// `"could not ..."` → `"Could not ..."` (char-safe; empty stays empty). +fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +/// The no-op message when there is nothing to install: a pin already +/// satisfied, the latest release already running, or a build newer than +/// the latest release (`latest` never downgrades). +fn already_message(current: &semver::Version, target: &semver::Version, pinned: bool) -> String { + if pinned { + format!("socket-patch is already version {current}.") + } else if target < current { + format!("socket-patch {current} is newer than the latest release ({target}).") + } else { + format!("socket-patch {current} is already the latest version.") + } +} + +/// The `--dry-run` report. `change` is whether a real run would install +/// `target` (a pin may point below `current`: that is a downgrade, not an +/// "update available"). +fn dry_run_message( + current: &semver::Version, + target: &semver::Version, + pinned: bool, + force: bool, + change: bool, +) -> String { + if change && target < current { + format!("Would downgrade socket-patch {current} \u{2192} {target} (dry run; not installed)") + } else if change { + format!( + "Update available: socket-patch {current} \u{2192} {target} (dry run; not installed)" + ) + } else if force { + format!("Would reinstall socket-patch {target} (dry run; --force)") + } else { + already_message(current, target, pinned) + } +} + +/// The confirmation question before installing. +fn confirm_prompt(current: &semver::Version, target: &semver::Version) -> String { + if target < current { + format!("Downgrade socket-patch {current} \u{2192} {target}?") + } else if target == current { + format!("Reinstall socket-patch {target}?") + } else { + format!("Update socket-patch {current} \u{2192} {target}?") + } +} + +/// The line after a declined [`confirm_prompt`], naming the same action. +fn cancelled_message(current: &semver::Version, target: &semver::Version) -> &'static str { + if target < current { + "Downgrade cancelled." + } else if target == current { + "Reinstall cancelled." + } else { + "Update cancelled." + } +} + +/// The result line after a successful install, naming the same action as +/// [`confirm_prompt`]. +fn installed_message(current: &semver::Version, target: &semver::Version, path: &std::path::Path) -> String { + let path = path.display(); + if target < current { + format!("Downgraded socket-patch {current} \u{2192} {target} ({path})") + } else if target == current { + format!("Reinstalled socket-patch {target} ({path})") + } else { + format!("Updated socket-patch {current} \u{2192} {target} ({path})") + } +} + +/// The status line shown while the release downloads and installs. +fn download_status(target: &semver::Version, asset: &str) -> String { + format!("Downloading socket-patch {target} ({asset})...") +} + /// Record a non-fatal advisory: stderr for humans, `warnings[]` on the /// envelope for machines. `--json` suppresses the stderr line (stdout is /// the machine channel and stderr must stay clean), so a warning that only @@ -88,7 +186,7 @@ fn fail(args: &UpdateArgs, code: &str, message: &str) -> i32 { /// rendered stderr line keeps update's own `Warning: ` wording). fn note_warning(warnings: &mut Vec, quiet: bool, code: &str, detail: String) { if !quiet { - eprintln!("Warning: {detail}"); + eprintln!("Warning: {}", capitalize_first(&detail)); } warnings.push(RunWarning { code: code.to_string(), @@ -96,6 +194,9 @@ fn note_warning(warnings: &mut Vec, quiet: bool, code: &str, detail: }); } +/// The status line while `--update` asks which release is the latest. +const CHECKING_LATEST: &str = "Checking for the latest socket-patch release..."; + pub async fn run(args: UpdateArgs) -> i32 { apply_env_toggles(&args.common); let quiet = args.common.json || args.common.silent; @@ -121,6 +222,7 @@ pub async fn run(args: UpdateArgs) -> i32 { Err(e) => return fail(&args, e.error_code(), &e.to_string()), }; let channel = detect_channel(&install_path, &ChannelEnv::from_env()); + let hint = upgrade_hint_for(channel, &install_path); if channel != InstallChannel::Standalone { if args.force { note_warning( @@ -142,7 +244,7 @@ pub async fn run(args: UpdateArgs) -> i32 { instead, or pass --force to replace it in place", install_path.display(), channel_label(channel), - upgrade_hint(channel) + hint ), ); } @@ -159,10 +261,19 @@ pub async fn run(args: UpdateArgs) -> i32 { // path deserves a real error over a panic. Err(e) => return fail(&args, "check_failed", &format!("invalid version pin: {e}")), }, - None => match fetch_latest_version(&endpoints, &timeouts).await { - Ok(v) => (v, false), - Err(e) => return fail(&args, e.error_code(), &e.to_string()), - }, + None => { + // The check can take a while on a slow network (two probes, + // each with its own connect and read budget): say what is + // happening instead of a blank terminal. + let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); + status.set(CHECKING_LATEST); + let latest = fetch_latest_version(&endpoints, &timeouts).await; + status.finish(); + match latest { + Ok(v) => (v, false), + Err(e) => return fail(&args, e.error_code(), &e.to_string()), + } + } }; // Whatever we just learned, remember it for the passive notifier @@ -187,13 +298,13 @@ pub async fn run(args: UpdateArgs) -> i32 { // zero downloads, zero mutation, exit 0, with `updateAvailable` in // the details (scripts branch on it). if args.common.dry_run { - let msg = if update_available { - format!("Update available: socket-patch {current} → {target_version} (dry run; not installed)") - } else if args.force { - format!("Would reinstall socket-patch {target_version} (dry run; --force)") - } else { - format!("socket-patch {current} is already the latest version.") - }; + let msg = dry_run_message( + ¤t, + &target_version, + pinned, + args.force, + update_available, + ); if args.common.json { let mut env = Envelope::new(Command::Update); env.dry_run = true; @@ -221,11 +332,7 @@ pub async fn run(args: UpdateArgs) -> i32 { // downgrades — a dev build newer than the newest release is left // alone.) --force reinstalls regardless. if !update_available && !args.force { - let msg = if pinned { - format!("socket-patch is already version {current}.") - } else { - format!("socket-patch {current} is already the latest version.") - }; + let msg = already_message(¤t, &target_version, pinned); if args.common.json { // `--dry-run` returned above, so this envelope's `dryRun` is // always `Envelope::new`'s `false`. @@ -246,26 +353,31 @@ pub async fn run(args: UpdateArgs) -> i32 { return 0; } - // 6. Confirm (auto-proceeds under --yes/--json; declines default-yes - // only on an explicit "n"). - let prompt = format!("Update socket-patch {current} → {target_version}?"); - if !output::confirm(&prompt, true, args.common.yes, args.common.json) { + // 6. Confirm (auto-proceeds under --yes/--json; an empty answer takes + // the default yes, while "n" or Ctrl-D/EOF declines). + let prompt = confirm_prompt(¤t, &target_version); + if !crate::ui::confirm(&prompt, true, &args.common) { if !quiet { - eprintln!("Update cancelled."); + eprintln!("{}", cancelled_message(¤t, &target_version)); } return 1; } - // 7. Lock → download → verify → stage → sanity → swap (core). - let outcome = match core_update::perform_update(UpdateRequest { + // 7. Lock → download → verify → stage → sanity → swap (core). The + // download can take a while (300 s budget), so a terminal gets a + // status line instead of a silent pause after the prompt. + let mut status = crate::ui::StatusLine::stderr(args.common.json, args.common.silent); + status.set(download_status(&target_version, &asset)); + let result = core_update::perform_update(UpdateRequest { target_triple: UPDATE_TARGET, version: &target_version, install_path: &install_path, endpoints: &endpoints, timeouts: &timeouts, }) - .await - { + .await; + status.finish(); + let outcome = match result { Ok(outcome) => outcome, Err(e) => { let mut message = e.to_string(); @@ -304,8 +416,8 @@ pub async fn run(args: UpdateArgs) -> i32 { println!("{}", env.to_pretty_json()); } else if !args.common.silent { println!( - "Updated socket-patch {current} → {target_version} ({})", - outcome.installed_path.display() + "{}", + installed_message(¤t, &target_version, &outcome.installed_path) ); } 0 @@ -315,6 +427,133 @@ pub async fn run(args: UpdateArgs) -> i32 { mod tests { use super::*; + #[test] + fn checking_latest_status_line() { + assert_eq!( + CHECKING_LATEST, + "Checking for the latest socket-patch release..." + ); + } + + fn v(s: &str) -> semver::Version { + semver::Version::parse(s).unwrap() + } + + #[test] + fn already_message_covers_pin_latest_and_newer_than_latest() { + assert_eq!( + already_message(&v("4.0.0"), &v("4.0.0"), true), + "socket-patch is already version 4.0.0." + ); + assert_eq!( + already_message(&v("4.0.0"), &v("4.0.0"), false), + "socket-patch 4.0.0 is already the latest version." + ); + assert_eq!( + already_message(&v("4.0.0"), &v("3.0.0"), false), + "socket-patch 4.0.0 is newer than the latest release (3.0.0)." + ); + } + + #[test] + fn dry_run_message_matches_the_real_run() { + // Pinned to the running version: same wording as the wet no-op. + assert_eq!( + dry_run_message(&v("4.0.0"), &v("4.0.0"), true, false, false), + "socket-patch is already version 4.0.0." + ); + // A pin below the running version is a downgrade. + assert_eq!( + dry_run_message(&v("4.0.0"), &v("3.0.0"), true, false, true), + "Would downgrade socket-patch 4.0.0 \u{2192} 3.0.0 (dry run; not installed)" + ); + assert_eq!( + dry_run_message(&v("4.0.0"), &v("9.9.9"), false, false, true), + "Update available: socket-patch 4.0.0 \u{2192} 9.9.9 (dry run; not installed)" + ); + assert_eq!( + dry_run_message(&v("4.0.0"), &v("4.0.0"), false, true, false), + "Would reinstall socket-patch 4.0.0 (dry run; --force)" + ); + // Running a build newer than the latest release. + assert_eq!( + dry_run_message(&v("4.0.0"), &v("3.0.0"), false, false, false), + "socket-patch 4.0.0 is newer than the latest release (3.0.0)." + ); + } + + #[test] + fn confirm_prompt_names_the_direction() { + assert_eq!( + confirm_prompt(&v("4.0.0"), &v("9.9.9")), + "Update socket-patch 4.0.0 \u{2192} 9.9.9?" + ); + assert_eq!( + confirm_prompt(&v("4.0.0"), &v("3.0.0")), + "Downgrade socket-patch 4.0.0 \u{2192} 3.0.0?" + ); + assert_eq!( + confirm_prompt(&v("4.0.0"), &v("4.0.0")), + "Reinstall socket-patch 4.0.0?" + ); + } + + #[test] + fn cancel_and_result_lines_match_the_prompt() { + assert_eq!(cancelled_message(&v("4.0.0"), &v("9.9.9")), "Update cancelled."); + assert_eq!(cancelled_message(&v("4.0.0"), &v("3.0.0")), "Downgrade cancelled."); + assert_eq!(cancelled_message(&v("4.0.0"), &v("4.0.0")), "Reinstall cancelled."); + let p = std::path::Path::new("/opt/sp/socket-patch"); + assert_eq!( + installed_message(&v("4.0.0"), &v("9.9.9"), p), + "Updated socket-patch 4.0.0 \u{2192} 9.9.9 (/opt/sp/socket-patch)" + ); + assert_eq!( + installed_message(&v("4.0.0"), &v("3.0.0"), p), + "Downgraded socket-patch 4.0.0 \u{2192} 3.0.0 (/opt/sp/socket-patch)" + ); + assert_eq!( + installed_message(&v("4.0.0"), &v("4.0.0"), p), + "Reinstalled socket-patch 4.0.0 (/opt/sp/socket-patch)" + ); + } + + #[test] + fn download_status_and_error_capitalization() { + assert_eq!( + download_status(&v("9.9.9"), "socket-patch-x.tar.gz"), + "Downloading socket-patch 9.9.9 (socket-patch-x.tar.gz)..." + ); + assert_eq!( + capitalize_first("could not check for updates: x"), + "Could not check for updates: x" + ); + assert_eq!(capitalize_first(""), ""); + assert_eq!(capitalize_first("éclair"), "Éclair"); + assert_eq!(capitalize_first("Already"), "Already"); + } + + #[test] + fn help_describes_the_update_flag_not_the_internal_subcommand() { + use clap::CommandFactory; + let mut cmd = crate::Cli::command(); + let sub = cmd + .find_subcommand_mut("self-update") + .expect("self-update subcommand"); + let help = sub.render_long_help().to_string(); + assert!( + help.starts_with("Update socket-patch itself to the latest release (or to VERSION)."), + "{help}" + ); + for internal in [ + "Internal parse target", + "propagate_version", + "parse_argv_with_shortcuts", + ] { + assert!(!help.contains(internal), "leaked {internal:?}: {help}"); + } + } + #[test] fn version_pin_parses_and_normalizes() { assert_eq!(parse_version_pin("3.4.0").unwrap(), "3.4.0"); diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 19b01af2..3628764a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -48,18 +48,18 @@ use crate::ecosystem_dispatch::{find_packages_for_rollback, partition_purls}; use crate::json_envelope::{ Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning, Status, VexSummary, }; +use crate::ui::{plural, StatusLine}; #[derive(Args)] pub struct VendorArgs { #[command(flatten)] pub common: GlobalArgs, - /// Tolerate MISSING patch-target files in the staged copy (they are - /// skipped instead of failing the vendor) and bypass the variant - /// probe for multi-release ecosystems. A plain beforeHash mismatch - /// no longer needs this: vendor staging always overwrites mismatched - /// content with the verified patched bytes (surfaced as a - /// `vendor_content_mismatch_overwritten` warning). + /// Tolerate missing patch-target files in the staged copy (skip them + /// instead of failing) and bypass the variant probe for multi-release + /// ecosystems. Not needed for a beforeHash mismatch: vendoring always + /// overwrites mismatched content with the verified patched bytes and + /// warns (`vendor_content_mismatch_overwritten`). #[arg( short = 'f', long, @@ -339,14 +339,256 @@ pub(crate) fn record_warning( common: &GlobalArgs, ) { if !common.silent && !common.json { - eprintln!("Warning ({}): {}", warning.code, warning.detail); + if let Some(line) = format_advisory(warning.code, &warning.detail, common.verbose) { + eprintln!("{line}"); + } } + push_advisory_event(env, purl, warning); +} + +/// The JSON half of [`record_warning`]: the uncounted advisory event, +/// with no human line (for an advisory that would mislead in context). +fn push_advisory_event(env: &mut Envelope, purl: &str, warning: &VendorWarning) { env.events.push( PatchEvent::new(PatchAction::Skipped, purl.to_string()) .with_reason(warning.code, warning.detail.clone()), ); } +/// How loudly a vendor advisory prints for humans. +#[derive(Debug, PartialEq, Eq)] +enum AdvisoryTier { + /// Routine success detail: shown only under `--verbose`. + Verbose, + /// Worth knowing, but nothing is wrong: `Note: ...`. + Note, + /// Something the user may need to act on: `Warning (): ...`. + Warning, +} + +fn advisory_tier(code: &str) -> AdvisoryTier { + match code { + // Every successful service vendor emits this, one per package. + "vendor_prebuilt_downloaded" => AdvisoryTier::Verbose, + // The run did what was asked; these explain how. + "vendor_fetched_missing" + | "vendor_would_revert_redirect" + | "vendor_takeover_reverted_redirect" => AdvisoryTier::Note, + _ => AdvisoryTier::Warning, + } +} + +/// The human line for a vendor advisory, or `None` when it is hidden at +/// this verbosity. The stable code is kept on real warnings (it is what +/// a user searches for); notes carry only the detail. +fn format_advisory(code: &str, detail: &str, verbose: bool) -> Option { + match advisory_tier(code) { + AdvisoryTier::Verbose if !verbose => None, + AdvisoryTier::Verbose | AdvisoryTier::Note => Some(format!("Note: {detail}")), + AdvisoryTier::Warning => Some(format!("Warning ({code}): {detail}")), + } +} + +/// `Error: Cannot vendor : `. +fn format_vendor_failure(purl: &str, detail: &str) -> String { + format!("Error: Cannot vendor {}: {detail}", normalize_purl(purl)) +} + +/// Report one package that failed to vendor. An error, so it prints even +/// under `--silent` ("errors only", never nothing); `--json` carries it +/// in the envelope instead. +/// The status line while one package's vendor engine call runs. +fn format_vendor_progress(dry_run: bool, purl: &str, n: usize, total: usize) -> String { + let verb = if dry_run { "Checking" } else { "Vendoring" }; + if total > 1 { + format!("{verb} {purl}... ({n}/{total})") + } else { + format!("{verb} {purl}...") + } +} + +fn report_vendor_failure(common: &GlobalArgs, purl: &str, detail: &str) { + if !common.json { + eprintln!("{}", format_vendor_failure(purl, detail)); + } +} + +/// The unreadable-ledger error, shared by the vendor and revert paths. +fn report_state_unreadable(common: &GlobalArgs, err: &dyn std::fmt::Display) { + if !common.json { + eprintln!("{}", format_state_unreadable(&err.to_string())); + } +} + +/// `Error: Could not read the vendor ledger: `, naming the ledger +/// file only when `err` doesn't already (a parse error carries the path, +/// a bare I/O error doesn't). +pub(crate) fn format_state_unreadable(err: &str) -> String { + if err.contains("state.json") { + format!("Error: Could not read the vendor ledger: {err}") + } else { + format!("Error: Could not read the vendor ledger (.socket/vendor/state.json): {err}") + } +} + +/// Per-outcome counts behind the human vendor summary line. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct VendorTally { + /// Vendored this run (or, on a dry run, would be). + vendored: u32, + /// Already in sync with the manifest's patch. + already: u32, + /// Not installed and not fetchable (these fail the run). + not_installed: u32, + /// Every other skip. + skipped: u32, + failed: u32, +} + +impl VendorTally { + /// Derive the tally from the envelope. `dry_in_sync` is the number of + /// dry-run previews whose ledger entry already records the patch (the + /// backends preview those as `verified`, like a fresh vendor). + fn from_envelope(env: &Envelope, dry_run: bool, dry_in_sync: u32) -> Self { + let code_count = |code: &str| { + env.events + .iter() + .filter(|e| { + e.action == PatchAction::Skipped && e.error_code.as_deref() == Some(code) + }) + .count() as u32 + }; + let already_wet = code_count("already_vendored"); + let not_installed = code_count("package_not_installed"); + let vendored = if dry_run { + (env.summary.applied + env.summary.verified).saturating_sub(dry_in_sync) + } else { + env.summary.applied + }; + VendorTally { + vendored, + already: already_wet + dry_in_sync, + not_installed, + skipped: env + .summary + .skipped + .saturating_sub(already_wet + not_installed), + failed: env.summary.failed, + } + } +} + +/// `Vendored 2 packages.` / `Would vendor 1 package; 1 already vendored; +/// 1 failed.` Zero clauses are left out; the headline count never is. +fn format_vendor_summary(dry_run: bool, t: &VendorTally) -> String { + // Everything already in sync: say so, instead of "Vendored 0 packages". + if t.vendored == 0 && t.already > 0 && t.not_installed == 0 && t.skipped == 0 && t.failed == 0 { + let all = if t.already == 1 { + "1 package is".to_string() + } else { + format!("All {} packages are", t.already) + }; + return format!("{all} already vendored; nothing to do."); + } + let verb = if dry_run { "Would vendor" } else { "Vendored" }; + let mut line = format!( + "{verb} {}", + plural(t.vendored as usize, "package", "packages") + ); + for (n, what) in [ + (t.already, "already vendored"), + (t.not_installed, "not installed"), + (t.skipped, "skipped"), + (t.failed, "failed"), + ] { + if n > 0 { + line.push_str(&format!("; {n} {what}")); + } + } + line.push('.'); + line +} + +/// Report an entry that could not be reverted (an error: prints even +/// under `--silent`). +fn report_revert_failure(common: &GlobalArgs, purl: &str, detail: &str) { + if !common.json { + eprintln!("Error: Failed to revert {}: {detail}", normalize_purl(purl)); + } +} + +/// The line for a vendored entry reverted because its patch left the +/// manifest. +fn format_reconciled(purl: &str, dry_run: bool) -> String { + let verb = if dry_run { "Would revert" } else { "Reverted" }; + format!( + "{verb} vendoring of {} (patch no longer in manifest).", + normalize_purl(purl) + ) +} + +/// Counts behind the `vendor --revert` summary. +#[derive(Debug, Default)] +struct RevertSummary { + /// Ledger entries reverted (orphan dirs excluded). + reverted: u32, + failed: u32, + /// Drift-kept entries. + kept: u32, + /// Orphaned uuid dirs (no ledger entry) removed, as display paths. + orphans: Vec, +} + +/// The `vendor --revert` summary lines. Orphaned dirs are reported on +/// their own line (they are not packages); the package line is left out +/// when only orphans were swept. +fn format_revert_summary(dry_run: bool, s: &RevertSummary) -> Vec { + let mut lines = Vec::new(); + if s.reverted > 0 || s.failed > 0 || (s.orphans.is_empty() && s.kept == 0) { + let verb = if dry_run { "Would revert" } else { "Reverted" }; + let mut line = format!( + "{verb} {}", + plural(s.reverted as usize, "vendored package", "vendored packages") + ); + if s.failed > 0 { + line.push_str(&format!("; {} failed", s.failed)); + } + line.push('.'); + lines.push(line); + } + if !s.orphans.is_empty() { + let verb = if dry_run { "Would remove" } else { "Removed" }; + lines.push(format!( + "{verb} {} with no ledger entry: {}.", + plural( + s.orphans.len(), + "orphaned vendor directory", + "orphaned vendor directories" + ), + s.orphans.join(", ") + )); + } + if s.kept > 0 { + lines.push(format!( + "Kept {}: lock entries were re-resolved since vendoring, so their artifacts \ + and ledger entries were retained — undo the drift and re-run `vendor --revert` \ + to finish.", + plural(s.kept as usize, "drifted package", "drifted packages") + )); + } + lines +} + +/// After a revert the lockfile points at the registry again. The installed +/// tree holds the vendored bytes only if it was reinstalled after vendoring +/// (vendoring itself rewires the lockfile only), so the hint is conditional. +fn format_revert_install_hint(cmd: &str) -> String { + format!( + "Run `{cmd}` to resync the installed tree with the restored lockfile (it may \ + still hold the vendored bytes if you reinstalled after vendoring)." + ) +} + /// Run-level advisory shared by the `vendor` command and the scan-driven /// vendor step: warn (once, at the envelope level — not per package) when /// the project's classic `yarn.lock` carries vendored wiring that a stray @@ -392,11 +634,12 @@ pub async fn run(args: VendorArgs) -> i32 { env.dry_run = args.common.dry_run; println!("{}", env.to_pretty_json()); } else if !args.common.silent { - let tracked = load_state(&args.common.cwd) - .await - .map(|s| s.entries.len()) - .unwrap_or(0); - println!("{}", no_manifest_message(tracked)); + // An unreadable ledger is not "no entries": say so (stderr) + // instead of the calm nothing-to-vendor line. + match load_state(&args.common.cwd).await { + Ok(state) => println!("{}", no_manifest_message(state.entries.len())), + Err(e) => eprintln!("{}", no_manifest_ledger_unreadable(&e.to_string())), + } } return 0; } @@ -468,7 +711,10 @@ pub async fn run(args: VendorArgs) -> i32 { if let Some(vex_path) = args.vex.vex.as_ref() { if args.common.dry_run { if !args.common.json && !args.common.silent { - println!("Skipping VEX generation (--dry-run: nothing was vendored)."); + println!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("vendored") + ); } } else { let params = args.vex.to_build_params(); @@ -539,6 +785,14 @@ pub async fn run(args: VendorArgs) -> i32 { /// tracks entries, i.e. a `scan`/`get --mode vendored` project — says so /// instead of implying nothing is vendored: their refresh path is `scan /// --mode vendored`, and `repair` is what re-verifies the ledger. +/// The no-manifest warning when the vendor ledger cannot be read either. +fn no_manifest_ledger_unreadable(err: &str) -> String { + format!( + "Warning: No manifest to vendor from, and the vendor ledger could not be read: \ + {err}\n Run `socket-patch repair` to check the vendored artifacts." + ) +} + fn no_manifest_message(tracked_entries: usize) -> String { match tracked_entries { 0 => "No manifest found, nothing to vendor.".to_string(), @@ -580,8 +834,8 @@ async fn run_vendor( Ok(None) => return 0, // vanished since the existence check (TOCTOU) Err(e) => { env.mark_error(EnvelopeError::new("invalid_manifest", e.to_string())); - if !common.json && !common.silent { - eprintln!("Error: could not read manifest: {e}"); + if !common.json { + eprintln!("Error: Could not read manifest: {e}"); } return 1; } @@ -621,6 +875,9 @@ async fn run_vendor( }; let sources = staged.as_patch_sources(); + if manifest.patches.is_empty() && !common.json && !common.silent { + println!("The manifest has no patches; nothing to vendor."); + } has_errors |= vendor_records( common, &manifest.patches, @@ -845,8 +1102,11 @@ pub(crate) async fn vendor_records( ); } + // An empty record set says nothing about scope: the caller knows why + // it is empty (an empty manifest, or a download phase that refused or + // failed every patch and already said so) and reports it. if vendorable.is_empty() { - if !common.json && !common.silent { + if !records.is_empty() && !common.json && !common.silent { println!("No vendorable patches in scope."); } return has_errors; @@ -877,6 +1137,7 @@ pub(crate) async fn vendor_records( Ok(s) => s, Err(e) => { env.mark_error(EnvelopeError::new("vendor_state_unreadable", e.to_string())); + report_state_unreadable(common, &e); return true; } }; @@ -1005,9 +1266,7 @@ pub(crate) async fn vendor_records( PatchEvent::new(PatchAction::Failed, purl.clone()) .with_error("vendor_fetch_failed", detail.clone()), ); - if !common.silent && !common.json { - eprintln!("Cannot vendor {}: {detail}", normalize_purl(purl)); - } + report_vendor_failure(common, purl, &detail); continue; } Err(registry_fetch::FetchError::Unverifiable(_)) => { @@ -1065,12 +1324,7 @@ pub(crate) async fn vendor_records( PatchEvent::new(PatchAction::Failed, purl.clone()) .with_error("vendor_fetch_failed", detail.clone()), ); - if !common.silent && !common.json { - eprintln!( - "Cannot vendor {}: fetch failed: {detail}", - normalize_purl(purl) - ); - } + report_vendor_failure(common, purl, &format!("fetch failed: {detail}")); } } } @@ -1136,7 +1390,16 @@ pub(crate) async fn vendor_records( }; let pipenv_version = tokio::sync::OnceCell::new(); - for (purl, pkg_path) in &all_packages { + let mut dry_in_sync: u32 = 0; + // Sorted, so per-package lines print in the same order every run. + let mut all_packages: Vec<(String, std::path::PathBuf)> = all_packages.into_iter().collect(); + all_packages.sort(); + // Progress over the per-package engine calls (download, pack, lockfile + // rewrite): shown only while an engine call runs, so every per-package + // line prints on a clean line. + let mut status = StatusLine::stderr(common.json, common.silent); + let total = all_packages.len(); + for (index, (purl, pkg_path)) in all_packages.iter().enumerate() { let is_variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); let candidates: Vec = if is_variant_eco { @@ -1198,13 +1461,7 @@ pub(crate) async fn vendor_records( PatchEvent::new(PatchAction::Failed, candidate.clone()) .with_error(refusal.code, refusal.detail.clone()), ); - if !common.json { - eprintln!( - "Cannot vendor {}: {}", - normalize_purl(candidate), - refusal.detail - ); - } + report_vendor_failure(common, candidate, &refusal.detail); continue; } @@ -1240,9 +1497,7 @@ pub(crate) async fn vendor_records( ), ), ); - if !common.silent && !common.json { - eprintln!("Cannot vendor {}: {corrupt}", normalize_purl(candidate)); - } + report_vendor_failure(common, candidate, &corrupt.to_string()); continue; } let claimed = redirect_ledger.as_ref().is_some_and(|l| { @@ -1318,13 +1573,11 @@ pub(crate) async fn vendor_records( ), ), ); - if !common.silent && !common.json { - eprintln!( - "Cannot vendor {}: cannot revert the hosted redirect: \ - {detail}", - normalize_purl(candidate) - ); - } + report_vendor_failure( + common, + candidate, + &format!("cannot revert the hosted redirect: {detail}"), + ); continue; } } @@ -1351,15 +1604,14 @@ pub(crate) async fn vendor_records( // a ledger asserting wiring that is gone. Fail // closed for this purl. has_errors = true; + let detail = format!( + "reverted the hosted redirect but could not update \ + .socket/vendor/redirect-state.json: {e}" + ); + report_vendor_failure(common, candidate, &detail); env.record( PatchEvent::new(PatchAction::Failed, candidate.clone()) - .with_error( - "redirect_ledger_write_failed", - format!( - "reverted the hosted redirect but could not \ - update .socket/vendor/redirect-state.json: {e}" - ), - ), + .with_error("redirect_ledger_write_failed", detail), ); continue; } @@ -1396,19 +1648,23 @@ pub(crate) async fn vendor_records( ), ), ); - if !common.silent && !common.json { - eprintln!( - "Cannot vendor {}: cannot revert the hosted redirect: \ - {detail}", - normalize_purl(candidate) - ); - } + report_vendor_failure( + common, + candidate, + &format!("cannot revert the hosted redirect: {detail}"), + ); continue; } } } } + status.set(format_vendor_progress( + common.dry_run, + &normalize_purl(candidate), + index + 1, + total, + )); let outcome = dispatch_vendor_one( candidate, pkg_path, @@ -1422,6 +1678,7 @@ pub(crate) async fn vendor_records( &pipenv_version, ) .await; + status.finish(); match outcome { None => { @@ -1434,20 +1691,22 @@ pub(crate) async fn vendor_records( } Some(VendorOutcome::Refused { code, detail }) => { if refusal_is_benign(code) { + // An expected skip, not an error: informational. + if !common.silent && !common.json { + eprintln!("Skipping {}: {detail}", normalize_purl(candidate)); + } env.record( PatchEvent::new(PatchAction::Skipped, candidate.clone()) .with_reason(code, detail.clone()), ); } else { has_errors = true; + report_vendor_failure(common, candidate, &detail); env.record( PatchEvent::new(PatchAction::Failed, candidate.clone()) .with_error(code, detail.clone()), ); } - if !common.silent && !common.json { - eprintln!("Cannot vendor {}: {detail}", normalize_purl(candidate)); - } } Some(VendorOutcome::Done { result, @@ -1456,9 +1715,10 @@ pub(crate) async fn vendor_records( }) => { if !result.success { has_errors = true; - if !common.silent && !common.json { + // The patch itself failed to apply to the staged copy. + if !common.json { eprintln!( - "Failed to vendor {}: {}", + "Error: Failed to vendor {}: {}", normalize_purl(candidate), result.error.as_deref().unwrap_or("unknown error") ); @@ -1499,9 +1759,27 @@ pub(crate) async fn vendor_records( .with_files(files); } } + // A dry run previews an in-sync package as `verified` + // (the backends cannot tell without writing); the + // ledger recording this exact patch is the tell. + if common.dry_run + && event.action == PatchAction::Verified + && lookup_entry(&state.entries, candidate) + .is_some_and(|e| e.uuid == record.uuid) + { + dry_in_sync += 1; + } + let in_sync = event.error_code.as_deref() == Some("already_vendored"); env.record(event); for w in &warnings { - record_warning(env, candidate, w, common); + // "vendored X from the patch service" on a package + // this run left untouched would contradict the + // "already vendored" count: JSON only. + if in_sync && w.code == "vendor_prebuilt_downloaded" { + push_advisory_event(env, candidate, w); + } else { + record_warning(env, candidate, w, common); + } } if let Some(entry) = entry { if let Some(flavor) = entry.flavor.as_deref() { @@ -1562,26 +1840,18 @@ pub(crate) async fn vendor_records( } else { "no installed package found on disk" }; + // Fails the run (exit 1), so it is an error line. + report_vendor_failure(common, purl, detail); env.record( PatchEvent::new(PatchAction::Skipped, purl.clone()) .with_reason("package_not_installed", detail), ); - if !common.silent && !common.json { - eprintln!("Cannot vendor {}: {detail}", normalize_purl(purl)); - } } } if !common.json && !common.silent { - let verb = if common.dry_run { - "Would vendor" - } else { - "Vendored" - }; - println!( - "{verb} {} package(s); {} skipped; {} failed.", - env.summary.applied, env.summary.skipped, env.summary.failed - ); + let tally = VendorTally::from_envelope(env, common.dry_run, dry_in_sync); + println!("{}", format_vendor_summary(common.dry_run, &tally)); if env.summary.applied > 0 && !common.dry_run { // pnpm >=11 reads `overrides` ONLY from pnpm-workspace.yaml (the // package.json `pnpm.overrides` mirror is ignored), so pnpm-wired @@ -1702,6 +1972,9 @@ pub(crate) async fn reconcile_dropped( ); continue; } + if !common.json && !common.silent { + println!("{}", format_reconciled(&purl, common.dry_run)); + } env.record( PatchEvent::new(PatchAction::Removed, purl.clone()) .with_reason("vendor_reconciled", "patch no longer in manifest"), @@ -1723,11 +1996,11 @@ pub(crate) async fn reconcile_dropped( } } else { had_error = true; + let detail = outcome.error.unwrap_or_else(|| "unknown error".into()); + report_revert_failure(common, &purl, &detail); env.record( - PatchEvent::new(PatchAction::Failed, purl.clone()).with_error( - "revert_failed", - outcome.error.unwrap_or_else(|| "unknown error".into()), - ), + PatchEvent::new(PatchAction::Failed, purl.clone()) + .with_error("revert_failed", detail), ); } } @@ -1740,9 +2013,7 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { Ok(s) => s, Err(e) => { env.mark_error(EnvelopeError::new("vendor_state_unreadable", e.to_string())); - if !common.json && !common.silent { - eprintln!("Error: could not read .socket/vendor/state.json: {e}"); - } + report_state_unreadable(common, &e); return 1; } }; @@ -1750,12 +2021,17 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { let mut has_errors = false; let mut recorded: Vec = state.entries.keys().cloned().collect(); recorded.sort(); + // Lockfile flavors of the entries this run reverted: the installed + // tree still holds the vendored bytes until a reinstall. + let mut reverted_flavors: HashSet = HashSet::new(); // The one vendored-revert primitive every reverting command shares // (rollback's vendored leg, both of remove's paths): dispatch → // drift-keep → per-entry ledger save. Only the event vocabulary and // the human lines are this command's. for purl in &recorded { + // Captured before the revert drops the entry from the ledger. + let flavor = state.entries.get(purl).and_then(|e| e.flavor.clone()); let result = crate::commands::rollback::revert_vendor_entry( &common.cwd, purl, @@ -1771,13 +2047,11 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { VendorRevertStep::Missing | VendorRevertStep::Preserved => {} VendorRevertStep::Failed(why) => { has_errors = true; + report_revert_failure(common, purl, &why); env.record( PatchEvent::new(PatchAction::Failed, purl.clone()) .with_error("revert_failed", why), ); - if !common.silent && !common.json { - eprintln!("Failed to revert {purl}"); - } } // Drift-skip keep (residual #131): the backend left the // drifted lock alone and kept the artifacts, so the ledger @@ -1794,10 +2068,12 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { ), VendorRevertStep::WouldRevert | VendorRevertStep::Reverted => { env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); + reverted_flavors.extend(flavor); } // Reverted on disk; the record of it could not be persisted. VendorRevertStep::LedgerWriteFailed(e) => { env.record(PatchEvent::new(PatchAction::Removed, purl.clone())); + reverted_flavors.extend(flavor); has_errors = true; env.record( PatchEvent::new(PatchAction::Failed, purl.clone()) @@ -1844,24 +2120,32 @@ async fn run_revert(args: &VendorArgs, env: &mut Envelope) -> i32 { } if !common.json && !common.silent { - let verb = if common.dry_run { - "Would revert" - } else { - "Reverted" - }; - println!( - "{verb} {} vendored package(s); {} failed.", - env.summary.removed, env.summary.failed - ); + let orphans: Vec = sweep + .removed + .iter() + .map(|u| format!(".socket/vendor/{}/{}", u.eco, u.uuid)) + .collect(); // In this command summary.skipped counts only genuine drift-skip // keeps (advisory warnings are pushed uncounted by record_warning). - if env.summary.skipped > 0 { - println!( - "Kept {} drifted package(s): lock entries were re-resolved since vendoring, so \ - their artifacts and ledger entries were retained — undo the drift and re-run \ - `vendor --revert` to finish.", - env.summary.skipped - ); + let summary = RevertSummary { + reverted: env.summary.removed.saturating_sub(orphans.len() as u32), + failed: env.summary.failed, + kept: env.summary.skipped, + orphans, + }; + for line in format_revert_summary(common.dry_run, &summary) { + println!("{line}"); + } + if summary.reverted > 0 && !common.dry_run { + let mut installs: Vec<&str> = reverted_flavors + .iter() + .filter_map(|f| flavor_install_command(f)) + .collect(); + installs.sort_unstable(); + installs.dedup(); + for cmd in installs { + println!("{}", format_revert_install_hint(cmd)); + } } } @@ -3384,6 +3668,16 @@ mod scope_and_hint_tests { /// write a manifest), says what IS vendored instead of "nothing" — /// the old "No .socket folder found" text was false on every such /// project (`.socket/vendor/` exists). + #[test] + fn no_manifest_with_unreadable_ledger_warns() { + assert_eq!( + no_manifest_ledger_unreadable("corrupt .socket/vendor/state.json: expected value"), + "Warning: No manifest to vendor from, and the vendor ledger could not be read: \ + corrupt .socket/vendor/state.json: expected value\n Run `socket-patch repair` \ + to check the vendored artifacts." + ); + } + #[test] fn no_manifest_message_names_the_manifest_and_tracked_entries() { assert_eq!( @@ -3750,3 +4044,264 @@ mod pristine_fetch_tests { ); } } + +/// Exact-string tests for the human output of `vendor` / `vendor --revert`. +#[cfg(test)] +mod ui_format_tests { + use super::*; + + #[test] + fn vendor_progress_line() { + assert_eq!( + format_vendor_progress(false, "pkg:npm/lodash@4.17.20", 1, 2), + "Vendoring pkg:npm/lodash@4.17.20... (1/2)" + ); + assert_eq!( + format_vendor_progress(true, "pkg:npm/lodash@4.17.20", 1, 1), + "Checking pkg:npm/lodash@4.17.20..." + ); + } + + fn tally( + vendored: u32, + already: u32, + not_installed: u32, + skipped: u32, + failed: u32, + ) -> VendorTally { + VendorTally { + vendored, + already, + not_installed, + skipped, + failed, + } + } + + #[test] + fn vendor_summary_singular_plural_and_zero() { + assert_eq!( + format_vendor_summary(false, &tally(0, 0, 0, 0, 0)), + "Vendored 0 packages." + ); + assert_eq!( + format_vendor_summary(false, &tally(1, 0, 0, 0, 0)), + "Vendored 1 package." + ); + assert_eq!( + format_vendor_summary(false, &tally(2, 0, 0, 0, 0)), + "Vendored 2 packages." + ); + assert_eq!( + format_vendor_summary(true, &tally(1, 0, 0, 0, 0)), + "Would vendor 1 package." + ); + assert_eq!( + format_vendor_summary(true, &tally(0, 0, 0, 0, 2)), + "Would vendor 0 packages; 2 failed." + ); + } + + #[test] + fn vendor_summary_lists_only_nonzero_clauses_in_order() { + assert_eq!( + format_vendor_summary(false, &tally(1, 2, 1, 3, 1)), + "Vendored 1 package; 2 already vendored; 1 not installed; 3 skipped; 1 failed." + ); + // Nothing but in-sync packages: no "Vendored 0 packages" headline. + assert_eq!( + format_vendor_summary(false, &tally(0, 2, 0, 0, 0)), + "All 2 packages are already vendored; nothing to do." + ); + assert_eq!( + format_vendor_summary(true, &tally(0, 2, 0, 0, 0)), + "All 2 packages are already vendored; nothing to do." + ); + assert_eq!( + format_vendor_summary(false, &tally(0, 1, 0, 0, 0)), + "1 package is already vendored; nothing to do." + ); + assert_eq!( + format_vendor_summary(false, &tally(0, 2, 0, 0, 1)), + "Vendored 0 packages; 2 already vendored; 1 failed." + ); + assert_eq!( + format_vendor_summary(false, &tally(1, 0, 1, 0, 0)), + "Vendored 1 package; 1 not installed." + ); + } + + #[test] + fn tally_splits_skips_and_counts_dry_run_previews() { + let mut env = Envelope::new(Command::Vendor); + env.record( + PatchEvent::new(PatchAction::Skipped, "pkg:npm/a@1") + .with_reason("already_vendored", "in sync"), + ); + env.record( + PatchEvent::new(PatchAction::Skipped, "pkg:npm/b@1") + .with_reason("package_not_installed", "not on disk"), + ); + env.record( + PatchEvent::new(PatchAction::Skipped, "pkg:jsr/c@1") + .with_reason("vendor_unsupported_ecosystem", "no backend"), + ); + env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/d@1")); + env.record(PatchEvent::new(PatchAction::Failed, "pkg:npm/e@1").with_error("x", "y")); + // An uncounted advisory event must not count as anything. + push_advisory_event( + &mut env, + "pkg:npm/d@1", + &VendorWarning::new("vendor_prebuilt_downloaded", "detail"), + ); + assert_eq!( + VendorTally::from_envelope(&env, false, 0), + tally(1, 1, 1, 1, 1) + ); + + let mut dry = Envelope::new(Command::Vendor); + dry.record(PatchEvent::new(PatchAction::Verified, "pkg:npm/a@1")); + dry.record(PatchEvent::new(PatchAction::Verified, "pkg:npm/b@1")); + dry.record(PatchEvent::new(PatchAction::Verified, "pkg:npm/c@1")); + assert_eq!( + VendorTally::from_envelope(&dry, true, 0), + tally(3, 0, 0, 0, 0) + ); + assert_eq!( + VendorTally::from_envelope(&dry, true, 2), + tally(1, 2, 0, 0, 0) + ); + assert_eq!( + format_vendor_summary(true, &VendorTally::from_envelope(&dry, true, 3)), + "All 3 packages are already vendored; nothing to do." + ); + } + + #[test] + fn advisories_are_tiered() { + assert_eq!( + format_advisory("vendor_prebuilt_downloaded", "d", false), + None + ); + assert_eq!( + format_advisory( + "vendor_prebuilt_downloaded", + "vendored x from the service", + true + ), + Some("Note: vendored x from the service".to_string()) + ); + assert_eq!( + format_advisory("vendor_fetched_missing", "fetched", false), + Some("Note: fetched".to_string()) + ); + assert_eq!( + format_advisory("vendor_lock_entry_drifted", "drifted", false), + Some("Warning (vendor_lock_entry_drifted): drifted".to_string()) + ); + } + + #[test] + fn failure_lines_normalize_the_purl() { + assert_eq!( + format_vendor_failure( + "pkg:npm/%40scope/pkg@1.0.0", + "no installed package found on disk" + ), + "Error: Cannot vendor pkg:npm/@scope/pkg@1.0.0: no installed package found on disk" + ); + assert_eq!( + format_reconciled("pkg:npm/left-pad@1.3.0", false), + "Reverted vendoring of pkg:npm/left-pad@1.3.0 (patch no longer in manifest)." + ); + assert_eq!( + format_reconciled("pkg:npm/left-pad@1.3.0", true), + "Would revert vendoring of pkg:npm/left-pad@1.3.0 (patch no longer in manifest)." + ); + } + + fn revert(reverted: u32, failed: u32, kept: u32, orphans: &[&str]) -> RevertSummary { + RevertSummary { + reverted, + failed, + kept, + orphans: orphans.iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn revert_summary_package_line() { + assert_eq!( + format_revert_summary(false, &revert(1, 0, 0, &[])), + vec!["Reverted 1 vendored package."] + ); + assert_eq!( + format_revert_summary(false, &revert(2, 1, 0, &[])), + vec!["Reverted 2 vendored packages; 1 failed."] + ); + assert_eq!( + format_revert_summary(true, &revert(2, 0, 0, &[])), + vec!["Would revert 2 vendored packages."] + ); + // Every entry failed: the line still explains the exit code. + assert_eq!( + format_revert_summary(false, &revert(0, 1, 0, &[])), + vec!["Reverted 0 vendored packages; 1 failed."] + ); + } + + #[test] + fn revert_summary_reports_orphans_separately() { + let one = ".socket/vendor/npm/4444"; + assert_eq!( + format_revert_summary(false, &revert(0, 0, 0, &[one])), + vec!["Removed 1 orphaned vendor directory with no ledger entry: .socket/vendor/npm/4444."] + ); + assert_eq!( + format_revert_summary(true, &revert(1, 0, 0, &["a", "b"])), + vec![ + "Would revert 1 vendored package.", + "Would remove 2 orphaned vendor directories with no ledger entry: a, b.", + ] + ); + } + + #[test] + fn revert_summary_kept_line() { + let lines = format_revert_summary(false, &revert(0, 0, 1, &[])); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!( + lines[0].starts_with("Kept 1 drifted package: lock entries were re-resolved"), + "{lines:?}" + ); + let lines = format_revert_summary(false, &revert(1, 0, 2, &[])); + assert_eq!(lines[0], "Reverted 1 vendored package."); + assert!( + lines[1].starts_with("Kept 2 drifted packages: "), + "{lines:?}" + ); + } + + #[test] + fn state_unreadable_names_the_file_once() { + assert_eq!( + format_state_unreadable("corrupt ./.socket/vendor/state.json: key must be a string"), + "Error: Could not read the vendor ledger: corrupt ./.socket/vendor/state.json: \ + key must be a string" + ); + assert_eq!( + format_state_unreadable("Permission denied (os error 13)"), + "Error: Could not read the vendor ledger (.socket/vendor/state.json): \ + Permission denied (os error 13)" + ); + } + + #[test] + fn revert_install_hint_names_the_command() { + assert_eq!( + format_revert_install_hint("npm install"), + "Run `npm install` to resync the installed tree with the restored lockfile (it \ + may still hold the vendored bytes if you reinstalled after vendoring)." + ); + } +} diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 7b9c0111..21826762 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -30,6 +30,7 @@ use socket_patch_core::vex::{ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::ecosystem_dispatch::find_manifest_package_paths; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning}; +use crate::ui::plural; /// Routing tag for a patch omitted from VEX by the property-7 ecosystem /// filter alone: the patch IS applied (byte-verified, or trusted under @@ -45,19 +46,23 @@ pub struct VexArgs { #[command(flatten)] pub common: GlobalArgs, - /// Write the VEX document to this path instead of stdout. + /// Write the VEX document to this path instead of stdout (`-` means + /// stdout). A relative path resolves against the current directory, not + /// `--cwd`. #[arg(long = "output", short = 'O', env = "SOCKET_VEX_OUTPUT")] pub output: Option, - /// Override the auto-detected top-level product PURL/identifier. - /// Auto-detection probes (in order): - /// 1. `.git/config` `[remote "origin"]` — converted to - /// `pkg:github//` for github.com, similar for - /// gitlab.com/bitbucket.org, raw URL otherwise. - /// 2. `package.json` → `pkg:npm/@` - /// 3. `pyproject.toml` → `pkg:pypi/@` - /// 4. `Cargo.toml` → `pkg:cargo/@` - #[arg(long = "product", env = "SOCKET_VEX_PRODUCT")] + /// Override the auto-detected top-level product PURL/identifier + /// + /// Auto-detection tries, in order: + /// 1. the git `origin` remote: pkg:github// for github.com + /// (likewise gitlab.com and bitbucket.org), the raw URL otherwise + /// 2. package.json: pkg:npm/@ + /// 3. pyproject.toml: pkg:pypi/@ + /// 4. Cargo.toml: pkg:cargo/@ + // `verbatim_doc_comment`: clap otherwise joins the numbered list into + // one run-on line. + #[arg(long = "product", env = "SOCKET_VEX_PRODUCT", verbatim_doc_comment)] pub product: Option, /// Skip the on-disk file-hash check and trust the manifest. @@ -65,13 +70,13 @@ pub struct VexArgs { /// emitted; this flag flips that off — useful when generating a /// VEX doc on a build machine that doesn't have the patched files /// laid out yet. - /// - /// `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_VEX_NO_VERIFY=1` (or - /// an exported-but-empty `SOCKET_VEX_NO_VERIFY=`) aborted the parse. - /// This var is also outside `GLOBAL_ARG_ENV_VARS`, so `main`'s empty-var - /// scrub never rescues it. + // + // `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_VEX_NO_VERIFY=1` (or + // an exported-but-empty `SOCKET_VEX_NO_VERIFY=`) aborted the parse. + // This var is also outside `GLOBAL_ARG_ENV_VARS`, so `main`'s empty-var + // scrub never rescues it. #[arg( long = "no-verify", env = "SOCKET_VEX_NO_VERIFY", @@ -119,11 +124,11 @@ pub struct VexEmbedArgs { /// Skip the on-disk file-hash check when building the VEX document and /// trust the manifest. See `socket-patch vex --no-verify`. - /// - /// `value_parser = parse_bool_flag`: these embedded flags share their - /// env vars with the standalone `vex` flags, so without it an ambient - /// `SOCKET_VEX_NO_VERIFY=1` (or `=`) aborted every host command parse — - /// including `apply` running from a postinstall hook. + // + // `value_parser = parse_bool_flag`: these embedded flags share their + // env vars with the standalone `vex` flags, so without it an ambient + // `SOCKET_VEX_NO_VERIFY=1` (or `=`) aborted every host command parse — + // including `apply` running from a postinstall hook. #[arg( long = "vex-no-verify", env = "SOCKET_VEX_NO_VERIFY", @@ -159,6 +164,9 @@ impl VexEmbedArgs { compact: self.vex_compact, assume_applied: Vec::new(), known_stale: Vec::new(), + // Embedded callers skip VEX entirely under `--dry-run`. + dry_run: false, + product_flag: "--vex-product", } } } @@ -185,6 +193,13 @@ pub(crate) struct VexBuildParams { /// Hosted probes positively identified unpatched installed bytes. These /// PURLs cannot be attested by another interpreter or --no-verify. pub known_stale: Vec, + /// `vex --dry-run`: build and verify, but write nothing to `output` and + /// leave any previous document there alone. Printing to stdout is not a + /// mutation, so it still happens. + pub dry_run: bool, + /// The flag that carried `product`, named in the non-IRI advisory + /// (`--product` standalone, `--vex-product` embedded). + pub product_flag: &'static str, } /// Successful result of [`generate_vex`]. @@ -200,6 +215,9 @@ pub(crate) struct VexWriteSummary { /// folds them into the envelope's `warnings[]` (which is the only /// channel `--json` has — it silences stderr). pub warnings: Vec, + /// Whether the document was written to `output` (false under + /// `--dry-run`, and when it went to stdout). + pub wrote_file: bool, } /// Failure from [`generate_vex`], carrying a stable code + message the @@ -210,15 +228,21 @@ pub(crate) struct VexGenError { /// Patches omitted by verification, populated only for the /// `no_applicable_patches` case (so callers can list them). pub failed: Vec, + /// Advisories raised before the failure (already printed in human + /// mode); the standalone `vex --json` error envelope carries them. + pub warnings: Vec, } pub async fn run(args: VexArgs) -> i32 { apply_env_toggles(&args.common); + // `-O -` is the conventional spelling of stdout, not a file named `-`. + let output = args.output.clone().filter(|p| p.as_os_str() != "-"); + // --json without --output would race the envelope and the VEX doc // on the same stdout stream. Bail out with a clear error before // doing any work. - if args.common.json && args.output.is_none() { + if args.common.json && output.is_none() { // A usage error, not a generation failure: no telemetry POST and no // config read (argument errors never report), just the envelope. emit_envelope_error( @@ -227,35 +251,52 @@ pub async fn run(args: VexArgs) -> i32 { "--json requires --output (the VEX document is itself JSON; \ route it to a file so the envelope can use stdout)", &[], + &[], ); return 2; } + // `-o` is `--org`, `-O` is `--output`: a file-shaped org slug is almost + // certainly a mistyped `-O` (the document then silently went to stdout). + let mut run_warnings: Vec = Vec::new(); + if let Some(detail) = org_looks_like_path(args.common.org.as_deref()) { + note_warning( + &mut run_warnings, + &args.common, + "org_looks_like_path", + detail, + ); + } + let params = VexBuildParams { - output: args.output.clone(), + output: output.clone(), product: args.product.clone(), no_verify: args.no_verify, doc_id: args.doc_id.clone(), compact: args.compact, assume_applied: Vec::new(), known_stale: Vec::new(), + dry_run: args.common.dry_run, + product_flag: "--product", }; let manifest_path = args.common.resolved_manifest_path(); match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { - Ok(summary) => { + Ok(mut summary) => { + run_warnings.append(&mut summary.warnings); + summary.warnings = run_warnings; if args.common.json { - emit_envelope_success(&summary); - } else if let Some(path) = &args.output { - if !args.common.silent { - println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, - path.display() - ); - } + emit_envelope_success(&summary, params.dry_run); } else if !args.common.silent { - eprintln!("Emitted {} VEX statement(s)", summary.statements); + match &output { + Some(path) if summary.wrote_file => { + println!("{}", format_vex_written(summary.statements, path)); + } + Some(path) => { + println!("{}", format_vex_dry_run(summary.statements, path)); + } + None => eprintln!("{}", format_vex_emitted(summary.statements)), + } } 0 } @@ -263,30 +304,93 @@ pub async fn run(args: VexArgs) -> i32 { // attest" cases (exit 1); every other error is a hard failure // (exit 2). `generate_vex_from_manifest_path` already fired // telemetry, so these emit-only sinks must not re-track. - Err(e) if e.code == "no_applicable_patches" => { - emit_envelope_error(&args, e.code, &e.message, &e.failed); - 1 - } - // Standalone-only remediation hint: after an embedded `apply --vex` - // / `scan --vex` run the advice would be circular, so the shared - // path keeps the bare message and it is appended here. - Err(e) if e.code == "no_patches" => { - emit_envelope_error( - &args, - e.code, - "Manifest is empty — nothing to attest. Run `socket-patch get` \ - or `socket-patch scan --sync` first.", - &[], - ); - 1 - } - Err(e) => { - emit_envelope_error(&args, e.code, &e.message, &[]); - 2 + Err(mut e) => { + run_warnings.append(&mut e.warnings); + let (message, exit) = match e.code { + "no_applicable_patches" => (e.message, 1), + // Standalone-only remediation hints: after an embedded + // `apply --vex` / `scan --vex` run the advice would be + // circular, so the shared path keeps the bare message and + // it is appended here. + "no_patches" => ( + "Manifest is empty — nothing to attest. Run `socket-patch get` \ + or `socket-patch scan --sync` first." + .to_string(), + 1, + ), + "manifest_not_found" => ( + format_manifest_not_found_hint(&e.message, &args.common.manifest_path), + 2, + ), + _ => (e.message, 2), + }; + emit_envelope_error(&args, e.code, &message, &e.failed, &run_warnings); + exit } } } +/// `Manifest not found at . Run ... first[, or pass --manifest-path].` +/// The `--manifest-path` hint only makes sense while the default path is in +/// use; a user who already pointed elsewhere gets just the next step. +fn format_manifest_not_found_hint(message: &str, manifest_path: &str) -> String { + let base = message.trim_end().trim_end_matches('.'); + if manifest_path == socket_patch_core::constants::DEFAULT_PATCH_MANIFEST_PATH { + format!( + "{base}. Run `socket-patch scan` or `socket-patch get` first, or pass \ + --manifest-path." + ) + } else { + format!("{base}. Run `socket-patch scan` or `socket-patch get` first.") + } +} + +/// `Wrote OpenVEX document with 1 statement to out.json` — the one-line +/// summary after writing a document to a file. +pub(crate) fn format_vex_written(statements: usize, path: &Path) -> String { + format!( + "Wrote OpenVEX document with {} to {}", + plural(statements, "statement", "statements"), + path.display() + ) +} + +/// The note an embedded `--vex` prints under `--dry-run`, where no +/// document is built: `done` is what the dry run did not do ("applied", +/// "redirected", "vendored"). +pub(crate) fn format_vex_dry_run_skip(done: &str) -> String { + format!("Skipping VEX generation (--dry-run: nothing was {done}).") +} + +/// The `--dry-run` twin of [`format_vex_written`]: nothing was written. +pub(crate) fn format_vex_dry_run(statements: usize, path: &Path) -> String { + format!( + "[dry-run] Would write OpenVEX document with {} to {}", + plural(statements, "statement", "statements"), + path.display() + ) +} + +/// The stderr summary after the document went to stdout. +pub(crate) fn format_vex_emitted(statements: usize) -> String { + format!( + "Emitted {}", + plural(statements, "VEX statement", "VEX statements") + ) +} + +/// A warning when the `--org` slug looks like a file path, i.e. a `-o` +/// typed for `-O`/`--output`. Slugs never contain slashes or end in +/// `.json`. +fn org_looks_like_path(org: Option<&str>) -> Option { + let org = org?.trim(); + let pathy = + org.contains('/') || org.contains('\\') || org.to_ascii_lowercase().ends_with(".json"); + pathy.then(|| { + format!("--org {org:?} looks like a file path; did you mean -O/--output? (-o is --org)") + }) +} + /// Map a `setup.manual` entry to an `Ecosystem`. Accepts the canonical /// `cli_name` plus the friendly aliases `setup --exclude`/`--ecosystems` accept /// (`go`/`golang`, `python`/`pypi`, `ruby`/`gem`, `php`/`composer`). @@ -321,15 +425,14 @@ async fn generate_vex( manifest: &PatchManifest, redirected: &[String], ledger: std::io::Result, + warnings: &mut Vec, ) -> Result { // Resolve product. - let product_id = match resolve_product_id(common, params.product.as_deref()).await { + let product_id = match resolve_product_id(common, params.product.as_deref(), warnings).await { Ok(id) => id, Err(reason) => return Err(fail(common, "product_undetected", reason).await), }; - let mut warnings: Vec = Vec::new(); - // The help text promises "PURL/identifier", so an arbitrary string is // accepted — but the OpenVEX spec types the product `@id` as an IRI, and // strict consumers (vexctl et al.) may reject or mis-key a bare name. @@ -343,14 +446,15 @@ async fn generate_vex( { if !has_iri_scheme(p) { note_warning( - &mut warnings, + warnings, common, "product_not_iri", format!( - "product override {p:?} (--product / --vex-product) is neither a PURL \ + "Product override {p:?} ({}) is neither a PURL \ (pkg:...) nor an absolute IRI; it is emitted verbatim as the OpenVEX \ product @id, which the spec requires to be an IRI — strict consumers may \ - reject the document. Prefer pkg:/@." + reject the document. Prefer pkg:/@.", + params.product_flag ), ); } @@ -363,8 +467,20 @@ async fn generate_vex( // `outcome.vendored`, and both are about how the patch persists, // not whether this run hashed it. The committed ledger is as // trustworthy as the manifest beside it, and reading it hashes - // nothing. An unreadable ledger degrades to "nothing vendored". - let entries = ledger.map(|state| state.entries).unwrap_or_default(); + // nothing. An unreadable ledger degrades to "nothing vendored" + // (and says so). + let entries = match ledger { + Ok(state) => state.entries, + Err(e) => { + note_warning( + warnings, + common, + "vendor_state_unreadable", + vendor_state_unreadable_message(&e.to_string()), + ); + HashMap::new() + } + }; let vendored = manifest .patches .keys() @@ -384,7 +500,10 @@ async fn generate_vex( let quiet = common.silent || common.json || params.output.is_none(); let purls: Vec = manifest.patches.keys().cloned().collect(); let package_paths = find_manifest_package_paths(&purls, common, quiet).await; - let vendor = vendor_context_from(common, manifest, ledger).await; + let (vendor, vendor_warning) = vendor_context_from(common, manifest, ledger).await; + if let Some(detail) = vendor_warning { + note_warning(warnings, common, "vendor_state_unreadable", detail); + } socket_patch_core::vex::applied_patches_with_vendor( manifest, &package_paths, @@ -453,7 +572,7 @@ async fn generate_vex( // package-manager install. for purl in &outcome.vendored_out_of_sync { note_warning( - &mut warnings, + warnings, common, "vendored_tree_out_of_sync", format!( @@ -500,12 +619,7 @@ async fn generate_vex( } keep }); - if !setup_filtered.is_empty() && !common.silent && !common.json { - eprintln!( - "Note: omitting patches for ecosystems that are not set up (and not declared `manual` \ - in .socket/manifest.json's `setup.manual`) from VEX." - ); - } + let any_setup_filtered = !setup_filtered.is_empty(); // The filter drops join the omission channel (`failed`) with their own // routing tag so they surface as per-purl `skipped` events in the // envelope — success and error paths alike. Before this they existed @@ -517,14 +631,32 @@ async fn generate_vex( purl, reason: ECOSYSTEM_NOT_SETUP.to_string(), })); - - if !outcome.failed.is_empty() && !common.silent && !common.json { - for f in &outcome.failed { + // `manifest.patches` is a HashMap: without a sort the omission order + // (stderr, the error list and the JSON `skipped` events) changes run + // to run. + outcome + .failed + .sort_by(|a, b| (&a.purl, &a.reason).cmp(&(&b.purl, &b.reason))); + + // When nothing attests and EVERY omission was the property-7 filter, + // the run fails with a message that explains the setup cause itself + // (see below), so the generic note would only repeat it. + let all_setup_drops = outcome.applied.is_empty() + && !outcome.failed.is_empty() + && outcome + .failed + .iter() + .all(|f| f.reason == ECOSYSTEM_NOT_SETUP); + if !common.silent && !common.json { + if any_setup_filtered && !all_setup_drops { eprintln!( - "Warning: omitting patch for {} from VEX ({})", - f.purl, f.reason + "Note: patches for ecosystems that are not set up (and not declared `manual` \ + in .socket/manifest.json's `setup.manual`) are omitted from VEX." ); } + for f in &outcome.failed { + eprintln!("{}", format_omission_warning(&f.purl, &f.reason)); + } } // Build the document. @@ -560,20 +692,8 @@ async fn generate_vex( // it is the documented exit-1 routing tag consumers already // branch on; the per-event `ecosystem_not_setup` errorCode is // the machine-readable discriminator. - let all_setup_drops = outcome.applied.is_empty() - && !outcome.failed.is_empty() - && outcome - .failed - .iter() - .all(|f| f.reason == ECOSYSTEM_NOT_SETUP); let message = if all_setup_drops { - format!( - "{} applied patch(es) with vulnerability metadata were omitted from VEX \ - because their ecosystems are not set up (no install hook) and not declared \ - `manual` in .socket/manifest.json's `setup.manual`. Run `socket-patch \ - setup`, or add the ecosystem to `setup.manual`, then re-run.", - outcome.failed.len() - ) + format_setup_drops_message(outcome.failed.len()) } else { "No applied patches with vulnerability metadata to attest.".to_string() }; @@ -581,6 +701,7 @@ async fn generate_vex( code: "no_applicable_patches", message, failed: outcome.failed, + warnings: Vec::new(), }); } }; @@ -595,17 +716,19 @@ async fn generate_vex( Err(e) => return Err(fail(common, "serialize_failed", e.to_string()).await), }; - // Write. + // Write. The file gets the same trailing newline `println!` gives the + // stdout form, so `cat out.json` does not glue the prompt to the `}`. let wrote_to_file = match ¶ms.output { + Some(_) if params.dry_run => false, Some(path) => { - if let Err(e) = tokio::fs::write(path, &serialized).await { + if let Err(e) = tokio::fs::write(path, format!("{serialized}\n")).await { // The raw io::Error ("No such file or directory (os error // 2)") names neither the file nor the operation — useless // in a CI log. Say what was being written and where. return Err(fail( common, "write_failed", - format!("failed to write VEX document to {}: {e}", path.display()), + format!("Failed to write VEX document to {}: {e}", path.display()), ) .await); } @@ -621,7 +744,11 @@ async fn generate_vex( track_vex_generated( doc.statements.len(), "openvex-0.2.0", - if wrote_to_file { "file" } else { "stdout" }, + if params.output.is_some() { + "file" + } else { + "stdout" + }, token.as_deref(), org.as_deref(), ) @@ -631,7 +758,8 @@ async fn generate_vex( statements: doc.statements.len(), failed: outcome.failed, doc, - warnings, + warnings: Vec::new(), + wrote_file: wrote_to_file, }) } @@ -679,11 +807,36 @@ pub(crate) async fn generate_vex_from_manifest_path( params: &VexBuildParams, manifest_path: &Path, ) -> Result { - let result = generate_vex_from_manifest_path_inner(common, params, manifest_path).await; - if result.is_err() { - remove_stale_vex_doc(params.output.as_deref()).await; + let mut warnings = Vec::new(); + let result = + generate_vex_from_manifest_path_inner(common, params, manifest_path, &mut warnings).await; + match result { + Ok(mut summary) => { + summary.warnings = warnings; + Ok(summary) + } + Err(mut e) => { + // A dry run mutates nothing, a stale document included. + if !params.dry_run { + if let Some(path) = params.output.as_deref() { + if remove_stale_vex_doc(path).await { + note_warning( + &mut warnings, + common, + "vex_stale_doc_removed", + format!( + "Removed the previous VEX document at {} (this run could not \ + attest it).", + path.display() + ), + ); + } + } + } + e.warnings = warnings; + Err(e) + } } - result } /// Delete a PRIOR run's OpenVEX document at `output` after a failed run. @@ -691,11 +844,10 @@ pub(crate) async fn generate_vex_from_manifest_path( /// openvex.dev) is removed — the guard keeps a mistyped `--output` pointing /// at an unrelated file from being destroyed by an unrelated failure. /// Removal errors are swallowed: the non-zero exit is the contract, the -/// deletion is hygiene. -async fn remove_stale_vex_doc(output: Option<&Path>) { - let Some(path) = output else { return }; +/// deletion is hygiene. Returns whether a document was actually removed. +async fn remove_stale_vex_doc(path: &Path) -> bool { let Ok(bytes) = tokio::fs::read(path).await else { - return; + return false; }; let is_openvex = serde_json::from_slice::(&bytes) .ok() @@ -705,9 +857,7 @@ async fn remove_stale_vex_doc(output: Option<&Path>) { .map(|c| c.contains("openvex.dev")) }) .unwrap_or(false); - if is_openvex { - let _ = tokio::fs::remove_file(path).await; - } + is_openvex && tokio::fs::remove_file(path).await.is_ok() } /// [`generate_vex_from_manifest_path`] without the failure-cleanup wrapper. @@ -715,10 +865,16 @@ async fn generate_vex_from_manifest_path_inner( common: &GlobalArgs, params: &VexBuildParams, manifest_path: &Path, + warnings: &mut Vec, ) -> Result { let manifest_file = match read_manifest(manifest_path).await { Ok(m) => m, - Err(e) => return Err(fail(common, "manifest_unreadable", e.to_string()).await), + Err(e) => { + // Core's text ("Failed to parse manifest JSON: ...") does not + // say which file; in a workspace that matters. + let message = format!("{e} (in {})", manifest_path.display()); + return Err(fail(common, "manifest_unreadable", message).await); + } }; let had_manifest_file = manifest_file.is_some(); // ONE read of the committed vendor ledger for the whole run: the @@ -743,13 +899,27 @@ async fn generate_vex_from_manifest_path_inner( let (manifest, redirected) = match augment_with_redirect(common, manifest).await { Ok(augmented) => augmented, Err(corrupt) => { - return Err(fail(common, "redirect_ledger_corrupt", corrupt.to_string()).await); + // Not core's Display: that text ("... so it will not be + // overwritten") is written for the `scan --redirect` writer, and + // `vex` only reads the ledger. + let message = format!( + "The redirect ledger {} is malformed ({}); cannot attest redirected patches. \ + Repair its JSON or restore it from version control, then re-run.", + corrupt.path.display(), + corrupt.detail + ); + return Err(fail(common, "redirect_ledger_corrupt", message).await); } }; if manifest.patches.is_empty() { let ledger_note = match &ledger { Err(e) => { - warn_unreadable_vendor_state(common, e); + note_warning( + warnings, + common, + "vendor_state_unreadable", + vendor_state_unreadable_message(&e.to_string()), + ); format!("; the vendor ledger is also unreadable ({e})") } Ok(_) => String::new(), @@ -772,7 +942,7 @@ async fn generate_vex_from_manifest_path_inner( ) .await); } - generate_vex(common, params, &manifest, &redirected, ledger).await + generate_vex(common, params, &manifest, &redirected, ledger, warnings).await } /// Fold the `scan --redirect` ledger's embedded records into a manifest view @@ -813,12 +983,18 @@ async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGe code, message, failed: Vec::new(), + warnings: Vec::new(), } } /// Pick the product PURL from an explicit override or by filesystem -/// auto-detect. -async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Result { +/// auto-detect. Auto-detect advisories (several project manifests) join +/// `warnings`. +async fn resolve_product_id( + common: &GlobalArgs, + product: Option<&str>, + warnings: &mut Vec, +) -> Result { // An empty (or whitespace-only) override means "unset" — the semantics // `scrub_empty_env_vars` already gives the `SOCKET_VEX_PRODUCT=` twin and // `api_client_overrides` gives `--api-url ""`. Without the filter, @@ -830,29 +1006,65 @@ async fn resolve_product_id(common: &GlobalArgs, product: Option<&str>) -> Resul return Ok(p.to_string()); } let detect = detect_product(&common.cwd).await; - for w in &detect.warnings { - if !common.silent && !common.json { - eprintln!("Warning: {w}"); + for w in detect.warnings { + note_warning(warnings, common, "product_multiple_manifests", w); + } + if let Some(purl) = detect.purl { + return Ok(purl); + } + let mut found = Vec::new(); + for name in PRODUCT_MANIFESTS { + if tokio::fs::metadata(common.cwd.join(name)).await.is_ok() { + found.push(*name); } } - detect.purl.ok_or_else(|| { - format!( - "Could not auto-detect a top-level product PURL in {}. \ - Provide one with --product (e.g. pkg:npm/my-app@1.0.0).", - common.cwd.display() - ) - }) + Err(format_product_undetected(&common.cwd, &found)) +} + +/// The project manifests product auto-detection reads (after the git +/// remote), in its probe order. +const PRODUCT_MANIFESTS: &[&str] = &["package.json", "pyproject.toml", "Cargo.toml"]; + +/// The `product_undetected` message. `found` names the manifests that exist +/// but yielded no PURL (no name/version), so the user knows which file to +/// fix instead of guessing. +fn format_product_undetected(cwd: &Path, found: &[&str]) -> String { + let why = match found { + [] => String::new(), + [one] => format!(" ({one} was found but has no usable name and version)"), + many => format!( + " ({} were found but have no usable name and version)", + join_and(many) + ), + }; + format!( + "Could not auto-detect a top-level product PURL in {}{why}. \ + Provide one with --product (e.g. pkg:npm/my-app@1.0.0).", + cwd.display() + ) +} + +/// `a`, `a and b`, `a, b and c`. +fn join_and(items: &[&str]) -> String { + match items { + [] => String::new(), + [one] => (*one).to_string(), + [init @ .., last] => format!("{} and {last}", init.join(", ")), + } } /// The one `unreadable vendor state` advisory (contract: `setup --check` /// and `vex` surface a ledger they cannot read or parse as this line, muted /// by `--silent`): a read-only consumer degrades to "nothing vendored" and -/// says so, on stderr, so the operator learns why nothing attests. +/// says so, on stderr, so the operator learns why nothing attests. `vex` +/// routes the same [`vendor_state_unreadable_message`] through its +/// warnings channel instead (stderr in human mode, `warnings[]` under +/// `--json`); this direct form is `setup --check`'s. pub(crate) fn warn_unreadable_vendor_state(common: &GlobalArgs, e: &std::io::Error) { if !common.silent { eprintln!( - "Warning: unreadable vendor state ({e}); vendored patches cannot be verified \ - from the committed artifact" + "Warning: {}", + vendor_state_unreadable_message(&e.to_string()) ); } } @@ -871,37 +1083,48 @@ pub(crate) fn warn_unreadable_vendor_state(common: &GlobalArgs, e: &std::io::Err /// silently omitted from the VEX document. The redirect copy dir holds the /// bytes the build actually consumes, so it is what verification must hash. /// -/// An unreadable/corrupt vendor ledger degrades to "no vendor entries" -/// (with a stderr warning): vendored PURLs then fall through to the -/// installed tree, fail verification there, and are omitted — fail-closed, -/// never falsely attested. Returns `None` when there is nothing vendored -/// and no redirect to synthesize (the common case). +/// An unreadable/corrupt vendor ledger degrades to "no vendor entries": +/// vendored PURLs then fall through to the installed tree, fail +/// verification there, and are omitted — fail-closed, never falsely +/// attested. The degrade is returned as a warning detail for the caller to +/// report in its own channel. The context is `None` when there is nothing +/// vendored and no redirect to synthesize (the common case). pub(crate) async fn vendor_context_from( common: &GlobalArgs, manifest: &PatchManifest, ledger: std::io::Result, -) -> Option { - let entries = match ledger { - Ok(state) => state.entries, - Err(e) => { - warn_unreadable_vendor_state(common, &e); - HashMap::new() - } +) -> (Option, Option) { + let (entries, warning) = match ledger { + Ok(state) => (state.entries, None), + Err(e) => ( + HashMap::new(), + Some(vendor_state_unreadable_message(&e.to_string())), + ), }; let go_patches = synthesize_go_patches(common, manifest, &entries).await; if entries.is_empty() && go_patches.is_empty() { - return None; + return (None, warning); } - Some(VendorContext { + let context = VendorContext { project_root: common.cwd.clone(), entries, go_patches, - }) + }; + (Some(context), warning) +} + +/// The unreadable-`.socket/vendor/state.json` advisory (`cause` is +/// `load_state`'s error, which names the file). +pub(crate) fn vendor_state_unreadable_message(cause: &str) -> String { + format!( + "Unreadable vendor state ({cause}); vendored patches cannot be verified from the \ + committed artifact" + ) } -/// Synthesize go-patches redirect targets for [`load_vendor_context`]: for +/// Synthesize go-patches redirect targets for [`vendor_context_from`]: for /// every socket-owned (`.socket/go-patches/`) `replace` in `go.mod` whose /// module+version maps to a manifest golang PURL with no explicit vendor /// entry, record the absolute redirect copy dir for dir-hash verification. @@ -954,9 +1177,16 @@ async fn synthesize_go_patches( /// stdout in `--json` mode, a stderr message otherwise. `failures` lists /// patches omitted by verification (populated for `no_applicable_patches`, /// empty everywhere else). -fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[FailedPatch]) { +fn emit_envelope_error( + args: &VexArgs, + code: &str, + message: &str, + failures: &[FailedPatch], + warnings: &[RunWarning], +) { if args.common.json { let mut env = Envelope::new(Command::Vex); + env.dry_run = args.common.dry_run; for f in failures { env.record( PatchEvent::new(PatchAction::Skipped, f.purl.clone()) @@ -964,29 +1194,79 @@ fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[Fa ); } env.mark_error(EnvelopeError::new(code, message.to_string())); + env.warnings = warnings.to_vec(); println!("{}", env.to_pretty_json()); } else { eprintln!("Error: {message}"); - for f in failures { - eprintln!(" omitted: {} ({})", f.purl, f.reason); + // The per-patch "Warning: omitting ..." lines already named each + // omission; `--silent` muted them, so list them with the error. + if args.common.silent { + for f in failures { + eprintln!(" omitted: {} ({})", f.purl, f.reason); + } + } + } +} + +/// What an omission routing tag means, in words (the tag itself stays the +/// machine-readable `errorCode`). +fn omission_phrase(reason: &str) -> &'static str { + match reason { + ECOSYSTEM_NOT_SETUP => { + "applied, but its ecosystem has no install hook set up and is not declared \ + `manual` in setup.manual" } + "package_not_found" => "the package is not installed", + "not_applied" => "the patched files still hold the original content", + "hash_mismatch" => "a patched file matches neither the original nor the patched content", + "file_not_found" => "a patched file is missing", + "no_files" => "the patch record lists no files", + "vendor_hash_mismatch" => "the vendored artifact does not match the patch", + "stale_install" => "the installed copy is not patched", + _ => "the patch could not be verified", } } +/// The per-patch stderr line: the readable phrase, then the tag in +/// parentheses (what `--json` reports as `errorCode`). +fn format_omission_warning(purl: &str, reason: &str) -> String { + format!( + "Warning: omitting {purl} from VEX: {} ({reason})", + omission_phrase(reason) + ) +} + /// Human `reason` string for an omission event; the routing tag rides -/// `errorCode`. The property-7 drop gets its own phrasing — that patch IS -/// applied and verified, which the generic "omitted" alone doesn't convey. -fn omission_reason_message(reason: &str) -> &'static str { +/// `errorCode`. +fn omission_reason_message(reason: &str) -> String { if reason == ECOSYSTEM_NOT_SETUP { "applied patch omitted from VEX: its ecosystem has no install hook set up and is not \ declared `manual` in setup.manual" + .to_string() } else { - "patch omitted from VEX" + format!("patch omitted from VEX: {}", omission_phrase(reason)) } } -fn emit_envelope_success(summary: &VexWriteSummary) { +/// The `no_applicable_patches` message when every omission was the +/// property-7 setup filter. +fn format_setup_drops_message(n: usize) -> String { + let (subject, verb, their, ecosystems) = if n == 1 { + ("applied patch", "was", "its", "ecosystem is") + } else { + ("applied patches", "were", "their", "ecosystems are") + }; + format!( + "{n} {subject} with vulnerability metadata {verb} omitted from VEX because {their} \ + {ecosystems} not set up (no install hook) and not declared `manual` in \ + .socket/manifest.json's `setup.manual`. Run `socket-patch setup`, or add the \ + ecosystem to `setup.manual`, then re-run." + ) +} + +fn emit_envelope_success(summary: &VexWriteSummary, dry_run: bool) { let mut env = Envelope::new(Command::Vex); + env.dry_run = dry_run; for st in &summary.doc.statements { for prod in &st.products { for sub in &prod.subcomponents { @@ -1123,6 +1403,154 @@ mod tests { assert!(!has_iri_scheme("bad scheme:rest")); } + #[test] + fn vex_summary_lines_pluralize() { + let p = Path::new("out.json"); + assert_eq!( + format_vex_written(1, p), + "Wrote OpenVEX document with 1 statement to out.json" + ); + assert_eq!( + format_vex_dry_run_skip("applied"), + "Skipping VEX generation (--dry-run: nothing was applied)." + ); + assert_eq!( + format_vex_written(0, p), + "Wrote OpenVEX document with 0 statements to out.json" + ); + assert_eq!( + format_vex_written(3, Path::new("dir/é.json")), + "Wrote OpenVEX document with 3 statements to dir/é.json" + ); + assert_eq!( + format_vex_dry_run(1, p), + "[dry-run] Would write OpenVEX document with 1 statement to out.json" + ); + assert_eq!( + format_vex_dry_run(2, p), + "[dry-run] Would write OpenVEX document with 2 statements to out.json" + ); + assert_eq!(format_vex_emitted(1), "Emitted 1 VEX statement"); + assert_eq!(format_vex_emitted(12), "Emitted 12 VEX statements"); + } + + #[test] + fn setup_drops_message_agrees_in_number() { + let one = format_setup_drops_message(1); + assert!( + one.starts_with( + "1 applied patch with vulnerability metadata was omitted from VEX because its \ + ecosystem is not set up (no install hook)" + ), + "{one}" + ); + let two = format_setup_drops_message(2); + assert!( + two.starts_with( + "2 applied patches with vulnerability metadata were omitted from VEX because \ + their ecosystems are not set up (no install hook)" + ), + "{two}" + ); + for m in [&one, &two] { + assert!(!m.contains("(s)"), "{m}"); + assert!(m.ends_with("then re-run."), "{m}"); + } + } + + #[test] + fn omission_warning_names_phrase_and_tag() { + assert_eq!( + format_omission_warning("pkg:npm/a@1.0.0", "not_applied"), + "Warning: omitting pkg:npm/a@1.0.0 from VEX: the patched files still hold the \ + original content (not_applied)" + ); + assert_eq!( + format_omission_warning("pkg:npm/b@2.0.0", "package_not_found"), + "Warning: omitting pkg:npm/b@2.0.0 from VEX: the package is not installed \ + (package_not_found)" + ); + // Unknown tags still read as a sentence and keep the raw tag. + assert_eq!( + format_omission_warning("pkg:npm/c@3.0.0", "brand_new_tag"), + "Warning: omitting pkg:npm/c@3.0.0 from VEX: the patch could not be verified \ + (brand_new_tag)" + ); + for tag in [ + ECOSYSTEM_NOT_SETUP, + "package_not_found", + "not_applied", + "hash_mismatch", + "file_not_found", + "no_files", + "vendor_hash_mismatch", + "stale_install", + ] { + assert_ne!( + omission_phrase(tag), + omission_phrase("brand_new_tag"), + "{tag} has no phrase of its own" + ); + let reason = omission_reason_message(tag); + assert!(reason.contains("omitted from VEX"), "{reason}"); + } + assert_eq!( + omission_reason_message("hash_mismatch"), + "patch omitted from VEX: a patched file matches neither the original nor the \ + patched content" + ); + } + + #[test] + fn product_undetected_names_unusable_manifests() { + let cwd = Path::new("proj"); + assert_eq!( + format_product_undetected(cwd, &[]), + "Could not auto-detect a top-level product PURL in proj. Provide one with \ + --product (e.g. pkg:npm/my-app@1.0.0)." + ); + assert_eq!( + format_product_undetected(cwd, &["package.json"]), + "Could not auto-detect a top-level product PURL in proj (package.json was found \ + but has no usable name and version). Provide one with --product (e.g. \ + pkg:npm/my-app@1.0.0)." + ); + let many = + format_product_undetected(cwd, &["package.json", "pyproject.toml", "Cargo.toml"]); + assert!( + many.contains( + "(package.json, pyproject.toml and Cargo.toml were found but have no usable \ + name and version)" + ), + "{many}" + ); + assert_eq!(join_and(&["a", "b"]), "a and b"); + assert_eq!(join_and(&[]), ""); + } + + #[test] + fn org_path_heuristic() { + assert_eq!(org_looks_like_path(None), None); + assert_eq!(org_looks_like_path(Some("socketdev")), None); + assert_eq!(org_looks_like_path(Some("my-org_2")), None); + assert_eq!( + org_looks_like_path(Some("out.json")).as_deref(), + Some("--org \"out.json\" looks like a file path; did you mean -O/--output? (-o is --org)") + ); + assert!(org_looks_like_path(Some("reports/vex")).is_some()); + assert!(org_looks_like_path(Some("C:\\vex")).is_some()); + assert!(org_looks_like_path(Some("OUT.JSON")).is_some()); + } + + #[test] + fn vendor_state_message_is_capitalized_and_keeps_cause() { + assert_eq!( + vendor_state_unreadable_message("corrupt x/state.json: eof"), + "Unreadable vendor state (corrupt x/state.json: eof); vendored patches cannot be \ + verified from the committed artifact" + ); + } + #[derive(Parser)] struct Wrap { #[command(subcommand)] diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 54de6cdc..7b065b9c 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -78,6 +78,8 @@ macro_rules! scan_ecosystem { && ($options.global || $options.global_prefix.is_some()) && !$silent { + // Status chrome: stderr, so it can never reach a + // machine stream (`--json` envelope, vex document). if let Some(first) = paths.first() { eprintln!("Using {} at: {}", using, first.display()); } @@ -101,7 +103,7 @@ macro_rules! scan_ecosystem { } Err(e) => { if !$silent { - eprintln!("Failed to find {}: {}", $err_label, e); + eprintln!("Warning: Failed to find {}: {}", $err_label, e); } } } diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 29adbc70..6406d9ab 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -9,8 +9,8 @@ pub mod args; pub mod commands; pub(crate) mod ecosystem_dispatch; pub mod json_envelope; -pub mod output; pub mod path_scope; +pub mod ui; pub mod update_notifier; use clap::{Parser, Subcommand}; @@ -59,18 +59,21 @@ pub enum Commands { /// vulnerabilities mitigated by the applied patches. Vex(commands::vex::VexArgs), - /// Eject patched dependencies into committable `.socket/vendor/` - /// and rewire lockfiles so fresh checkouts build with the patches - /// (no socket-patch or Socket API needed). `--revert` undoes it. + /// Eject patched dependencies into committable `.socket/vendor/` and + /// rewire lockfiles to use them (`--revert` undoes it) + /// + /// Fresh checkouts then build with the patches, with no socket-patch or + /// Socket API needed. Vendor(commands::vendor::VendorArgs), - /// Configure package.json postinstall scripts to apply patches + /// Wire install hooks (npm, Python, Bundler, Composer) that re-apply + /// patches after install Setup(commands::setup::SetupArgs), - /// Rollback patches to restore original files + /// Roll back patches to restore original files Rollback(commands::rollback::RollbackArgs), - /// Get security patches from Socket API and apply them + /// Get security patches from the Socket API and apply them #[command(visible_alias = "download")] Get(commands::get::GetArgs), @@ -80,21 +83,30 @@ pub enum Commands { /// Remove a patch from the manifest by PURL or UUID (rolls back files first) Remove(commands::remove::RemoveArgs), - /// Download missing blobs and clean up unused blobs. + /// Download missing patch artifacts and clean up unused ones /// - /// `repair` (alias `gc`) is a first-class command for cleaning up - /// the `.socket/` directory without running a scan. For the - /// combined workflow (discover + apply + GC), use - /// `scan --sync --json --yes`. `repair`/`gc` remain useful on - /// their own when the user wants to clean up without an apply pass. + /// Restores missing blobs and diff/package archives, rebuilds missing + /// or corrupt vendored artifacts, then deletes the artifacts nothing + /// references. It needs no scan; for the combined workflow (discover, + /// apply, clean up) use `scan --sync --json --yes`. #[command(visible_alias = "gc")] Repair(commands::repair::RepairArgs), - /// Internal parse target of the root `--update` flag (see the rewrite - /// in [`parse_argv_with_shortcuts`]). Hidden: the public contract - /// surface is `socket-patch --update`, and this name carries no - /// stability guarantee (documented as internal in CLI_CONTRACT.md). - #[command(hide = true, name = "self-update")] + // Internal parse target of the root `--update` flag (see the rewrite + // in `parse_argv_with_shortcuts`). Hidden: the public contract + // surface is `socket-patch --update`, and this name carries no + // stability guarantee (documented as internal in CLI_CONTRACT.md). + // Plain `//` comments plus an explicit `about`/`override_usage`: a doc + // comment here is what `socket-patch --update --help` printed, and the + // derived usage line named the hidden subcommand, and so did the + // `--update --version` line until `display_name` pinned it. + #[command( + hide = true, + name = "self-update", + display_name = "socket-patch", + about = "Update socket-patch itself to the latest (or a pinned) release", + override_usage = "socket-patch --update [VERSION] [OPTIONS]" + )] SelfUpdate(commands::update::UpdateArgs), } @@ -608,7 +620,14 @@ mod tests { assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); assert!(!err.use_stderr()); assert_eq!(err.exit_code(), 0); - assert!(err.to_string().contains("self-update"), "{err}"); + // The page is self-update's, but spelled the public way: the hidden + // subcommand name must not leak into its usage line. + let text = err.to_string(); + assert!( + text.contains("Usage: socket-patch --update [VERSION]"), + "{text}" + ); + assert!(!text.contains("self-update"), "{text}"); } #[test] diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index 16a32a4a..a1434d09 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -61,7 +61,7 @@ async fn main() { { Ok(argv) => argv, Err(bad_arg) => { - eprintln!("error: invalid UTF-8 was detected in one or more arguments: {bad_arg:?}"); + eprintln!("Error: Invalid UTF-8 was detected in one or more arguments: {bad_arg:?}"); std::process::exit(2); } }; @@ -76,11 +76,15 @@ async fn main() { // combination is contradictory; refuse with the contract's usage exit. if cli.update { eprintln!( - "error: --update cannot be combined with a subcommand; run `socket-patch --update` on its own" + "Error: --update cannot be combined with a subcommand; run `socket-patch --update` on its own" ); std::process::exit(2); } + // Human-output policy (core advisories and prompt notes go quiet under + // --silent/--json) is fixed once, before any command code runs. + socket_patch_cli::ui::init(cli.command.global_args()); + // Passive update notifier: guards + (maybe) a background check kicked // off before dispatch, joined with a short grace budget after it. // Structurally skipped for `--update` itself — an explicit update IS diff --git a/crates/socket-patch-cli/src/output.rs b/crates/socket-patch-cli/src/output.rs deleted file mode 100644 index a1265c8c..00000000 --- a/crates/socket-patch-cli/src/output.rs +++ /dev/null @@ -1,390 +0,0 @@ -use std::io::{self, IsTerminal, Write}; - -/// Check if stdin is a terminal (for interactive prompts). -pub(crate) fn stdin_is_tty() -> bool { - std::io::stdin().is_terminal() -} - -/// Print one JSON document, pretty-printed, to stdout — the one writer -/// behind every `--json` envelope, so each consumer parses stdout as -/// exactly one document. -pub(crate) fn print_json(v: &serde_json::Value) { - println!( - "{}", - serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") - ); -} - -/// The update notifier's TTY gate reads *stderr*, not stdin: the notice -/// prints there, and stdout may be legitimately piped (`list | jq`) in a -/// perfectly interactive session. -pub(crate) fn stderr_is_tty() -> bool { - std::io::stderr().is_terminal() -} - -/// Format a severity string with optional ANSI colors. -pub fn format_severity(s: &str, use_color: bool) -> String { - if !use_color { - return s.to_string(); - } - match s.to_lowercase().as_str() { - "critical" => format!("\x1b[91m{s}\x1b[0m"), - "high" => format!("\x1b[31m{s}\x1b[0m"), - // GHSA emits `moderate`; same tier as medium (see get.rs severity_rank). - "medium" | "moderate" => format!("\x1b[33m{s}\x1b[0m"), - "low" => format!("\x1b[36m{s}\x1b[0m"), - _ => s.to_string(), - } -} - -/// Wrap text in ANSI color codes if use_color is true. -pub fn color(text: &str, code: &str, use_color: bool) -> String { - if use_color { - format!("\x1b[{code}m{text}\x1b[0m") - } else { - text.to_string() - } -} - -/// Error type for interactive selection. -pub enum SelectError { - /// User cancelled the selection. - Cancelled, - /// JSON mode requires explicit selection (re-running with the chosen - /// UUID as the identifier — `--id` is a boolean type-tag, not a - /// value-taking selector). - JsonModeNeedsExplicit, -} - -/// Prompt the user for a yes/no confirmation. -/// -/// - `skip_prompt` (from `-y` flag) or `is_json`: return `default_yes` immediately. -/// - Non-TTY stdin: return `default_yes` with a stderr warning. -/// - Interactive: print prompt to stderr, read line; empty = `default_yes`; -/// unreadable input (e.g. non-UTF-8 bytes) = no. -pub(crate) fn confirm(prompt: &str, default_yes: bool, skip_prompt: bool, is_json: bool) -> bool { - if skip_prompt || is_json { - return default_yes; - } - if !stdin_is_tty() { - eprintln!("Non-interactive mode detected, proceeding with default."); - return default_yes; - } - let hint = if default_yes { "[Y/n]" } else { "[y/N]" }; - eprint!("{prompt} {hint} "); - io::stderr() - .flush() - .expect("stderr is unbuffered, so flush cannot fail"); - // An empty answer takes the default; an unreadable one declines. - read_yes_no().unwrap_or(default_yes) -} - -/// Read one yes/no answer from stdin: `Some(true)` for `y`/`yes` (any -/// case, surrounding whitespace ignored), `Some(false)` for any other -/// answer — including a line that could not be read: terminals can deliver -/// non-UTF-8 bytes (a Latin-1 paste), which `read_line` reports as -/// `InvalidData`, and that is a decline, never a panic — and `None` when -/// nothing was answered (an empty line), which callers map to their own -/// default. -pub(crate) fn read_yes_no() -> Option { - let mut answer = String::new(); - if io::stdin().read_line(&mut answer).is_err() { - return Some(false); - } - let answer = answer.trim().to_lowercase(); - if answer.is_empty() { - return None; - } - Some(answer == "y" || answer == "yes") -} - -/// Prompt the user to select one option from a list using dialoguer. -/// -/// - `is_json`: return `Err(SelectError::JsonModeNeedsExplicit)`. -/// - Empty `options`: return `Err(SelectError::Cancelled)` — there is no -/// option to select, so neither auto-select nor an interactive menu is -/// meaningful (returning `Ok(0)` would hand callers an out-of-bounds index). -/// - Non-TTY: auto-select first option with stderr warning. -/// - Interactive: use `dialoguer::Select` on stderr. -pub fn select_one(prompt: &str, options: &[String], is_json: bool) -> Result { - if is_json { - return Err(SelectError::JsonModeNeedsExplicit); - } - if options.is_empty() { - return Err(SelectError::Cancelled); - } - if !stdin_is_tty() { - eprintln!("Non-interactive mode: auto-selecting first option."); - return Ok(0); - } - dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) - .with_prompt(prompt) - .items(options) - .default(0) - .interact_opt() - .map_err(|_| SelectError::Cancelled)? - .ok_or(SelectError::Cancelled) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ---- format_severity ---- - - #[test] - fn format_severity_critical_with_color() { - let out = format_severity("critical", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("critical"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("91"), "expected bright-red code 91: {out:?}"); - } - - #[test] - fn format_severity_high_with_color() { - let out = format_severity("high", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("high"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("31"), "expected red code 31: {out:?}"); - } - - #[test] - fn format_severity_medium_with_color() { - let out = format_severity("medium", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("medium"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("33"), "expected yellow code 33: {out:?}"); - } - - #[test] - fn format_severity_low_with_color() { - let out = format_severity("low", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("low"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("36"), "expected cyan code 36: {out:?}"); - } - - #[test] - fn format_severity_case_insensitive_critical_uppercase() { - let out = format_severity("CRITICAL", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("CRITICAL"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("91"), "expected bright-red code 91: {out:?}"); - } - - #[test] - fn format_severity_case_insensitive_critical_titlecase() { - let out = format_severity("Critical", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("Critical"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("91"), "expected bright-red code 91: {out:?}"); - } - - #[test] - fn format_severity_case_insensitive_high_lowercase() { - let out = format_severity("high", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("high"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - } - - #[test] - fn format_severity_case_insensitive_high_uppercase() { - let out = format_severity("HIGH", true); - assert!(out.starts_with("\x1b["), "expected ANSI prefix: {out:?}"); - assert!(out.contains("HIGH"), "expected input verbatim: {out:?}"); - assert!(out.ends_with("\x1b[0m"), "expected ANSI reset: {out:?}"); - assert!(out.contains("31"), "expected red code 31: {out:?}"); - } - - #[test] - fn format_severity_unknown_passes_through_with_color() { - let out = format_severity("unknown", true); - assert_eq!(out, "unknown"); - } - - #[test] - fn format_severity_critical_no_color() { - assert_eq!(format_severity("critical", false), "critical"); - } - - #[test] - fn format_severity_high_no_color() { - assert_eq!(format_severity("high", false), "high"); - } - - #[test] - fn format_severity_medium_no_color() { - assert_eq!(format_severity("medium", false), "medium"); - } - - #[test] - fn format_severity_low_no_color() { - assert_eq!(format_severity("low", false), "low"); - } - - #[test] - fn format_severity_unknown_no_color() { - assert_eq!(format_severity("unknown", false), "unknown"); - } - - #[test] - fn format_severity_empty_with_color_passes_through() { - let out = format_severity("", true); - assert_eq!(out, ""); - } - - #[test] - fn format_severity_full_color_ramp_is_exact() { - // Pin every known arm to its exact wrapper so an accidental palette - // edit is caught, not just "contains a digit". - assert_eq!(format_severity("critical", true), "\x1b[91mcritical\x1b[0m"); - assert_eq!(format_severity("high", true), "\x1b[31mhigh\x1b[0m"); - assert_eq!(format_severity("medium", true), "\x1b[33mmedium\x1b[0m"); - assert_eq!(format_severity("low", true), "\x1b[36mlow\x1b[0m"); - } - - #[test] - fn format_severity_moderate_is_medium_tier_yellow() { - // Regression: GHSA emits `moderate` for the medium tier (see - // get.rs `severity_rank`), and both scan.rs call sites pass raw - // API severities straight through. Dropping `moderate` into the - // unknown arm rendered a medium-tier vuln with no color at all — - // less prominent than `low` (cyan). - assert_eq!(format_severity("moderate", true), "\x1b[33mmoderate\x1b[0m"); - assert_eq!(format_severity("MODERATE", true), "\x1b[33mMODERATE\x1b[0m"); - assert_eq!(format_severity("moderate", false), "moderate"); - } - - #[test] - fn format_severity_critical_is_more_prominent_than_high() { - // Regression: `critical` is the worst severity and must render at - // least as loud as `high`. The ramp uses the high-intensity (9x) red - // for critical and the standard (3x) red for high; swapping them (the - // original bug) made `high` brighter than `critical`. - let crit = format_severity("critical", true); - let high = format_severity("high", true); - assert_ne!(crit, high, "critical and high must use distinct colors"); - assert!( - crit.contains("\x1b[91m"), - "critical must use high-intensity red 91: {crit:?}" - ); - assert!( - high.contains("\x1b[31m"), - "high must use standard red 31: {high:?}" - ); - // Guard the inversion directly: critical must not be wrapped in the - // duller standard-red code that belongs to `high`. - assert!( - !crit.contains("\x1b[31m"), - "critical must not use the duller standard red reserved for high: {crit:?}" - ); - } - - // ---- color ---- - - #[test] - fn color_with_color_on() { - assert_eq!(color("hi", "31", true), "\x1b[31mhi\x1b[0m"); - } - - #[test] - fn color_with_color_off() { - assert_eq!(color("hi", "31", false), "hi"); - } - - #[test] - fn color_with_empty_text_and_color_on() { - assert_eq!(color("", "1;32", true), "\x1b[1;32m\x1b[0m"); - } - - // ---- confirm ---- - - #[test] - fn confirm_skip_prompt_returns_default_yes_true() { - assert!(confirm("?", true, true, false)); - } - - #[test] - fn confirm_skip_prompt_returns_default_yes_false() { - assert!(!confirm("?", false, true, false)); - } - - #[test] - fn confirm_is_json_returns_default_yes_true() { - assert!(confirm("?", true, false, true)); - } - - #[test] - fn confirm_is_json_returns_default_yes_false() { - assert!(!confirm("?", false, false, true)); - } - - #[test] - fn confirm_skip_prompt_and_is_json_both_set_returns_default_yes() { - assert!(confirm("?", true, true, true)); - } - - // ---- select_one ---- - // - // Only the `is_json` branch is exercised here: it returns before reading - // stdin, so it is deterministic regardless of whether the test runs under - // a TTY. The non-TTY auto-select (`Ok(0)`) and the interactive - // `dialoguer` branches both depend on / consume the real stdin and would - // hang or vary by environment, so they are intentionally left to the e2e - // suite (see get.rs `select_patches` coverage). - - #[test] - fn select_one_json_mode_requires_explicit_selection() { - let opts = vec!["first".to_string(), "second".to_string()]; - match select_one("pick one", &opts, true) { - Err(SelectError::JsonModeNeedsExplicit) => {} - Err(SelectError::Cancelled) => panic!("json mode must not report Cancelled"), - Ok(idx) => panic!("json mode must not auto-select (got index {idx})"), - } - } - - #[test] - fn select_one_json_mode_ignores_options_contents() { - // Even with a single option, JSON mode must defer to an explicit - // UUID re-run rather than silently picking it. - let opts = vec!["only".to_string()]; - assert!(matches!( - select_one("pick", &opts, true), - Err(SelectError::JsonModeNeedsExplicit) - )); - } - - #[test] - fn select_one_empty_options_is_cancelled_not_index_zero() { - // Regression: with no options there is no "first" to auto-select. - // Returning `Ok(0)` here would hand the caller an out-of-bounds index - // (every caller does `group[idx]`). This guard runs before any stdin - // read, so it is deterministic under TTY and non-TTY alike. - let opts: Vec = Vec::new(); - match select_one("pick", &opts, false) { - Err(SelectError::Cancelled) => {} - Ok(idx) => panic!("empty options must not yield an index (got {idx})"), - Err(SelectError::JsonModeNeedsExplicit) => { - panic!("non-JSON empty options must report Cancelled, not JSON mode") - } - } - } - - #[test] - fn select_one_json_mode_takes_precedence_over_empty_options() { - // JSON mode is decided first: even an empty list must surface the - // explicit-selection contract so the caller can emit `selection_required`. - let opts: Vec = Vec::new(); - assert!(matches!( - select_one("pick", &opts, true), - Err(SelectError::JsonModeNeedsExplicit) - )); - } -} diff --git a/crates/socket-patch-cli/src/path_scope.rs b/crates/socket-patch-cli/src/path_scope.rs index 6468ab71..dfa27a77 100644 --- a/crates/socket-patch-cli/src/path_scope.rs +++ b/crates/socket-patch-cli/src/path_scope.rs @@ -70,6 +70,9 @@ impl PathScope { pub fn parse(raw_patterns: &[String]) -> Result { let mut patterns = Vec::with_capacity(raw_patterns.len()); let mut raw = Vec::with_capacity(raw_patterns.len()); + // Lowercase on purpose: scan and rollback print this after a clap-style + // `error: ` usage-error prefix. Capitalize it together with those + // call sites if they move to `Error: `. for r in raw_patterns { let normalized = normalize_pattern(r); if normalized.is_empty() { @@ -258,7 +261,10 @@ mod tests { #[test] fn invalid_pattern_is_a_parse_error() { let err = PathScope::parse(&["packages/[".to_string()]).unwrap_err(); - assert!(err.contains("invalid path pattern"), "{err}"); + assert!( + err.starts_with("invalid path pattern \"packages/[\": "), + "{err}" + ); let err = PathScope::parse(&["".to_string()]).unwrap_err(); assert!(err.contains("empty pattern"), "{err}"); } diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs new file mode 100644 index 00000000..ed0b70d6 --- /dev/null +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -0,0 +1,323 @@ +//! Terminal UI: everything that decides *how* human output looks. +//! +//! - [`StatusLine`]: the one self-rewriting progress line. +//! - [`confirm`], [`confirm_or_proceed`], [`select_one`]: +//! prompts. +//! - [`print_json`]: the one `--json` document writer. +//! - [`plural`], [`truncate`]: text shaping. +//! - [`color_enabled`], [`paint`], [`severity`], [`pad`]: color policy and +//! ANSI-aware column alignment. +//! - [`init`] / [`quiet`]: the process-wide `--silent`/`--json` switch, +//! shared with core's own advisories. +//! +//! Every piece that writes takes (or wraps) a plain `Write` sink so it can +//! be unit-tested against a `Vec`. + +mod prompt; +mod status; +mod text; + +use std::io::IsTerminal; + +use crate::args::GlobalArgs; + +pub(crate) use prompt::{confirm, confirm_or_proceed}; +pub use prompt::{select_one, SelectError}; +pub(crate) use status::StatusLine; +pub(crate) use text::{plural, truncate}; + +/// Call once after argument parsing. Core's informational advisories (and +/// the prompts' non-interactive notes) go quiet under `--silent`/`--json`; +/// core's warnings (token shape, org auto-detect) go quiet only under +/// `--silent`, since `--json` still reports warnings on stderr. Also +/// points console's (and so dialoguer's) color switches at our policy, +/// so a `NO_COLOR` menu is as plain as everything else. +pub fn init(common: &GlobalArgs) { + socket_patch_core::utils::notice::set_output_mode(common.silent, common.json); + console::set_colors_enabled(stdout_color()); + console::set_colors_enabled_stderr(stderr_color()); +} + +/// Print one JSON document, pretty-printed, to stdout — the one writer +/// behind every `--json` envelope, so each consumer parses stdout as +/// exactly one document. +pub(crate) fn print_json(v: &serde_json::Value) { + println!( + "{}", + serde_json::to_string_pretty(v).expect("serializing an in-memory JSON value cannot fail") + ); +} + +/// Whether stdin is a terminal a person can answer prompts on. +pub(crate) fn stdin_is_tty() -> bool { + std::io::stdin().is_terminal() +} + +/// Whether `--silent`/`--json` is in effect for this process (see [`init`]). +pub(crate) fn quiet() -> bool { + socket_patch_core::utils::notice::is_quiet() +} + +/// Whether the Windows console behind stdout and stderr accepted VT +/// (escape-sequence) processing, probed once. console's color probe is +/// what switches VT on, so its answer is exactly "escapes will render". +/// `false` for a stream that is not a console (it is then not a terminal +/// either, and escapes in a pipe are the reader's business). +#[cfg(windows)] +fn vt_probe() -> (bool, bool) { + static VT: std::sync::OnceLock<(bool, bool)> = std::sync::OnceLock::new(); + *VT.get_or_init(|| { + ( + console::Term::stdout().features().colors_supported(), + console::Term::stderr().features().colors_supported(), + ) + }) +} + +/// Whether escape sequences written to a terminal on stdout render: on +/// Windows, whether VT processing could be enabled ([`vt_probe`]). +#[cfg(windows)] +fn stdout_vt() -> bool { + vt_probe().0 +} + +/// [`stdout_vt`] for stderr (also gates the live [`StatusLine`]). +#[cfg(windows)] +pub(crate) fn stderr_vt() -> bool { + vt_probe().1 +} + +/// Unix terminals always render escape sequences. +#[cfg(not(windows))] +fn stdout_vt() -> bool { + true +} + +/// Unix terminals always render escape sequences. +#[cfg(not(windows))] +pub(crate) fn stderr_vt() -> bool { + true +} + +/// The color policy, pure over its inputs: +/// `NO_COLOR` (non-empty) → off; `CLICOLOR_FORCE` (non-empty, not `0`) → +/// on; not a terminal → off; `CLICOLOR=0` → off; `TERM=dumb` → off; +/// otherwise on. +pub(crate) fn color_enabled(is_tty: bool, env: impl Fn(&str) -> Option) -> bool { + let set = |k: &str| env(k).filter(|v| !v.is_empty()); + if set("NO_COLOR").is_some() { + return false; + } + if set("CLICOLOR_FORCE").is_some_and(|v| v != "0") { + return true; + } + if !is_tty { + return false; + } + if set("CLICOLOR").is_some_and(|v| v == "0") { + return false; + } + set("TERM").is_none_or(|v| v != "dumb") +} + +fn env_var(k: &str) -> Option { + std::env::var(k).ok() +} + +/// [`color_enabled`] for stdout; a terminal must also render escapes +/// ([`stdout_vt`]). +pub(crate) fn stdout_color() -> bool { + let tty = std::io::stdout().is_terminal(); + color_enabled(tty, env_var) && (!tty || stdout_vt()) +} + +/// [`color_enabled`] for stderr; a terminal must also render escapes +/// ([`stderr_vt`]). +pub(crate) fn stderr_color() -> bool { + let tty = std::io::stderr().is_terminal(); + color_enabled(tty, env_var) && (!tty || stderr_vt()) +} + +/// Columns of the terminal on stderr (status lines, prompts): its size, +/// else `$COLUMNS`, else 80. +pub(crate) fn stderr_width() -> usize { + width_of(&console::Term::stderr()) +} + +/// Columns of the terminal on stdout (tables): its size, else `$COLUMNS`, +/// else 80. Measured separately from stderr, which may be redirected +/// while stdout is still a wide terminal. +pub(crate) fn stdout_width() -> usize { + width_of(&console::Term::stdout()) +} + +fn width_of(term: &console::Term) -> usize { + term.size_checked() + .map(|(_, cols)| cols as usize) + .filter(|&c| c > 0) + .or_else(|| env_var("COLUMNS")?.parse().ok().filter(|&c| c > 0)) + .unwrap_or(80) +} + +/// Wrap `text` in an SGR color `code` (e.g. `"33"`) when `on`. +pub fn paint(text: &str, code: &str, on: bool) -> String { + if on { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_string() + } +} + +/// Color a severity label by tier (`critical` bright red, `high` red, +/// `medium`/`moderate` yellow, `low` cyan; anything else plain). The +/// text itself is kept verbatim. +pub fn severity(s: &str, on: bool) -> String { + let code = match s.to_lowercase().as_str() { + "critical" => "91", + "high" => "31", + // GHSA emits `moderate`; same tier as medium (see get.rs severity_rank). + "medium" | "moderate" => "33", + "low" => "36", + _ => return s.to_string(), + }; + paint(s, code, on) +} + +/// Column alignment for [`pad`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Align { + Left, + Right, +} + +/// Pad `s` to `width` *visible* characters. SGR color sequences don't +/// count, so a colored cell lines up exactly like the plain one (`{:<16}` +/// counts the invisible bytes and misaligns). Never truncates. +pub(crate) fn pad(s: &str, width: usize, align: Align) -> String { + let fill = " ".repeat(width.saturating_sub(visible_width(s))); + match align { + Align::Left => format!("{s}{fill}"), + Align::Right => format!("{fill}{s}"), + } +} + +/// Characters a terminal would display for `s` (SGR sequences excluded). +pub(crate) fn visible_width(s: &str) -> usize { + strip_ansi(s).chars().count() +} + +/// Remove `ESC [ ... ` sequences. +pub(crate) fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\x1b' { + if chars.clone().next() == Some('[') { + chars.next(); + for c in chars.by_ref() { + if ('\x40'..='\x7e').contains(&c) { + break; + } + } + } + continue; + } + out.push(c); + } + out +} + +/// A tiny terminal emulator for asserting on what a user would *see* +/// (shared with the integration tests' `pty_io`). +#[cfg(test)] +pub(crate) mod test_support; + +#[cfg(test)] +mod test_support_tests { + use super::test_support::render; + + #[test] + fn render_emulates_cr_and_clears() { + assert_eq!(render(b"abc\rX"), vec!["Xbc"]); + assert_eq!(render(b"abcdef\r\x1b[2Kxy"), vec!["xy"]); + assert_eq!(render(b"abcdef\rxy\x1b[K"), vec!["xy"]); + assert_eq!(render(b"a\x1b[31mb\x1b[0m\nc\n"), vec!["ab", "c"]); + assert_eq!(render(b""), Vec::::new()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + let map: HashMap<&str, &str> = pairs.iter().copied().collect(); + move |k| map.get(k).map(|v| v.to_string()) + } + + #[test] + fn color_enabled_truth_table() { + type Case<'a> = (bool, &'a [(&'a str, &'a str)], bool); + let cases: &[Case] = &[ + (true, &[], true), + (false, &[], false), + (true, &[("NO_COLOR", "1")], false), + (true, &[("NO_COLOR", "")], true), // no-color.org: empty = unset + (true, &[("TERM", "dumb")], false), + (true, &[("TERM", "xterm-256color")], true), + (true, &[("CLICOLOR", "0")], false), + (true, &[("CLICOLOR", "1")], true), + (false, &[("CLICOLOR_FORCE", "1")], true), + (true, &[("CLICOLOR_FORCE", "1"), ("TERM", "dumb")], true), + (false, &[("CLICOLOR_FORCE", "0")], false), + (false, &[("CLICOLOR_FORCE", "")], false), + (true, &[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1")], false), + ]; + for (tty, vars, want) in cases { + assert_eq!( + color_enabled(*tty, env(vars)), + *want, + "tty={tty} env={vars:?}" + ); + } + } + + #[test] + fn paint_off_has_no_escapes() { + assert_eq!(paint("hi", "31", false), "hi"); + assert_eq!(paint("hi", "31", true), "\x1b[31mhi\x1b[0m"); + assert_eq!(paint("", "1;32", true), "\x1b[1;32m\x1b[0m"); + assert_eq!(severity("", true), ""); + for s in ["critical", "high", "medium", "moderate", "low", "unknown"] { + assert!(!severity(s, false).contains('\x1b')); + } + } + + #[test] + fn severity_ramp_is_exact() { + assert_eq!(severity("critical", true), "\x1b[91mcritical\x1b[0m"); + assert_eq!(severity("HIGH", true), "\x1b[31mHIGH\x1b[0m"); + assert_eq!(severity("moderate", true), "\x1b[33mmoderate\x1b[0m"); + assert_eq!(severity("low", true), "\x1b[36mlow\x1b[0m"); + assert_eq!(severity("unknown", true), "unknown"); + } + + #[test] + fn strip_ansi_removes_sgr_only() { + assert_eq!(strip_ansi("\x1b[91mCRITICAL\x1b[0m x"), "CRITICAL x"); + assert_eq!(strip_ansi("plain é"), "plain é"); + } + + #[test] + fn pad_counts_visible_chars_only() { + let colored = pad(&severity("HIGH", true), 16, Align::Left); + let plain = pad(&severity("HIGH", false), 16, Align::Left); + assert_eq!(strip_ansi(&colored), plain); + assert_eq!(visible_width(&colored), 16); + assert_eq!(pad("0+1", 8, Align::Right), " 0+1"); + let paid = format!("0+{}", paint("1", "33", true)); + assert_eq!(strip_ansi(&pad(&paid, 8, Align::Right)), " 0+1"); + assert_eq!(pad("toolongvalue", 4, Align::Left), "toolongvalue"); + } +} diff --git a/crates/socket-patch-cli/src/ui/prompt.rs b/crates/socket-patch-cli/src/ui/prompt.rs new file mode 100644 index 00000000..86b76e06 --- /dev/null +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -0,0 +1,534 @@ +//! Yes/no confirmation and single-choice selection. Prompts always go to +//! stderr so stdout stays clean for data. + +use std::io::{self, BufRead, IsTerminal, Write}; + +use crate::args::GlobalArgs; + +/// The one note printed (unless `--silent`) when a prompt is answered +/// automatically because stdin is not a terminal. +pub(crate) const NON_INTERACTIVE_PROCEED: &str = + "Non-interactive mode detected, proceeding automatically."; +/// Same, for a prompt whose automatic answer is "no". +pub(crate) const NON_INTERACTIVE_DECLINE: &str = + "Non-interactive mode detected, declining by default."; +/// Same, for [`select_one`], which takes the first option. +pub(crate) const NON_INTERACTIVE_SELECT_FIRST: &str = + "Non-interactive mode detected, selecting the first option."; + +/// Ask a yes/no question on stderr. Returns the answer. +/// +/// - `--yes` or `--json`: `default_yes`, without asking. +/// - stdin not a terminal (CI): `default_yes`, with a one-line note +/// unless `--silent`. +/// - Otherwise: pending typeahead is discarded first (so an Enter pressed +/// during a long scan cannot answer), then `prompt [Y/n] ` is shown. +/// An empty line takes the default; `y`/`yes` accept; anything else, +/// end of input (Ctrl-D) and unreadable input decline. +pub(crate) fn confirm(prompt: &str, default_yes: bool, common: &GlobalArgs) -> bool { + if common.yes || common.json { + return default_yes; + } + ask( + prompt, + Ask { + default_yes, + non_interactive_answer: default_yes, + interactive: io::stdin().is_terminal(), + silent: common.silent, + }, + ) +} + +/// A default-**no** confirmation that still proceeds when nobody can be +/// asked (stdin not a terminal): `setup`'s mutation gate. `--yes`/`--json` +/// proceed without asking. +pub(crate) fn confirm_or_proceed(prompt: &str, common: &GlobalArgs) -> bool { + if common.yes || common.json { + return true; + } + ask( + prompt, + Ask { + default_yes: false, + non_interactive_answer: true, + interactive: io::stdin().is_terminal(), + silent: common.silent, + }, + ) +} + +/// How a yes/no question is answered (see [`confirm_with`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Ask { + /// The answer to an empty line; also picks the `[Y/n]`/`[y/N]` hint. + pub default_yes: bool, + /// The answer when nobody can be asked (`interactive == false`). + pub non_interactive_answer: bool, + /// Whether stdin is a terminal a person is typing into. + pub interactive: bool, + /// `--silent`: suppress the non-interactive note. + pub silent: bool, +} + +fn ask(prompt: &str, ask: Ask) -> bool { + if ask.interactive { + discard_typeahead(); + } + confirm_with(&mut io::stdin().lock(), &mut io::stderr(), prompt, ask) +} + +/// Drop keystrokes typed before the prompt appeared, so an Enter pressed +/// during a long scan cannot answer a default-yes prompt. +fn discard_typeahead() { + #[cfg(unix)] + // SAFETY: tcflush only discards the terminal's pending input queue; + // STDIN_FILENO is a valid descriptor (the caller checked it is a tty). + unsafe { + libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH); + } + #[cfg(windows)] + // SAFETY: GetStdHandle has no preconditions; FlushConsoleInputBuffer + // only discards pending console input and fails harmlessly on a + // non-console handle. Errors are ignored: the flush is best-effort. + unsafe { + use windows_sys::Win32::System::Console::{ + FlushConsoleInputBuffer, GetStdHandle, STD_INPUT_HANDLE, + }; + FlushConsoleInputBuffer(GetStdHandle(STD_INPUT_HANDLE)); + } +} + +/// The testable core of [`confirm`]: reads one line from `input`, writes +/// the prompt (and any note) to `out`. +pub(crate) fn confirm_with( + input: &mut impl BufRead, + out: &mut impl Write, + prompt: &str, + ask: Ask, +) -> bool { + if !ask.interactive { + if !ask.silent { + let note = if ask.non_interactive_answer { + NON_INTERACTIVE_PROCEED + } else { + NON_INTERACTIVE_DECLINE + }; + let _ = writeln!(out, "{note}"); + } + return ask.non_interactive_answer; + } + let hint = if ask.default_yes { "[Y/n]" } else { "[y/N]" }; + let _ = write!(out, "{prompt} {hint} "); + let _ = out.flush(); + read_answer(input, out).unwrap_or(ask.default_yes) +} + +/// The answer-reading core of [`confirm_with`]: reads one line from +/// `input` and maps it to `Some(true)` for `y`/`yes` (any case, whitespace +/// ignored), `None` for an empty line (the caller's default), and +/// `Some(false)` for any other answer, end of input (Ctrl-D) or unreadable +/// input (non-UTF-8, an I/O error). Ends the prompt line on `out` when the +/// terminal did not echo a newline. +fn read_answer(input: &mut impl BufRead, out: &mut impl Write) -> Option { + // Read raw bytes: `read_line` rolls its buffer back on invalid UTF-8, + // which would hide the newline the terminal already echoed. + let mut buf = Vec::new(); + let read = input.read_until(b'\n', &mut buf); + // Keep the next output off the prompt line when the terminal did not + // echo a newline (Ctrl-D, a mid-line read error). + if !buf.ends_with(b"\n") { + let _ = writeln!(out); + } + match read { + // EOF: the user hit Ctrl-D (or input is gone). Never take that as yes. + Ok(0) => Some(false), + Ok(_) => match String::from_utf8(buf) { + Ok(line) => { + let answer = line.trim().to_lowercase(); + if answer.is_empty() { + None + } else { + Some(answer == "y" || answer == "yes") + } + } + // Non-UTF-8 bytes (a Latin-1 paste): decline. + Err(_) => Some(false), + }, + // An I/O error: decline. + Err(_) => Some(false), + } +} + +/// Error type for interactive selection. +pub enum SelectError { + /// User cancelled the selection. + Cancelled, + /// JSON mode requires explicit selection (re-running with the chosen + /// UUID as the identifier — `--id` is a boolean type-tag, not a + /// value-taking selector). + JsonModeNeedsExplicit, +} + +/// Prompt the user to select one option from a list (arrow-key menu on +/// stderr). Takes the same `common` flags as [`confirm`]. +/// +/// - `--json`: `Err(JsonModeNeedsExplicit)`. +/// - Empty `options`: `Err(Cancelled)` — there is nothing to select, and +/// `Ok(0)` would hand callers an out-of-bounds index. +/// - stdin not a terminal: the first option, with +/// [`NON_INTERACTIVE_SELECT_FIRST`] unless `--silent` or the process is +/// quiet ([`super::quiet`]; a caller that must never get +/// `JsonModeNeedsExplicit`, like `scan`, passes its flags with `json` +/// off, but a `--json` run still keeps the note off stderr). +/// - Interactive: Esc/q/Ctrl-C cancel; the cursor is always restored. +pub fn select_one( + prompt: &str, + options: &[String], + common: &GlobalArgs, +) -> Result { + if common.json { + return Err(SelectError::JsonModeNeedsExplicit); + } + if options.is_empty() { + return Err(SelectError::Cancelled); + } + if !io::stdin().is_terminal() { + if !common.silent && !super::quiet() { + eprintln!("{NON_INTERACTIVE_SELECT_FIRST}"); + } + return Ok(0); + } + let (prompt, options) = fit_menu(prompt, options, super::stderr_width()); + let _guard = CursorGuard::install(); + let picked = dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt) + .items(&options) + .default(0) + .interact_opt(); + match picked { + Ok(Some(idx)) => Ok(idx), + _ => Err(SelectError::Cancelled), + } +} + +/// Fit a [`select_one`] menu to a `width`-column terminal. dialoguer +/// erases its menu by counting logical lines, so a prompt or option that +/// wraps leaves rows of the old menu on screen. The prompt renders as +/// `? › ` (5 extra columns, one spare so the cursor never sits +/// in the last column); each option renders as `❯