From eb131ca06945eda11b7774a26a6d79d0e3abd8ef Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 12:48:24 -0400 Subject: [PATCH 01/18] feat(cli): consolidated terminal UI and CLI-wide output polish Scan's progress lines garbled on a real terminal, e.g. "Found 7 patches for 1 packagesatch 7/7)". Hand-rolled `\r` rewrites never cleared to end of line, and warnings printed mid-line. An end-to-end audit of every command (243 verified findings) found the same class of glitch across the CLI. This commit fixes them and moves all terminal-UI logic into one small, unit-tested module. New `crates/socket-patch-cli/src/ui/` (replaces `output.rs`; drops the unused `indicatif`): - `StatusLine`: deterministic status line. Every update writes `\r\x1b[2K` and a width-truncated message. `println` puts a line above an active status. The line clears on finish and on Drop. It is never live off a TTY, under TERM=dumb, in debug mode, or under --json/--silent. - Prompts: `confirm` / `confirm_or_proceed` / `select_one`, built on the testable `confirm_with` core. EOF (Ctrl-D) declines instead of accepting. Typeahead is flushed before prompting. The non-TTY defaults are kept, but their note respects --silent. The dialoguer cursor is restored on Ctrl-C. - Color: one `color_enabled` policy honoring NO_COLOR / CLICOLOR / CLICOLOR_FORCE / TERM=dumb, shared with dialoguer. Table cells are padded before painting, so colored rows align. - Text: `plural`, and a char-safe word-boundary `truncate`. - Core: `utils::notice` quiet switch. Info advisories are muted under --silent/--json and print once per process. Warnings are muted only under --silent. Per-command polish (scan agent/hosted/vendored, get, apply, rollback, remove, vendor, repair, list, lock waits, --update, notifier, vex, setup): - counted nouns instead of "(s)"; - consistent "Error:"/"Warning:" prefixes, with progress and warnings on stderr; - --silent is errors-only, but a failing run still says why; - deterministic ordering; - no developer notes leaking into --help; - dry-run fixes for `get` and `vex`, plus `vex -O -`. Rebased onto #247; its behavior and contract are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 +- Cargo.lock | 28 +- Cargo.toml | 3 +- README.md | 10 +- crates/socket-patch-cli/CLI_CONTRACT.md | 47 +- crates/socket-patch-cli/Cargo.toml | 7 +- crates/socket-patch-cli/src/args.rs | 146 +- crates/socket-patch-cli/src/commands/apply.rs | 934 +++++++-- .../src/commands/fetch_stage.rs | 266 ++- crates/socket-patch-cli/src/commands/get.rs | 1811 +++++++++++++---- crates/socket-patch-cli/src/commands/list.rs | 353 +++- .../socket-patch-cli/src/commands/lock_cli.rs | 141 +- .../socket-patch-cli/src/commands/remove.rs | 576 +++++- .../socket-patch-cli/src/commands/repair.rs | 580 ++++-- .../src/commands/repair_vendor.rs | 183 +- .../socket-patch-cli/src/commands/rollback.rs | 1100 ++++++++-- .../src/commands/scan/discovery.rs | 271 ++- .../socket-patch-cli/src/commands/scan/gc.rs | 198 +- .../src/commands/scan/hosted.rs | 1001 ++++++++- .../socket-patch-cli/src/commands/scan/mod.rs | 907 +++++---- .../src/commands/scan/render.rs | 956 +++++++++ .../src/commands/scan/vendor_flow.rs | 157 +- crates/socket-patch-cli/src/commands/setup.rs | 855 ++++++-- .../socket-patch-cli/src/commands/update.rs | 283 ++- .../socket-patch-cli/src/commands/vendor.rs | 669 +++++- crates/socket-patch-cli/src/commands/vex.rs | 713 +++++-- .../src/ecosystem_dispatch.rs | 4 +- crates/socket-patch-cli/src/lib.rs | 36 +- crates/socket-patch-cli/src/main.rs | 8 +- crates/socket-patch-cli/src/output.rs | 390 ---- crates/socket-patch-cli/src/path_scope.rs | 8 +- crates/socket-patch-cli/src/ui/mod.rs | 323 +++ crates/socket-patch-cli/src/ui/prompt.rs | 477 +++++ crates/socket-patch-cli/src/ui/status.rs | 332 +++ .../socket-patch-cli/src/ui/test_support.rs | 61 + crates/socket-patch-cli/src/ui/text.rs | 140 ++ .../socket-patch-cli/src/update_notifier.rs | 37 +- .../tests/cli_apply_silent.rs | 2 +- .../tests/cli_config_fallback.rs | 16 +- .../tests/cli_dry_run_paths_e2e.rs | 2 +- .../socket-patch-cli/tests/cli_parse_list.rs | 43 +- .../tests/cli_remove_silent.rs | 15 +- .../tests/cli_rollback_silent.rs | 2 +- .../socket-patch-cli/tests/cli_scan_silent.rs | 6 +- .../tests/cli_setup_silent.rs | 10 +- .../socket-patch-cli/tests/common/pty_io.rs | 100 + ...overage_fix_scan_hosted_dryrun_vendored.rs | 96 + .../coverage_fix_vendor_silent_mute_exit.rs | 2 +- .../tests/covgap_api_client.rs | 123 +- .../tests/covgap_commands_apply.rs | 22 +- .../tests/covgap_commands_fetch_stage.rs | 46 +- .../tests/covgap_commands_get.rs | 435 +++- .../tests/covgap_commands_list.rs | 4 +- .../tests/covgap_commands_remove.rs | 50 +- .../tests/covgap_commands_repair.rs | 67 +- .../tests/covgap_commands_repair_vendor.rs | 4 +- .../tests/covgap_commands_rollback.rs | 109 +- .../tests/covgap_commands_scan_hosted.rs | 289 ++- .../tests/covgap_commands_scan_mod.rs | 813 +++++++- .../tests/covgap_commands_scan_vendor_flow.rs | 2 +- .../tests/covgap_commands_setup.rs | 164 +- .../tests/covgap_commands_update.rs | 28 +- .../tests/covgap_commands_vendor.rs | 50 +- .../tests/covgap_commands_vex.rs | 65 +- .../socket-patch-cli/tests/covgap_output.rs | 304 ++- crates/socket-patch-cli/tests/e2e_cargo.rs | 10 +- crates/socket-patch-cli/tests/e2e_composer.rs | 6 +- .../tests/e2e_embedded_vex.rs | 2 +- crates/socket-patch-cli/tests/e2e_golang.rs | 4 +- crates/socket-patch-cli/tests/e2e_maven.rs | 5 +- crates/socket-patch-cli/tests/e2e_nuget.rs | 6 +- .../socket-patch-cli/tests/e2e_safety_lock.rs | 4 +- crates/socket-patch-cli/tests/e2e_vex.rs | 2 +- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 2 +- .../tests/get_edge_cases_e2e.rs | 10 +- .../socket-patch-cli/tests/get_modes_e2e.rs | 2 +- .../tests/help_text_hygiene.rs | 148 ++ .../tests/in_process_redirect.rs | 13 +- .../socket-patch-cli/tests/in_process_scan.rs | 2 +- .../tests/in_process_vendor_bun.rs | 2 +- .../tests/interactive_prompts_e2e.rs | 51 +- .../tests/output_helpers_e2e.rs | 92 +- .../tests/output_modes_e2e.rs | 37 +- .../tests/repair_invariants.rs | 2 +- .../tests/rollback_duality_invariants.rs | 4 +- .../tests/rollback_invariants.rs | 28 +- .../socket-patch-cli/tests/scan_paths_e2e.rs | 2 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 12 + .../tests/self_update_channels_e2e.rs | 28 +- .../socket-patch-cli/tests/self_update_e2e.rs | 5 +- .../tests/self_update_failures_e2e.rs | 12 +- .../tests/setup_contract_gaps.rs | 16 +- .../tests/setup_terminal_output.rs | 237 +++ .../tests/vex_terminal_output.rs | 412 ++++ .../socket-patch-core/src/api/blob_fetcher.rs | 318 ++- crates/socket-patch-core/src/api/client.rs | 248 ++- .../src/manifest/cleanup_blobs.rs | 139 +- .../src/manifest/operations.rs | 25 +- .../socket-patch-core/src/update/channel.rs | 124 ++ .../socket-patch-core/src/update/download.rs | 2 +- crates/socket-patch-core/src/update/mod.rs | 21 +- .../socket-patch-core/src/update/release.rs | 172 +- .../socket-patch-core/src/utils/env_compat.rs | 2 +- crates/socket-patch-core/src/utils/mod.rs | 1 + crates/socket-patch-core/src/utils/notice.rs | 99 + .../tests/covgap_api_blob_fetcher.rs | 7 +- docs/design/configuration.md | 5 +- 107 files changed, 15818 insertions(+), 3395 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/scan/render.rs delete mode 100644 crates/socket-patch-cli/src/output.rs create mode 100644 crates/socket-patch-cli/src/ui/mod.rs create mode 100644 crates/socket-patch-cli/src/ui/prompt.rs create mode 100644 crates/socket-patch-cli/src/ui/status.rs create mode 100644 crates/socket-patch-cli/src/ui/test_support.rs create mode 100644 crates/socket-patch-cli/src/ui/text.rs create mode 100644 crates/socket-patch-cli/tests/common/pty_io.rs create mode 100644 crates/socket-patch-cli/tests/help_text_hygiene.rs create mode 100644 crates/socket-patch-cli/tests/setup_terminal_output.rs create mode 100644 crates/socket-patch-cli/tests/vex_terminal_output.rs create mode 100644 crates/socket-patch-core/src/utils/notice.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3615d14f..31738522 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 @@ -374,7 +374,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..97e7d1cc 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 (the pre-service behavior). 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", @@ -157,7 +182,8 @@ pub struct GlobalArgs { /// (the step-1 POST). 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 @@ -165,12 +191,19 @@ pub struct GlobalArgs { /// 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 +215,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 +228,7 @@ pub struct GlobalArgs { /// Operate on globally-installed packages. #[arg( + help_heading = GLOBAL_OPTIONS, long = "global", short = 'g', env = "SOCKET_GLOBAL", @@ -202,11 +238,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 +254,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 +265,7 @@ pub struct GlobalArgs { /// Suppress non-error output. #[arg( + help_heading = GLOBAL_OPTIONS, long = "silent", short = 's', env = "SOCKET_SILENT", @@ -237,6 +276,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 +286,7 @@ pub struct GlobalArgs { /// Skip interactive prompts. #[arg( + help_heading = GLOBAL_OPTIONS, long = "yes", short = 'y', env = "SOCKET_YES", @@ -254,21 +295,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 lock-contending subcommands (`apply`, `rollback`, `repair`, + /// `remove`, `vendor`, `setup --exclude`'s manifest write, and the + /// hosted and 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(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 +319,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 +327,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 +630,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..eda662ee 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 @@ -371,7 +427,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 +456,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 +465,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 +518,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 +535,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 +545,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 +899,8 @@ pub(crate) async fn run_locked( unmatched, run_warnings, fallback_skips, + targeted, + show_summary, }) => { let patched_count = results .iter() @@ -853,13 +928,44 @@ 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()); + for line in format_results_block(&results, args.common.dry_run, &cwd) { + 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 +1002,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 +1036,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 +1067,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,8 +1076,8 @@ pub(crate) async fn run_locked( Some(Ok(summary)) => { if !args.common.silent { println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, + "Wrote OpenVEX document with {} to {}", + plural(summary.statements, "statement", "statements"), args.vex .vex .as_ref() @@ -1068,7 +1093,14 @@ 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() { + // 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!("Skipping VEX generation (--dry-run: nothing was applied)."); } } @@ -1133,7 +1165,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 +1249,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 +1557,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 +1591,8 @@ async fn apply_patches_inner( unmatched: Vec::new(), run_warnings: Vec::new(), fallback_skips: Vec::new(), + targeted: 0, + show_summary: false, }); } @@ -1398,19 +1676,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. + 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.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." - ); + for line in format_none_installed_error(&unmatched) { + eprintln!("{line}"); + } } return Ok(ApplyOutcome { success: unmatched.is_empty(), @@ -1418,6 +1694,8 @@ async fn apply_patches_inner( unmatched, run_warnings, fallback_skips, + targeted: target_manifest_purls.len(), + show_summary: true, }); } @@ -1642,11 +1920,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.common.json { 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 +1962,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.common.json { // 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 +2027,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.common.json { 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 +2045,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.common.json { + 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 +2088,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 +2804,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..03221f06 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; /// 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,99 @@ 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. +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,8 +337,9 @@ pub(crate) async fn stage_patch_sources( overlay_dir(&socket_diffs_path, &staged.diffs).await; overlay_dir(&socket_packages_path, &staged.packages).await; + // Progress: stderr, like every other status line (stdout is data). if !quiet { - println!( + eprintln!( "Downloading missing patch artifacts (mode: {})...", download_mode.as_tag() ); @@ -266,8 +348,18 @@ pub(crate) async fn stage_patch_sources( let sources = staged.as_patch_sources(); let fetch_result = fetch_missing_sources(manifest, &sources, download_mode, client, None).await; + // 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 @@ -278,14 +370,16 @@ pub(crate) async fn stage_patch_sources( 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() + eprintln!( + "{}", + format_blob_fallback(fetch_result.failed, still_missing_blobs.len()) ); } let blob_result = fetch_missing_blobs(manifest, &staged.blobs, client, None).await; 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 +405,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); } @@ -447,9 +548,9 @@ pub(crate) async fn stage_vendor_sources_in_memory( } if !quiet { - println!( - "Fetching {} patch(es)' content (kept in memory)...", - to_fetch.len() + eprintln!( + "Fetching content for {}...", + plural(to_fetch.len(), "patch", "patches") ); } @@ -510,11 +611,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; @@ -1044,3 +1145,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..871c2b20 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::{ @@ -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 @@ -492,13 +478,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 +493,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 +528,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 +588,461 @@ 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 ()`. +/// +/// `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 purl is shown decoded (`%40scope` → `@scope`). +fn format_patch_summary( + purl: &str, + tier: &str, + patch_id: Option<&str>, + vulns: &HashMap, +) -> 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) { + line.push_str(&format!(" ({})", sev.to_uppercase())); + } + } + 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`] will put a menu in front of the user for +/// these candidates: a free user, several accessible patches for one purl, +/// no `--yes`/`--json`, and an interactive stdin (mirrors `select_one`). +fn selection_prompted( + candidates: &[PatchSearchResult], + can_access_paid: bool, + common: &GlobalArgs, +) -> bool { + use std::io::IsTerminal; + if can_access_paid || common.yes || common.json || !std::io::stdin().is_terminal() { + return false; + } + let mut seen = std::collections::HashSet::new(); + candidates + .iter() + .filter(|p| p.tier == "free") + .any(|p| !seen.insert(p.purl.as_str())) +} + +/// 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]) -> 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) + )); + } + 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. +fn no_packages_message(global: bool) -> &'static str { + if global { + "No global packages found." + } else { + "No packages found. Run your package manager's install first." + } +} + +/// 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 { + "." + } + ), + } +} + +/// The error printed when the nested apply failed. Under `--silent` +/// apply's own per-patch failure lines are muted, so this one line is all +/// the user gets: point at how to see the details. +fn format_apply_failed(silent: bool) -> &'static str { + if silent { + "Error: Some patches could not be applied (re-run without --silent for details)." + } else { + "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 +1053,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 +1069,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 +1098,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 +1110,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 +1341,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 +1378,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 +1767,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 +1779,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 { @@ -1530,13 +1963,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 +2210,15 @@ 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` / `silent` are the +/// caller's flags: they decide the failure line (`common` itself is always +/// quiet and 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, + silent: bool, client: &ApiClient, lock: LockGuard, ) -> bool { @@ -1795,8 +2230,10 @@ async fn run_nested_apply( vex: Default::default(), }; 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!("{}", format_apply_failed(silent)); } code == 0 } @@ -1888,14 +2325,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 +2351,24 @@ 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, + params.silent, run.api_client, lock, ) @@ -2030,16 +2458,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 +2504,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 +2519,49 @@ 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 + ) + ); } // Record the fetch BEFORE the save+apply step so the @@ -2174,6 +2610,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 +2659,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 +2671,7 @@ pub async fn run(args: GetArgs) -> i32 { } _ => unreachable!(), }; + status.finish(); match result { Ok(r) => r, Err(e) => { @@ -2236,28 +2688,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 +2718,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 +2747,7 @@ pub async fn run(args: GetArgs) -> i32 { } _ => unreachable!(), }; + drop(status); if search_response.patches.is_empty() { if args.common.json { @@ -2306,12 +2758,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 +2782,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 +2811,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 +2844,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 +2859,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; } - // 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, + // 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, ) { 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)); + } + + // 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 +3034,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; } - println!(); + }; + 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", + })); + } + } + } + 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 +3246,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,6 +3263,11 @@ 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. @@ -2741,25 +3317,24 @@ 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, + args.common.silent, client, lock, ) @@ -2927,10 +3502,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; @@ -3294,6 +3866,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 +3914,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 +3934,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 +3954,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 +4001,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 +4028,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 +4048,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 +4080,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 +4096,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 +4113,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 +4125,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 +4136,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 +4149,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 +4172,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 +4524,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 +4979,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 +4999,550 @@ 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), + "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 + ), + "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()), + "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), + "pkg:npm/a@1 [FREE] fixes GHSA-2" + ); + } + + #[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]), + "Selected:\n pkg:npm/lodash@4.17.20 [FREE] 6332e781: fixes CVE-2026-4800 (HIGH)\n\n" + ); + assert_eq!(format_selected_patches(&[]), "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 apply_failed_line_is_an_error_even_when_silent() { + assert_eq!( + format_apply_failed(false), + "Error: Some patches could not be applied." + ); + assert_eq!( + format_apply_failed(true), + "Error: Some patches could not be applied (re-run without --silent for details)." + ); + } + + #[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 +5802,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..e1591929 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -47,20 +47,67 @@ pub(crate) fn acquire_or_emit( dry_run: bool, timeout: Duration, ) -> Result { - match acquire(socket_dir, timeout) { + let lock_path = socket_dir.join("apply.lock"); + let result = match acquire(socket_dir, Duration::ZERO) { + // Contended with a wait budget: say what we are waiting on, or a + // `--lock-timeout 30` run just sits there silently for 30 s. The + // status line is terminal-only and quiet under --json/--silent. + Err(LockError::Held) if timeout > Duration::ZERO => { + let mut status = crate::ui::StatusLine::stderr(crate::ui::quiet(), false); + status.set(waiting_message(&lock_path, timeout)); + let result = acquire(socket_dir, timeout); + status.finish(); + result + } + other => other, + }; + match result { Ok(guard) => Ok(guard), Err(err) => { - let hint = match err { - LockError::Held => Hint::Wait, - LockError::Io { .. } => Hint::None, + let hint = 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(), }; 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) } } } +/// 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) + ) +} + +/// 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 +167,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 +178,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 +419,53 @@ mod tests { ); } + #[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/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 8d90e924..54643ab9 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", @@ -160,7 +307,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 +401,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 +471,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 +498,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 +551,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 +586,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 +608,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 +722,7 @@ pub async fn run(args: RemoveArgs) -> i32 { Ok(leg) => leg, Err(code) => return code, }; + printed_progress |= loud && vendor_leg.printed; } } @@ -576,6 +773,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,16 +859,24 @@ 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}"); @@ -695,14 +902,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 +953,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 @@ -890,6 +1115,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 +1196,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 +1213,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 +1225,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 +1241,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 +1359,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 +1368,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 +1388,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 +1474,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 +1483,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 +1503,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."); } @@ -1494,4 +1759,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..ed72e8da 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,7 +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 \ + 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)"; if args.common.json { @@ -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,106 @@ 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)) +} + +/// 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. +fn format_final_line(download_failed: usize, 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 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 +338,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 +420,52 @@ 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() - ); + if manifest.as_ref().is_some_and(|m| m.patches.is_empty()) { + println!("No patches in manifest; nothing to download."); + } else { + println!("All {} are present locally.", noun.many); + } } } 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!("Dry run - 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 +479,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 +516,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 +547,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 +558,41 @@ 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 line = format_final_line(download_failed_count, noun, args.common.dry_run); + if download_failed_count > 0 { + if stdout_started { + eprintln!(); + } + eprintln!("{line}"); + } else { + if stdout_started { + println!(); + } + println!("{line}"); + } } // Translate the aggregate counts into envelope events. `repair` @@ -509,7 +619,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(); } @@ -627,9 +737,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 +763,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 +788,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 +822,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 +865,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 +946,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 +966,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 +989,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 +1006,168 @@ 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, BLOB, false), "Repair complete."); + assert_eq!( + format_final_line(0, BLOB, true), + "Dry run: no changes made." + ); + assert_eq!( + format_final_line(1, DIFF_ARCHIVE, false), + "Repair finished with errors: 1 diff archive was not downloaded." + ); + assert_eq!( + format_final_line(2, BLOB, false), + "Repair finished with errors: 2 blobs were not downloaded." + ); + } + + #[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..c48ab6cf 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!( + "Dry run - 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![ + "Dry run - 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![ + "Dry run - 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..3af3f974 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,315 @@ 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}") +} + +/// 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 +667,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 +800,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 +971,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 +1004,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 +1060,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 +1070,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 +1090,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 +1111,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 +1167,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 +1228,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 +1445,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 +1511,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 +1542,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 +1575,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 +1640,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 +1823,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 +1851,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 +1886,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 +1963,14 @@ 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}"); } if args.common.verbose { @@ -1709,70 +2001,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 +2144,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 } @@ -2221,8 +2547,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 +2573,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 +2592,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 +2610,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 +2654,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 +2694,17 @@ 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 (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 +2724,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 +3013,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 ---------------------------------- @@ -4217,4 +4578,345 @@ 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 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..8cd2efe6 100644 --- a/crates/socket-patch-cli/src/commands/scan/gc.rs +++ b/crates/socket-patch-cli/src/commands/scan/gc.rs @@ -257,9 +257,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 +335,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 +373,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 +392,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 +407,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 +514,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 +1577,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 +1596,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 +1614,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..ed507568 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -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 @@ -844,6 +867,7 @@ pub(super) async fn run_redirect( api_client, all_packages_with_patches, can_access_paid_patches, + &args.common, false, false, ) @@ -858,6 +882,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 +958,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 +1158,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 +1202,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 +1348,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 +1401,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 +1579,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 +1605,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 +1744,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 +1775,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 +1872,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 +1892,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 +2086,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 +2100,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 +2220,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 +2250,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); } @@ -2307,24 +2389,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` @@ -2358,70 +2444,111 @@ 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!("Skipping VEX generation (--dry-run: nothing was 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 +2560,319 @@ 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)", + }; + let indent = if nothing_redirected { " " } else { "" }; + 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!( + "{indent}Skipped {purl}: {}", + describe_skip_reason(reason) + )); + } + for purl in unconfirmed { + lines.push(format!( + "{indent}Not redirected {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 +2905,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 +3038,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 +4066,368 @@ 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(), + " Not redirected pkg:npm/minimist@1.2.5: no lockfile entry pinning it could \ + be redirected" + .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..9374604e 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -25,16 +25,14 @@ 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 render; mod vendor_flow; use self::discovery::{ @@ -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", @@ -388,8 +382,8 @@ async fn embed_vex_human( Ok(summary) => { if !common.silent { println!( - "Wrote OpenVEX document with {} statement(s) to {}", - summary.statements, + "Wrote OpenVEX document with {} to {}", + plural(summary.statements, "statement", "statements"), vex_args .vex .as_ref() @@ -415,9 +409,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 +424,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 +443,77 @@ 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 still keeps the non-interactive note off stderr: the +/// process-wide quiet switch mutes it.) +fn selection_args(common: &GlobalArgs) -> GlobalArgs { + GlobalArgs { + json: false, + ..common.clone() + } } /// 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 +540,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 +1524,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 +1554,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 +1585,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 +1650,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 +1776,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 +1803,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 +1902,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 +1942,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 +1965,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 +1982,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; @@ -1982,7 +2013,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { batch_error_count += 1; last_batch_error = Some(e.to_string()); if !args.common.json { - eprintln!("\nError querying batch {}: {e}", batch_idx + 1); + status.println(format!("Error querying batch {}: {e}", batch_idx + 1)); } } } @@ -1999,6 +2030,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 +2050,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, @@ -2039,15 +2072,17 @@ 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 { + status.finish_with(format!( + "No patches found for {}", + plural(package_count, "package", "packages") + )); } // Calculate patch counts @@ -2237,6 +2272,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 +2446,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 +2477,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 +2499,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 +2522,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 +2538,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 +2550,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 +2618,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 +2635,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 +2646,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 +2658,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 +2676,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,152 +2712,158 @@ 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 - let selected: Vec = - match select_patches(&all_search_results, can_access_paid_patches, false) { - Ok(s) => s, - Err(code) => return code, - }; + let selected: Vec = match select_patches( + &all_search_results, + can_access_paid_patches, + &selection_args(&args.common), + ) { + Ok(s) => s, + Err(code) => return code, + }; // 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) - ); + 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) - ); + 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 { + 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 { + 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`/ @@ -2810,13 +2873,13 @@ pub async fn run(mut args: ScanArgs) -> i32 { // 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(); + let report_only = args.mode.is_none() && !args.prune && !args.common.yes && !ui::stdin_is_tty(); 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 +2889,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 +2970,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 +2992,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 +3000,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 +3047,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 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..1b9b733f --- /dev/null +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -0,0 +1,956 @@ +//! 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(super) 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). +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::*; + + 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..0a4e2e55 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -26,6 +26,7 @@ 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 +39,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 +175,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. @@ -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; } }; @@ -665,9 +696,8 @@ async fn run_vendor_interactive_path( .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" }, + "GC: pruned {}.", + plural(gc.pruned.len(), "manifest entry", "manifest entries") ); } if !args.common.silent { @@ -677,6 +707,43 @@ async fn run_vendor_interactive_path( 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 Wait for it to finish, or retry with --lock-timeout to wait for the lock.", + ); + } + 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 +901,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 +1481,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..468bee95 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -5,7 +5,8 @@ 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, @@ -23,13 +24,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 { @@ -92,8 +93,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 +117,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); + } + } + 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; } - let find_result = find_package_json_files(&args.common.cwd).await; + 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 +211,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 +271,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. @@ -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,13 +1138,58 @@ 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 @@ -1069,7 +1199,7 @@ async fn run_check(args: &SetupArgs) -> i32 { // 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..."); + eprintln!("Searching for package.json / Python / Bundler / Composer manifests..."); } // Excluded members (persisted in the manifest + any passed via `--exclude`) @@ -1091,8 +1221,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 { @@ -1149,6 +1284,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,24 +1329,12 @@ 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)); } else { // `--silent` is "errors only": the status report is muted, but // read/parse failures must still reach stderr. A plain @@ -1250,7 +1379,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { // 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..."); + eprintln!("Searching for package.json / Python / Bundler / Composer manifests..."); } // Honor the persisted/`--exclude` member set so we never touch a member that @@ -1292,7 +1421,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 +1462,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( 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,24 +1486,40 @@ 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( 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!("Aborted."); + } return 0; } if !quiet { - println!("\nRemoving changes..."); + eprintln!("\nRemoving install hooks..."); } let mut npm_results = Vec::new(); for loc in &npm_files { @@ -1421,12 +1576,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 +1598,10 @@ async fn run_remove(args: &SetupArgs) -> i32 { } } + print_warnings(common, &warnings); eprint_errors_when_silent( 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,15 +1615,51 @@ 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 — @@ -1485,18 +1681,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 +1704,89 @@ 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::from("\nProposed changes:\n"); 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: {}", + out.push_str(&format!(" - {}\n", pathdiff(&r.path, cwd))); + out.push_str(&format!(" postinstall: \"{}\"\n", r.old_script)); + out.push_str(&format!( + " -> postinstall: {}\n", + render_removed(&r.new_script) + )); + out.push_str(&format!( + " dependencies: \"{}\"\n", + r.old_dependencies_script + )); + out.push_str(&format!( + " -> dependencies: {}\n", render_removed(&r.new_dependencies_script) - ); + )); } - 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)); + 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!(); } } @@ -1669,7 +1881,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { // unaffected, and prompting follows the shared `confirm()` semantics. let quiet = common.json || common.silent; if !quiet { - println!("Configuring socket-patch install hooks..."); + eprintln!("Configuring socket-patch install hooks..."); } // Resolve the effective exclude set (persisted + `--exclude`); excluded @@ -1678,7 +1890,25 @@ 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(); + warn_unmatched_excludes(common, &unmatched); + // 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. @@ -1734,10 +1964,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 +1973,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 +2003,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 +2026,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}"); - } } + print_warnings(common, &warnings); eprint_errors_when_silent( 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,26 +2065,38 @@ 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( 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!("Aborted."); + } 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..."); + eprintln!("\nApplying changes..."); } let mut npm_results = Vec::new(); @@ -1907,12 +2163,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 +2184,10 @@ async fn run_setup(args: &SetupArgs) -> i32 { } } + print_warnings(common, &warnings); eprint_errors_when_silent( 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 +2217,55 @@ 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)); } } + 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 +2352,268 @@ 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(), + 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_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 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..bde51d11 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(), @@ -121,6 +219,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 +241,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 ), ); } @@ -187,13 +286,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 +320,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 +341,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 +404,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 +415,125 @@ pub async fn run(args: UpdateArgs) -> i32 { mod tests { use super::*; + 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..c9d9ba3f 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; #[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,237 @@ 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. +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 { + 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 @@ -580,8 +803,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 +844,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 +1071,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 +1106,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 +1235,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 +1293,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,6 +1359,10 @@ pub(crate) async fn vendor_records( }; let pipenv_version = tokio::sync::OnceCell::new(); + 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(); for (purl, pkg_path) in &all_packages { let is_variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()); @@ -1198,13 +1425,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 +1461,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 +1537,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 +1568,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,13 +1612,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; } } @@ -1434,20 +1648,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 +1672,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 +1716,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 +1797,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 +1929,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 +1953,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 +1970,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 +1978,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 +2004,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 +2025,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 +2077,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)); + } } } @@ -3750,3 +3991,243 @@ mod pristine_fetch_tests { ); } } + +/// Exact-string tests for the human output of `vendor` / `vendor --revert`. +#[cfg(test)] +mod ui_format_tests { + use super::*; + + 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." + ); + assert_eq!( + format_vendor_summary(false, &tally(0, 2, 0, 0, 0)), + "Vendored 0 packages; 2 already vendored." + ); + assert_eq!( + format_vendor_summary(true, &tally(0, 2, 0, 0, 0)), + "Would vendor 0 packages; 2 already vendored." + ); + 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)), + "Would vendor 0 packages; 3 already vendored." + ); + } + + #[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..30c1ceee 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,86 @@ 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 `--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 +418,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 +439,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 +460,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 +493,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 +565,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 +612,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 +624,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 +685,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 +694,7 @@ async fn generate_vex( code: "no_applicable_patches", message, failed: outcome.failed, + warnings: Vec::new(), }); } }; @@ -595,17 +709,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 +737,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 +751,8 @@ async fn generate_vex( statements: doc.statements.len(), failed: outcome.failed, doc, - warnings, + warnings: Vec::new(), + wrote_file: wrote_to_file, }) } @@ -679,11 +800,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 +837,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 +850,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 +858,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 +892,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 +935,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 +976,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 +999,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 +1076,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 +1170,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 +1187,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 +1396,150 @@ 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_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..6fa6a911 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}; @@ -64,13 +64,14 @@ pub enum Commands { /// (no socket-patch or Socket API needed). `--revert` undoes it. 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), @@ -90,11 +91,19 @@ pub enum Commands { #[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. + #[command( + hide = true, + name = "self-update", + 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 +617,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..d9c2a8b2 --- /dev/null +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -0,0 +1,477 @@ +//! 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: auto-selecting 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 _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), + } +} + +/// dialoguer hides the cursor while its menu is up. Its own error paths +/// don't show it again, and Ctrl-C (which console turns into a real +/// SIGINT) kills the process mid-menu. This guard shows the cursor on +/// drop and, for its lifetime, on SIGINT before handing the signal to +/// whatever disposition was there before. +struct CursorGuard { + /// The SIGINT disposition to put back on drop; `None` when the guard + /// left SIGINT alone (it was ignored). + #[cfg(unix)] + previous: Option, +} + +/// The disposition [`show_cursor_then_reraise`] hands SIGINT back to +/// (`SIG_DFL` is 0). +#[cfg(unix)] +static PREVIOUS_SIGINT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[cfg(unix)] +const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; + +/// Whether stderr was a terminal when the guard was installed: the +/// handler writes [`SHOW_CURSOR`] only then, so a redirected stderr never +/// gets a stray escape. (Checked at install; `isatty` in a handler is +/// not async-signal-safe on every platform.) +#[cfg(unix)] +static STDERR_IS_TTY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +#[cfg(unix)] +extern "C" fn show_cursor_then_reraise(sig: libc::c_int) { + let previous = PREVIOUS_SIGINT.load(std::sync::atomic::Ordering::SeqCst) as libc::sighandler_t; + // SAFETY: write, signal and raise are async-signal-safe. SIGINT is + // blocked while this handler runs, so the re-raised signal is + // delivered to `previous` once it returns. + unsafe { + if STDERR_IS_TTY.load(std::sync::atomic::Ordering::SeqCst) { + libc::write( + libc::STDERR_FILENO, + SHOW_CURSOR.as_ptr().cast(), + SHOW_CURSOR.len(), + ); + } + libc::signal(sig, previous); + libc::raise(sig); + } +} + +impl CursorGuard { + fn install() -> Self { + #[cfg(unix)] + { + STDERR_IS_TTY.store( + io::stderr().is_terminal(), + std::sync::atomic::Ordering::SeqCst, + ); + let handler = show_cursor_then_reraise as extern "C" fn(libc::c_int); + // SAFETY: installs a handler that only calls async-signal-safe + // functions; the previous disposition is restored on drop. + let previous = unsafe { libc::signal(libc::SIGINT, handler as libc::sighandler_t) }; + if previous == libc::SIG_IGN || previous == libc::SIG_ERR { + // Started with Ctrl-C ignored (nohup, some launchers): keep + // it ignored so the menu just returns Cancelled; the Drop + // still restores the cursor. + if previous == libc::SIG_IGN { + // SAFETY: puts back the disposition we just replaced. + unsafe { libc::signal(libc::SIGINT, libc::SIG_IGN) }; + } + return CursorGuard { previous: None }; + } + PREVIOUS_SIGINT.store(previous as usize, std::sync::atomic::Ordering::SeqCst); + CursorGuard { + previous: Some(previous), + } + } + #[cfg(not(unix))] + CursorGuard {} + } +} + +impl Drop for CursorGuard { + fn drop(&mut self) { + if io::stderr().is_terminal() { + let _ = console::Term::stderr().show_cursor(); + } + #[cfg(unix)] + if let Some(previous) = self.previous { + // SAFETY: restores the disposition captured in `install`. + unsafe { + libc::signal(libc::SIGINT, previous); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A person at a terminal answering `Apply 1 patch?`. + fn at_tty(default_yes: bool) -> Ask { + Ask { + default_yes, + non_interactive_answer: default_yes, + interactive: true, + silent: false, + } + } + + /// Run `confirm_with` over `input`; returns (answer, what was written). + fn run(input: &[u8], ask: Ask) -> (bool, String) { + let mut out = Vec::new(); + let answer = confirm_with(&mut &input[..], &mut out, "Apply 1 patch?", ask); + (answer, String::from_utf8(out).unwrap()) + } + + #[test] + fn empty_line_takes_the_default() { + assert_eq!( + run(b"\n", at_tty(true)), + (true, "Apply 1 patch? [Y/n] ".into()) + ); + assert_eq!( + run(b"\n", at_tty(false)), + (false, "Apply 1 patch? [y/N] ".into()) + ); + assert!(run(b" \n", at_tty(true)).0); + } + + #[test] + fn yes_answers_accept() { + for input in [&b"y\n"[..], b"Y\n", b"yes\n", b"YES\n", b" y \n"] { + assert!(run(input, at_tty(false)).0, "{input:?}"); + } + } + + #[test] + fn no_and_garbage_decline() { + for input in [&b"n\n"[..], b"N\n", b"no\n", b"garbage\n", b"yess\n"] { + assert!(!run(input, at_tty(true)).0, "{input:?}"); + } + } + + #[test] + fn eof_declines_even_when_default_is_yes() { + // Ctrl-D at "[Y/n]" used to mean yes and mutate the project. + let (answer, out) = run(b"", at_tty(true)); + assert!(!answer); + assert_eq!( + out, "Apply 1 patch? [Y/n] \n", + "a newline must follow the prompt" + ); + } + + #[test] + fn answer_without_trailing_newline_still_counts_and_ends_the_line() { + let (answer, out) = run(b"y", at_tty(false)); + assert!(answer); + assert_eq!(out, "Apply 1 patch? [y/N] \n"); + } + + #[test] + fn invalid_utf8_declines_without_an_extra_newline() { + // The terminal already echoed the Enter; a second newline would + // leave a stray blank line. + let (answer, out) = run(b"\xE9\n", at_tty(true)); + assert!(!answer); + assert_eq!(out, "Apply 1 patch? [Y/n] "); + } + + #[test] + fn non_interactive_returns_the_non_interactive_answer_with_note() { + let ask = Ask { + interactive: false, + ..at_tty(true) + }; + let (answer, out) = run(b"n\n", ask); + assert!(answer, "non-interactive must not read stdin"); + assert_eq!(out, format!("{NON_INTERACTIVE_PROCEED}\n")); + + let ask = Ask { + interactive: false, + ..at_tty(false) + }; + let (answer, out) = run(b"", ask); + assert!(!answer); + assert_eq!(out, format!("{NON_INTERACTIVE_DECLINE}\n")); + } + + #[test] + fn non_interactive_silent_writes_nothing() { + let ask = Ask { + interactive: false, + silent: true, + ..at_tty(true) + }; + assert_eq!(run(b"", ask), (true, String::new())); + } + + #[test] + fn setup_style_default_no_but_proceed_when_non_interactive() { + let setup = Ask { + default_yes: false, + non_interactive_answer: true, + interactive: true, + silent: false, + }; + let (answer, _) = run( + b"", + Ask { + interactive: false, + ..setup + }, + ); + assert!(answer, "nobody to ask: proceed"); + assert_eq!( + run(b"\n", setup), + (false, "Apply 1 patch? [y/N] ".into()), + "at a terminal an empty answer means no" + ); + } + + #[test] + fn read_answer_maps_empty_to_none_and_eof_to_decline() { + let read = |input: &[u8]| { + let mut out = Vec::new(); + let answer = read_answer(&mut &input[..], &mut out); + (answer, String::from_utf8(out).unwrap()) + }; + assert_eq!(read(b"\n"), (None, String::new())); + assert_eq!(read(b" \n"), (None, String::new())); + assert_eq!(read(b"Yes\n"), (Some(true), String::new())); + assert_eq!(read(b" y \n"), (Some(true), String::new())); + assert_eq!(read(b"no\n"), (Some(false), String::new())); + assert_eq!(read(b"\xE9\n"), (Some(false), String::new())); + // EOF declines (never `None`, which a default-yes caller would + // turn into yes) and ends the prompt line. + assert_eq!(read(b""), (Some(false), "\n".into())); + } + + #[test] + fn select_one_json_mode_requires_explicit_selection() { + let json = GlobalArgs { + json: true, + ..GlobalArgs::default() + }; + let opts = vec!["first".to_string(), "second".to_string()]; + assert!(matches!( + select_one("pick one", &opts, &json), + Err(SelectError::JsonModeNeedsExplicit) + )); + // JSON mode is decided before the empty-options guard. + assert!(matches!( + select_one("pick", &[], &json), + Err(SelectError::JsonModeNeedsExplicit) + )); + } + + #[test] + fn select_one_empty_options_is_cancelled_not_index_zero() { + assert!(matches!( + select_one("pick", &[], &GlobalArgs::default()), + Err(SelectError::Cancelled) + )); + } +} diff --git a/crates/socket-patch-cli/src/ui/status.rs b/crates/socket-patch-cli/src/ui/status.rs new file mode 100644 index 00000000..ca833c60 --- /dev/null +++ b/crates/socket-patch-cli/src/ui/status.rs @@ -0,0 +1,332 @@ +//! A single self-rewriting status line ("Scanning packages...", +//! "Querying API... (batch 3/7)"). +//! +//! Deterministic by construction: no timer, no background thread, and +//! every byte is written synchronously by a method call, so a `Vec` +//! sink captures exactly what a terminal would receive. + +use std::fmt::Display; +use std::io::{self, IsTerminal, Write}; + +/// Return to column 0 and erase the whole line. +const CLEAR: &str = "\r\x1b[2K"; + +/// A transient progress line on a terminal, or nothing at all elsewhere. +/// +/// - **live** (stderr is a terminal that understands escape sequences, +/// `TERM` is not `dumb`, not `--json`/`--silent`, not `SOCKET_DEBUG`): +/// [`set`](Self::set) redraws the line in place, always clearing first, +/// so a shorter message never leaves the tail of a longer one behind. +/// Messages are cut to `width - 1` characters: a line that wraps can't +/// be rewritten by `\r`. +/// - **not live**: `set` is a no-op and no `\r` or escape sequence is +/// ever written. [`finish_with`](Self::finish_with) still writes its +/// plain final line when `report` is on, so logs and pipes keep the +/// result lines. +/// +/// Anything else printed while a line is showing must go through +/// [`println`](Self::println), which clears the line, prints, and redraws. +/// Dropping the value clears a still-visible line. +pub(crate) struct StatusLine { + out: W, + live: bool, + report: bool, + width: usize, + current: Option, +} + +impl StatusLine { + /// The status line for a command's stderr. It reports (writes + /// [`finish_with`](Self::finish_with) lines) unless `json` or + /// `silent`, and is live only when it reports on a terminal that + /// understands escape sequences. + /// + /// Under `SOCKET_DEBUG` the line is never live: core's debug logging + /// writes straight to stderr and would land on the end of the status. + pub(crate) fn stderr(json: bool, silent: bool) -> Self { + let human = !json && !silent; + let live = human + && io::stderr().is_terminal() + && super::stderr_vt() + && !term_is_dumb() + && !socket_patch_core::utils::env_compat::is_debug_enabled(); + StatusLine::new(io::stderr(), live, human, super::stderr_width()) + } +} + +fn term_is_dumb() -> bool { + std::env::var("TERM").is_ok_and(|t| t == "dumb") +} + +impl StatusLine { + /// `live`: draw transient lines. `report`: write `finish_with` lines. + /// `width`: terminal columns. + pub fn new(out: W, live: bool, report: bool, width: usize) -> Self { + StatusLine { + out, + live, + report, + width, + current: None, + } + } + + /// Show `msg` as the current status (replacing any previous one). + pub fn set(&mut self, msg: impl Display) { + if !self.live { + return; + } + let msg: String = msg.to_string(); + let fitted: String = msg + .chars() + .filter(|c| !c.is_control()) + .take(self.width.saturating_sub(1).max(1)) + .collect(); + let _ = write!(self.out, "{CLEAR}{fitted}"); + let _ = self.out.flush(); + self.current = Some(fitted); + } + + /// Print a permanent line (a warning or error) without garbling the + /// status: clear it, write `line`, then redraw it. The line is always + /// written — callers decide whether it should print at all. + /// + /// `line` often carries a server error body, so trailing whitespace + /// (a body's `\r\n`, which would add a blank line) is trimmed and + /// control characters other than `\n` and `\t` (a stray `\r` or + /// escape sequence would rewrite the terminal) are dropped. + pub fn println(&mut self, line: impl Display) { + let line = line.to_string(); + let line: String = line + .trim_end() + .chars() + .filter(|&c| c == '\n' || c == '\t' || !c.is_control()) + .collect(); + self.clear(); + let _ = writeln!(self.out, "{line}"); + if let Some(cur) = &self.current { + let _ = write!(self.out, "{CLEAR}{cur}"); + } + let _ = self.out.flush(); + } + + /// Replace the status with a permanent result line (written when + /// `report` is on, live or not). + pub fn finish_with(&mut self, line: impl Display) { + self.clear(); + self.current = None; + if self.report { + let _ = writeln!(self.out, "{line}"); + } + let _ = self.out.flush(); + } + + /// Erase the status, leaving nothing behind. + pub fn finish(&mut self) { + self.clear(); + self.current = None; + let _ = self.out.flush(); + } + + /// Erase the visible line (keeps `current` for a redraw). + fn clear(&mut self) { + if self.live && self.current.is_some() { + let _ = write!(self.out, "{CLEAR}"); + } + } + + /// The sink, for tests. + #[cfg(test)] + pub(crate) fn into_inner(mut self) -> W + where + W: Default, + { + self.finish(); + std::mem::take(&mut self.out) + } +} + +impl Drop for StatusLine { + fn drop(&mut self) { + self.finish(); + } +} + +#[cfg(test)] +mod tests { + use super::super::test_support::render; + use super::*; + + fn live() -> StatusLine> { + StatusLine::new(Vec::new(), true, true, 80) + } + + fn bytes(s: &StatusLine>) -> String { + String::from_utf8(s.out.clone()).unwrap() + } + + #[test] + fn set_writes_clear_then_message() { + let mut s = live(); + s.set("abc"); + assert_eq!(bytes(&s), "\r\x1b[2Kabc"); + } + + #[test] + fn user_report_regression_final_line_has_no_stale_tail() { + // The reported garble: "Found 7 patches for 1 packagesatch 7/7)". + let mut s = live(); + s.set("Querying API for patches... (batch 7/7)"); + s.finish_with("Found 7 patches for 1 package"); + let out = bytes(&s); + assert_eq!( + render(out.as_bytes()), + vec!["Found 7 patches for 1 package"] + ); + } + + #[test] + fn shorter_after_longer_leaves_no_residue() { + let mut s = live(); + s.set("Scanning global packages..."); + s.finish_with("Found 2 packages"); + assert_eq!(render(bytes(&s).as_bytes()), vec!["Found 2 packages"]); + + let mut s = live(); + s.set("Querying API for patches... (batch 10/10)"); + s.set("Short"); + assert_eq!(render(bytes(&s).as_bytes()), vec!["Short"]); + } + + #[test] + fn println_while_active_clears_prints_and_redraws() { + let mut s = live(); + s.set("Querying API for patches... (batch 1/3)"); + s.println("Warning: falling back"); + assert_eq!( + bytes(&s), + "\r\x1b[2KQuerying API for patches... (batch 1/3)\ + \r\x1b[2KWarning: falling back\n\ + \r\x1b[2KQuerying API for patches... (batch 1/3)" + ); + assert_eq!( + render(bytes(&s).as_bytes()), + vec![ + "Warning: falling back", + "Querying API for patches... (batch 1/3)" + ] + ); + s.set("Querying API for patches... (batch 2/3)"); + s.finish_with("Found 1 patch for 1 package"); + assert_eq!( + render(bytes(&s).as_bytes()), + vec!["Warning: falling back", "Found 1 patch for 1 package"] + ); + } + + #[test] + fn println_trims_and_strips_control_chars_from_server_text() { + let mut s = live(); + s.println("Error querying batch 1: 502 Bad\r\nGateway\x1b[2J\x07\r\n\r\n"); + assert_eq!(bytes(&s), "Error querying batch 1: 502 Bad\nGateway[2J\n"); + let mut s = live(); + s.println("a\tb \n"); + assert_eq!(bytes(&s), "a\tb\n"); + } + + #[test] + fn println_with_nothing_active_is_just_the_line() { + let mut s = live(); + s.println("Error querying batch 1: boom"); + assert_eq!(bytes(&s), "Error querying batch 1: boom\n"); + } + + #[test] + fn drop_clears_an_active_line() { + let mut buf = Vec::new(); + { + let mut s = StatusLine::new(&mut buf, true, true, 80); + s.set("Fetching patch details... (1/3)"); + } + let out = String::from_utf8(buf).unwrap(); + assert!(out.ends_with("\r\x1b[2K"), "{out:?}"); + assert_eq!(render(out.as_bytes()), Vec::::new()); + } + + #[test] + fn drop_with_nothing_active_writes_nothing() { + let mut buf = Vec::new(); + { + let _s = StatusLine::new(&mut buf, true, true, 80); + } + assert!(buf.is_empty()); + { + let mut s = StatusLine::new(&mut buf, true, true, 80); + s.set("x"); + s.finish(); + } + assert_eq!(String::from_utf8(buf).unwrap(), "\r\x1b[2Kx\r\x1b[2K"); + } + + #[test] + fn non_live_writes_no_escapes_but_keeps_final_lines() { + let mut s = StatusLine::new(Vec::new(), false, true, 80); + s.set("Scanning packages..."); + s.println("Warning: w"); + s.set("Querying API for patches... (batch 1/1)"); + s.finish_with("Found 2 packages"); + s.set("Fetching patch details... (1/1)"); + s.finish(); + let out = s.into_inner(); + assert_eq!( + String::from_utf8(out).unwrap(), + "Warning: w\nFound 2 packages\n" + ); + } + + #[test] + fn not_reporting_suppresses_final_lines_but_not_println() { + // --silent: result lines vanish, errors routed via println stay. + let mut s = StatusLine::new(Vec::new(), false, false, 80); + s.set("x"); + s.println("Error querying batch 2: boom"); + s.finish_with("Found 2 packages"); + let out = s.into_inner(); + assert_eq!( + String::from_utf8(out).unwrap(), + "Error querying batch 2: boom\n" + ); + } + + #[test] + fn live_output_has_newlines_only_from_println_and_finish_with() { + let mut s = live(); + for i in 1..=5 { + s.set(format!("Querying API for patches... (batch {i}/5)")); + } + assert!(!bytes(&s).contains('\n')); + } + + #[test] + fn long_message_is_cut_to_width_minus_one_on_char_boundaries() { + let mut s = StatusLine::new(Vec::new(), true, true, 10); + s.set("Scanning ééééééééé packages"); + let out = bytes(&s); + let shown = out.strip_prefix("\r\x1b[2K").unwrap(); + assert_eq!(shown, "Scanning "); + let mut s = StatusLine::new(Vec::new(), true, true, 6); + s.set("ééééééééé"); + assert_eq!(bytes(&s), "\r\x1b[2Kééééé"); + // A degenerate width still shows something and never panics. + let mut s = StatusLine::new(Vec::new(), true, true, 0); + s.set("abc"); + assert_eq!(bytes(&s), "\r\x1b[2Ka"); + } + + #[test] + fn control_chars_in_a_message_cannot_break_the_line() { + let mut s = live(); + s.set("a\nb\rc"); + assert_eq!(bytes(&s), "\r\x1b[2Kabc"); + } +} diff --git a/crates/socket-patch-cli/src/ui/test_support.rs b/crates/socket-patch-cli/src/ui/test_support.rs new file mode 100644 index 00000000..2c668efb --- /dev/null +++ b/crates/socket-patch-cli/src/ui/test_support.rs @@ -0,0 +1,61 @@ +//! A tiny terminal emulator for asserting on what a user would *see*. +//! +//! Test-only and dependency-free: the lib includes it under `cfg(test)`, +//! and the integration tests include this same file by path from +//! `tests/common/pty_io.rs`, so there is exactly one emulator. + +/// Render raw bytes as screen lines: `\n` starts a line, `\r` returns +/// to column 0 (later text overwrites), `ESC[2K` erases the line, +/// `ESC[K` erases to its end, SGR and other CSI sequences are +/// dropped. Trailing spaces are trimmed and a final empty line is +/// omitted. +pub fn render(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes); + let mut lines: Vec> = vec![Vec::new()]; + let mut col = 0usize; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + let line = lines.last_mut().expect("never empty"); + match c { + '\n' => { + lines.push(Vec::new()); + col = 0; + } + '\r' => col = 0, + '\x1b' if chars.peek() == Some(&'[') => { + chars.next(); + let mut params = String::new(); + let mut fin = '\0'; + for c in chars.by_ref() { + if ('\x40'..='\x7e').contains(&c) { + fin = c; + break; + } + params.push(c); + } + match (fin, params.as_str()) { + ('K', "2") => line.clear(), + ('K', "" | "0") => line.truncate(col), + _ => {} + } + } + c => { + if col < line.len() { + line[col] = c; + } else { + line.resize(col, ' '); + line.push(c); + } + col += 1; + } + } + } + let mut out: Vec = lines + .into_iter() + .map(|l| l.into_iter().collect::().trim_end().to_string()) + .collect(); + if out.last().is_some_and(String::is_empty) { + out.pop(); + } + out +} diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs new file mode 100644 index 00000000..78074958 --- /dev/null +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -0,0 +1,140 @@ +//! Pure text helpers for human output: counted nouns and one-line +//! truncation. No I/O, no terminal state. + +/// `plural(1, "package", "packages")` → `"1 package"`, +/// `plural(2, "package", "packages")` → `"2 packages"` (and `0 packages`). +pub fn plural(n: usize, one: &str, many: &str) -> String { + format!("{n} {}", if n == 1 { one } else { many }) +} + +const ELLIPSIS: &str = "..."; + +/// How far back from the cut point a word boundary is still preferred +/// over a hard mid-word cut. +const WORD_BOUNDARY_WINDOW: usize = 15; + +/// Fit `s` on one line of at most `max` characters. +/// +/// - Every whitespace run (including embedded newlines and tabs from API +/// free text) collapses to a single space, and the ends are trimmed. +/// - Counts `char`s, never bytes, so multi-byte text never panics. +/// - When it has to cut, it prefers the last space within the final +/// ~15 characters, trims the trailing space, and appends `...`. +/// - The result never exceeds `max` characters (for `max <= 3` there is +/// no room for an ellipsis, so it is a plain hard cut). +pub fn truncate(s: &str, max: usize) -> String { + let flat = s.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= max { + return flat; + } + if max <= ELLIPSIS.len() { + return flat.chars().take(max).collect(); + } + let budget = max - ELLIPSIS.len(); + let chars: Vec = flat.chars().collect(); + let mut cut = budget; + // The char right after the cut being a space means the cut already + // lands on a word boundary; otherwise look back for one. + if chars[budget] != ' ' { + if let Some(space) = chars[..budget].iter().rposition(|&c| c == ' ') { + if space > 0 && space + WORD_BOUNDARY_WINDOW >= budget { + cut = space; + } + } + } + let head: String = chars[..cut].iter().collect(); + format!("{}{ELLIPSIS}", head.trim_end()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plural_counts() { + assert_eq!(plural(0, "package", "packages"), "0 packages"); + assert_eq!(plural(1, "package", "packages"), "1 package"); + assert_eq!(plural(2, "package", "packages"), "2 packages"); + assert_eq!(plural(1, "patch", "patches"), "1 patch"); + assert_eq!(plural(7, "patch", "patches"), "7 patches"); + } + + #[test] + fn truncate_short_input_is_untouched() { + assert_eq!(truncate("hello", 60), "hello"); + assert_eq!(truncate("", 5), ""); + let exact = "a".repeat(10); + assert_eq!(truncate(&exact, 10), exact); + } + + #[test] + fn truncate_ascii_hard_cut_without_nearby_space() { + let s = "a".repeat(50); + let out = truncate(&s, 20); + assert_eq!(out, format!("{}...", "a".repeat(17))); + assert_eq!(out.chars().count(), 20); + } + + #[test] + fn truncate_prefers_word_boundary() { + let s = "Nuxt route rules silently dropped for mixed-case paths, bypassing appMiddleware"; + let out = truncate(s, 76); + assert_eq!( + out, + "Nuxt route rules silently dropped for mixed-case paths, bypassing..." + ); + assert!(out.chars().count() <= 76); + } + + #[test] + fn truncate_trims_space_before_ellipsis() { + // The cut lands right after a space: no "word ..." gap. + let out = truncate("abcdefgh ijklmnopqrstuvwxyz", 12); + assert_eq!(out, "abcdefgh..."); + } + + #[test] + fn truncate_far_boundary_falls_back_to_hard_cut() { + // The only space is further back than the window: cut mid-word. + let s = format!("ab {}", "c".repeat(40)); + let out = truncate(&s, 30); + assert_eq!(out, format!("ab {}...", "c".repeat(24))); + assert_eq!(out.chars().count(), 30); + } + + #[test] + fn truncate_multibyte_is_char_safe() { + let s = "é".repeat(100); + let out = truncate(&s, 10); + assert_eq!(out, format!("{}...", "é".repeat(7))); + let cjk = "漏洞修复".repeat(10); + assert_eq!(truncate(&cjk, 5).chars().count(), 5); + // 90 bytes but 30 chars: fits under 80 and is returned untouched + // (a byte-length check would cut it, a byte slice would panic). + let fits = "日".repeat(30); + assert_eq!(truncate(&fits, 80), fits); + } + + #[test] + fn truncate_collapses_newlines_and_tabs() { + assert_eq!( + truncate("line one\nline\ttwo \r\n three", 80), + "line one line two three" + ); + let out = truncate("first\nsecond third fourth", 16); + assert!(!out.contains('\n'), "{out:?}"); + assert_eq!(out, "first second..."); + } + + #[test] + fn truncate_tiny_max_never_exceeds() { + let s = "abcdefgh"; + assert_eq!(truncate(s, 0), ""); + assert_eq!(truncate(s, 1), "a"); + assert_eq!(truncate(s, 3), "abc"); + assert_eq!(truncate(s, 4), "a..."); + for max in 0..12 { + assert!(truncate("some words here ok", max).chars().count() <= max); + } + } +} diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs index 2229f668..6cb95883 100644 --- a/crates/socket-patch-cli/src/update_notifier.rs +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -17,16 +17,16 @@ //! - it can never delay a command beyond the grace budget; //! - state corruption/unwritability is silently absorbed. +use std::io::IsTerminal; use std::time::Duration; use socket_patch_core::update::{ - self as core_update, detect_channel, is_newer, upgrade_hint, ChannelEnv, InstallChannel, - UpdateEndpoints, UpdateTimeouts, + self as core_update, detect_channel, is_newer, upgrade_hint, upgrade_hint_for, ChannelEnv, + InstallChannel, UpdateEndpoints, UpdateTimeouts, }; use socket_patch_core::utils::socket_cli_config::env_truthy; use crate::args::GlobalArgs; -use crate::output; /// Everything the guard stack looks at, captured up front so the decision /// logic is a pure, table-testable function. @@ -124,7 +124,9 @@ impl GuardCtx { silent: common.silent, json: common.json, ci: in_ci(), - stderr_tty: output::stderr_is_tty(), + // The notice prints on stderr; stdout may be legitimately piped + // (`list | jq`) in a perfectly interactive session. + stderr_tty: std::io::stderr().is_terminal(), forced: env_truthy("SOCKET_UPDATE_NOTIFIER_FORCE"), state_dir_resolvable: core_update::state::state_dir().is_some(), } @@ -234,20 +236,22 @@ async fn refresh_latest(debug: bool) -> Option { /// pointing an npm-installed user at `--update` would only route them into /// its managed-install refusal. fn upgrade_command() -> &'static str { - let channel = core_update::resolve_install_path() - .map(|p| detect_channel(&p, &ChannelEnv::from_env())) - .unwrap_or(InstallChannel::Standalone); - upgrade_hint(channel) + match core_update::resolve_install_path() { + Ok(p) => upgrade_hint_for(detect_channel(&p, &ChannelEnv::from_env()), &p), + Err(_) => upgrade_hint(InstallChannel::Standalone), + } } -/// Render the two-line notice. Pure for unit tests. +/// Render the two-line notice. Pure for unit tests. The caller prints a +/// blank line before it (it follows the command's own output, often an +/// `Error: ...` line, and must not read as part of that error). fn format_notice( current: &semver::Version, latest: &semver::Version, hint: &str, use_color: bool, ) -> String { - let new_version = output::color(&latest.to_string(), "32", use_color); + let new_version = crate::ui::paint(&latest.to_string(), "32", use_color); format!( "[socket-patch] Update available: {current} \u{2192} {new_version}\n\ [socket-patch] Run `{hint}` to upgrade (set SOCKET_NO_UPDATE_CHECK=1 to hide)" @@ -326,13 +330,15 @@ pub async fn finish(notifier: Option) { return; } + // Separator: the notice follows the command's own output. + eprintln!(); eprintln!( "{}", format_notice( ¤t, &latest, upgrade_command(), - output::stderr_is_tty() + crate::ui::stderr_color() ) ); @@ -462,7 +468,14 @@ mod tests { ); let colored = format_notice(¤t, &latest, "socket-patch --update", true); assert!(colored.contains("\u{1b}["), "{colored}"); - // Two lines, both stderr-prefixed for grep-ability. + // Exactly two lines (the caller prints the separating blank line), + // both prefixed for grep-ability. + assert_eq!( + plain, + "[socket-patch] Update available: 3.3.0 \u{2192} 3.4.0\n\ + [socket-patch] Run `socket-patch --update` to upgrade \ + (set SOCKET_NO_UPDATE_CHECK=1 to hide)" + ); for line in plain.lines() { assert!(line.starts_with("[socket-patch]"), "{line}"); } diff --git a/crates/socket-patch-cli/tests/cli_apply_silent.rs b/crates/socket-patch-cli/tests/cli_apply_silent.rs index 42dcf88f..49a8fb9a 100644 --- a/crates/socket-patch-cli/tests/cli_apply_silent.rs +++ b/crates/socket-patch-cli/tests/cli_apply_silent.rs @@ -292,7 +292,7 @@ fn apply_silent_unmatched_manifest_keeps_warning_output() { "silent human mode writes the diagnostic to stderr, not stdout: {stdout}" ); assert!( - stderr.contains("Warning: No packages found that match available patches"), + stderr.contains("Error: The targeted manifest patch matched no installed package:"), "--silent must keep the exit-flipping diagnostic (errors only, never \ nothing); stderr was: {stderr:?}" ); diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs index ea23e17c..dc09b57a 100644 --- a/crates/socket-patch-cli/tests/cli_config_fallback.rs +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -57,7 +57,9 @@ fn write_config(data_dir: &Path, json: &serde_json::Value) { /// project so the crawl finds nothing and no batch request fires. fn scan_cmd(project: &Path, data_dir: &Path) -> Command { let mut cmd = Command::new(BINARY); - cmd.args(["scan", "--json", "-e", "npm", "--cwd"]) + // Human mode: core's proxy advisory (the oracle below) is muted under + // `--json`/`--silent`. + cmd.args(["scan", "-e", "npm", "--cwd"]) .arg(project); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); @@ -292,6 +294,14 @@ async fn corrupt_config_warns_and_keeps_json_stdout_clean() { Some(0), "a corrupt config must never break the run" ); + let mut json_cmd = scan_cmd(project.path(), data.path()); + json_cmd.arg("--json"); + let json_out = run(json_cmd); + assert!( + json_out.stderr.contains("could not parse socket-cli config"), + "the parse warning must reach stderr under --json too; got:\n{}", + json_out.stderr + ); assert!( out.stderr.contains("could not parse socket-cli config") && out.stderr.contains("config.json"), @@ -303,10 +313,10 @@ async fn corrupt_config_warns_and_keeps_json_stdout_clean() { "with the config unusable the run falls back to the public proxy; stderr:\n{}", out.stderr ); - serde_json::from_str::(&out.stdout).unwrap_or_else(|e| { + serde_json::from_str::(&json_out.stdout).unwrap_or_else(|e| { panic!( "--json stdout must stay parseable despite the warning ({e}); stdout:\n{}", - out.stdout + json_out.stdout ) }); } diff --git a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs index 7e8893ba..b5b91d44 100644 --- a/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/cli_dry_run_paths_e2e.rs @@ -376,7 +376,7 @@ fn apply_dry_run_human_count_excludes_vendored() { assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("1 package(s) can be patched"), + stdout.contains("1 package can be patched"), "human dry-run count must exclude the vendored entry; stdout:\n{stdout}" ); } diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 7a6063f7..ad6b3939 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -469,7 +469,7 @@ fn populated_manifest_plain_lists_full_record_via_binary() { // Every field of the single record must be rendered, not just an exit 0. assert!( - stdout.contains("Found 1 patch(es):"), + stdout.contains("Found 1 patch:"), "missing count header: {stdout}" ); assert!( @@ -496,7 +496,7 @@ fn populated_manifest_plain_lists_full_record_via_binary() { ); assert!(stdout.contains("CVE-2024-0001"), "missing cve: {stdout}"); assert!( - stdout.contains("Severity: high"), + stdout.contains("Severity: HIGH"), "missing severity: {stdout}" ); assert!( @@ -695,7 +695,7 @@ fn multi_manifest_plain_lists_all_records_sorted_via_binary() { // Count header must reflect the real number of patches, not a hardcode. assert!( - stdout.contains("Found 3 patch(es):"), + stdout.contains("Found 3 patches:"), "count header must say 3, got: {stdout}" ); @@ -1066,7 +1066,7 @@ fn hosted_only_project_list_plain_labels_hosted_via_binary() { String::from_utf8_lossy(&out.stderr) ); assert!( - stdout.contains("Found 1 patch(es):"), + stdout.contains("Found 1 patch:"), "count header must include the hosted record: {stdout}" ); assert!( @@ -1184,6 +1184,39 @@ fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); } +#[test] +fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_binary() { + // No manifest and a corrupt ledger: the run takes the + // manifest_not_found exit, but the ledger corruption must still reach + // a JSON consumer via the error envelope's `warnings[]` (stderr is not + // the machine channel), and nothing may leak onto stderr. + let tmp = tempfile::tempdir().unwrap(); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("redirect-state.json"), "{ not json").unwrap(); + + let out = run_list_binary(tmp.path(), &["--json"]); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); + let warnings = v["warnings"].as_array().expect("warnings[] present"); + assert_eq!(warnings.len(), 1, "envelope={v}"); + assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}"); + assert!( + out.stderr.is_empty(), + "--json must keep stderr clean: {}", + String::from_utf8_lossy(&out.stderr) + ); + + // Human mode: the warning still reaches stderr ahead of the error. + let out = run_list_binary(tmp.path(), &[]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1)); + assert!(stderr.contains("Warning: "), "stderr={stderr}"); + assert!(stderr.contains("Error: Manifest not found at "), "stderr={stderr}"); +} + #[test] fn corrupt_manifest_with_hosted_ledger_still_manifest_invalid_via_binary() { // A corrupt manifest is an error state; hosted records must never mask @@ -1415,7 +1448,7 @@ fn vendored_only_project_list_plain_labels_vendored_via_binary() { String::from_utf8_lossy(&out.stderr) ); assert!( - stdout.contains("Found 1 patch(es):"), + stdout.contains("Found 1 patch:"), "count header must include the vendored record: {stdout}" ); assert!( diff --git a/crates/socket-patch-cli/tests/cli_remove_silent.rs b/crates/socket-patch-cli/tests/cli_remove_silent.rs index 7efb2e96..46d2721c 100644 --- a/crates/socket-patch-cli/tests/cli_remove_silent.rs +++ b/crates/socket-patch-cli/tests/cli_remove_silent.rs @@ -66,8 +66,8 @@ fn run_remove(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// A successful `remove --silent --yes` (rollback included — the package /// is simply not installed) must produce no output on either stream: -/// no "will be removed" listing, no "Rolling back" / "No packages found -/// to rollback" progress, no "Removed N patch(es)" summary. +/// no "will be removed" listing, no "Rolling back" progress or skipped- +/// rollback warning, no "Removed N patches" summary. #[test] fn remove_silent_produces_no_output_on_success() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -112,12 +112,15 @@ fn remove_silent_produces_no_output_on_success() { &["pkg:npm/__remove_silent_test__@1.0.0", "--yes"], ); assert_eq!(loud_code, 0); + // (The "Rolling back files..." progress is a transient status line, + // drawn only on a terminal; the crawler-miss warning is the loud + // run's rollback report here.) assert!( - loud_stdout.contains("Rolling back patch before removal"), - "non-silent run must print rollback progress; got {loud_stdout:?}" + loud_stderr.contains("had no matching installed package"), + "non-silent run must report the skipped rollback; got {loud_stderr:?}" ); assert!( - loud_stdout.contains("Removed 1 patch(es) from manifest"), + loud_stdout.contains("Removed 1 patch from manifest"), "non-silent run must print the removal summary; got {loud_stdout:?}" ); assert!( @@ -347,7 +350,7 @@ fn remove_silent_suppresses_detached_revert_output() { let (loud_code, loud_stdout, loud_stderr) = run_remove(tmp2.path(), &[purl, "--yes"]); assert_eq!(loud_code, 0); assert!( - loud_stderr.contains("vendored patch(es) will be reverted and removed"), + loud_stderr.contains("vendored patch will be reverted and removed"), "non-silent detached run must print the listing; got {loud_stderr:?}" ); assert!( diff --git a/crates/socket-patch-cli/tests/cli_rollback_silent.rs b/crates/socket-patch-cli/tests/cli_rollback_silent.rs index 2aff4f1c..f98fbaa3 100644 --- a/crates/socket-patch-cli/tests/cli_rollback_silent.rs +++ b/crates/socket-patch-cli/tests/cli_rollback_silent.rs @@ -285,7 +285,7 @@ fn rollback_silent_per_package_failure_keeps_error_output() { assert!( chatter .iter() - .any(|l| l.contains("Failed to rollback") && l.contains("mismatch-target")), + .any(|l| l.contains("Failed to roll back") && l.contains("mismatch-target")), "--silent must keep the per-package failure line; stderr was: {stderr:?}" ); // The mismatched file must be left untouched (fail-safe). diff --git a/crates/socket-patch-cli/tests/cli_scan_silent.rs b/crates/socket-patch-cli/tests/cli_scan_silent.rs index 89fca24b..d04c8c8b 100644 --- a/crates/socket-patch-cli/tests/cli_scan_silent.rs +++ b/crates/socket-patch-cli/tests/cli_scan_silent.rs @@ -296,8 +296,8 @@ async fn scan_silent_apply_flow_produces_no_output_but_still_applies() { "non-silent scan must print the pre-apply listing; got {loud_stdout:?}" ); assert!( - loud_stderr.contains("Found 1 packages"), - "non-silent scan must print the crawl summary on stderr; got {loud_stderr:?}" + loud_stderr.contains("Found 1 package ("), + "non-silent scan must print the singular crawl summary on stderr; got {loud_stderr:?}" ); } @@ -633,7 +633,7 @@ async fn scan_silent_keeps_error_output() { "--silent must NOT suppress error output; got {stderr:?}" ); assert!( - !stderr.contains("Found 1 packages"), + !stderr.contains("Found 1 package"), "--silent must suppress the informational crawl summary even on \ the error path; got {stderr:?}" ); diff --git a/crates/socket-patch-cli/tests/cli_setup_silent.rs b/crates/socket-patch-cli/tests/cli_setup_silent.rs index 92b915d7..46c5952d 100644 --- a/crates/socket-patch-cli/tests/cli_setup_silent.rs +++ b/crates/socket-patch-cli/tests/cli_setup_silent.rs @@ -100,14 +100,14 @@ fn setup_silent_configures_but_prints_nothing() { // header and summary — otherwise the assertions above pass vacuously. let tmp2 = tempfile::tempdir().expect("tempdir"); write_root(tmp2.path()); - let (loud_code, loud_stdout, _) = run_setup(tmp2.path(), &["--yes"]); + let (loud_code, loud_stdout, loud_stderr) = run_setup(tmp2.path(), &["--yes"]); assert_eq!(loud_code, 0); assert!( - loud_stdout.contains("Configuring socket-patch install hooks"), - "non-silent setup must print the header; got {loud_stdout:?}" + loud_stderr.contains("Configuring socket-patch install hooks"), + "non-silent setup must print the (stderr) header; got {loud_stderr:?}" ); assert!( - loud_stdout.contains("item(s) updated"), + loud_stdout.contains("1 item updated"), "non-silent setup must print the summary; got {loud_stdout:?}" ); } @@ -205,7 +205,7 @@ fn setup_remove_silent_prints_nothing_but_removes() { "non-silent remove must print the preview; got {loud_stdout:?}" ); assert!( - loud_stdout.contains("item(s) had socket-patch removed"), + loud_stdout.contains("1 item had socket-patch removed"), "non-silent remove must print the summary; got {loud_stdout:?}" ); } diff --git a/crates/socket-patch-cli/tests/common/pty_io.rs b/crates/socket-patch-cli/tests/common/pty_io.rs new file mode 100644 index 00000000..ee818f99 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/pty_io.rs @@ -0,0 +1,100 @@ +//! PTY I/O shared by the interactive suites. +//! +//! `confirm()` discards terminal typeahead right before it shows a y/n +//! prompt (so an Enter pressed during a long scan cannot answer it). Input +//! written into the PTY before the prompt appears is therefore thrown +//! away, exactly as a real user's early keystrokes are. These helpers send +//! the scripted answer only once a prompt is on screen. +//! +//! Pull in with `#[path = "common/pty_io.rs"] mod pty_io;` and use it via +//! `crate::pty_io::...`. + +#![allow(dead_code)] + +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// Text that marks "a prompt is waiting for input": the y/n hints and +/// dialoguer's `ColorfulTheme` prompt suffix. +pub const PROMPT_MARKERS: &[&str] = &["[Y/n] ", "[y/N] ", "\u{203a}"]; + +/// Everything the child writes to the PTY, collected on a thread. +pub struct PtyOutput { + buf: Arc>>, + handle: JoinHandle<()>, +} + +impl PtyOutput { + /// Start draining `reader` (the PTY master) until EOF. + pub fn spawn(mut reader: Box) -> Self { + let buf = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&buf); + let handle = std::thread::spawn(move || { + let mut chunk = [0u8; 4096]; + loop { + match reader.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => sink.lock().unwrap().extend_from_slice(&chunk[..n]), + } + } + }); + PtyOutput { buf, handle } + } + + fn saw_prompt(&self) -> bool { + let text = String::from_utf8_lossy(&self.buf.lock().unwrap()).into_owned(); + PROMPT_MARKERS.iter().any(|m| text.contains(m)) + } + + /// Block until a prompt is visible, the child's output ended, or + /// `timeout` passed (then the caller proceeds anyway). + pub fn wait_for_prompt(&self, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline && !self.handle.is_finished() && !self.saw_prompt() { + std::thread::sleep(Duration::from_millis(20)); + } + } + + /// Block until `needle` has appeared at least `n` times, the child's + /// output ended, or `timeout` passed (then the caller proceeds anyway). + /// For a second prompt in one run, where [`Self::wait_for_prompt`] + /// would already be satisfied by the first. + pub fn wait_for_count(&self, needle: &str, n: usize, timeout: Duration) { + let deadline = Instant::now() + timeout; + let count = || { + String::from_utf8_lossy(&self.buf.lock().unwrap()) + .matches(needle) + .count() + }; + while Instant::now() < deadline && !self.handle.is_finished() && count() < n { + std::thread::sleep(Duration::from_millis(20)); + } + } + + /// Join the reader and return all output (call after the child exited + /// and the master was dropped). + pub fn finish(self) -> Vec { + let _ = self.handle.join(); + Arc::try_unwrap(self.buf) + .map(|m| m.into_inner().unwrap()) + .unwrap_or_else(|arc| arc.lock().unwrap().clone()) + } +} + +/// Write `input` once a prompt is on screen (immediately when empty). +pub fn send_when_prompted(out: &PtyOutput, writer: &mut dyn Write, input: &[u8]) { + if !input.is_empty() { + out.wait_for_prompt(Duration::from_secs(10)); + } + let _ = writer.write_all(input); + let _ = writer.flush(); +} + +/// The lib's terminal emulator (`ui::test_support::render`), included +/// from the same source file so the two can't drift. +#[path = "../../src/ui/test_support.rs"] +mod test_support; +#[allow(unused_imports)] +pub use test_support::render; diff --git a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs index 9b6dd50b..9158424e 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_scan_hosted_dryrun_vendored.rs @@ -402,3 +402,99 @@ async fn dry_run_refuses_unrevertable_vendored_state_like_the_wet_run() { "dry-run must leave the vendored lock byte-identical" ); } + +/// `scan --mode hosted [--dry-run]` in HUMAN mode → `(code, stdout, +/// stderr)`. +fn scan_hosted_human(cwd: &Path, api_url: &str, dry_run: bool) -> (i32, String, String) { + let mut args = vec![ + "scan", + "--mode", + "hosted", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]; + if dry_run { + args.push("--dry-run"); + } + run_cli(cwd, &args) +} + +/// The first line of `stdout` (the summary). +/// The engine's one-line summary. Human hosted `scan` prints the results +/// table and discovery summary above it, so find it by its lead words. +fn summary_line(stdout: &str) -> &str { + stdout + .lines() + .find(|l| l.starts_with("Would redirect ") || l.starts_with("Redirected ")) + .unwrap_or_default() +} + +/// Human takeover output: the dry run announces the planned migration and +/// the wet run the landed one, each as its own line on stderr, and both +/// summaries count the same 3 files — the lock and workspace the hosted +/// rewriter touches plus the package.json `pnpm.overrides` wiring only the +/// vendored revert touches (the wet count used to omit it: "rewrote 2 +/// files"). The wet run's next steps name `.socket/vendor/` and +/// package.json, so the deleted vendored ledger entry and artifact and the +/// reverted wiring are committed too. +#[tokio::test] +#[serial] +async fn human_takeover_prints_migration_lines_and_matching_file_counts() { + let server = MockServer::start().await; + mock_hosted_api(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + vendored_project(root); + + let (code, dry_out, dry_err) = scan_hosted_human(root, &server.uri(), true); + assert_eq!(code, 0, "stdout=\n{dry_out}\nstderr=\n{dry_err}"); + assert!( + dry_err.lines().any(|l| l + == format!( + "Would migrate {PURL} from vendored to hosted (its vendored wiring, ledger \ + entry, and committed artifact would be reverted first)." + )), + "stderr=\n{dry_err}" + ); + assert!( + !dry_err.contains("redirect_would_revert_vendored"), + "the planned takeover is a progress line, not a warning; stderr=\n{dry_err}" + ); + assert_eq!( + summary_line(&dry_out), + "Would redirect 1 package and rewrite 3 files (--dry-run: nothing was changed).", + "stdout=\n{dry_out}" + ); + + let (code, wet_out, wet_err) = scan_hosted_human(root, &server.uri(), false); + assert_eq!(code, 0, "stdout=\n{wet_out}\nstderr=\n{wet_err}"); + assert!( + wet_err.lines().any(|l| l + == format!( + "Migrated {PURL} from vendored to hosted (reverted its vendored wiring, ledger \ + entry, and committed artifact)." + )), + "stderr=\n{wet_err}" + ); + assert_eq!( + summary_line(&wet_out), + "Redirected 1 package; rewrote 3 files.", + "the wet count must equal the dry-run preview; stdout=\n{wet_out}" + ); + assert!( + wet_out.contains( + "Commit .socket/vendor/ (the redirect ledger, plus the removed vendored ledger \ + entries and artifacts), package.json, pnpm-lock.yaml, and pnpm-workspace.yaml \ + to keep the redirect." + ), + "stdout=\n{wet_out}" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_vendor_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_vendor_silent_mute_exit.rs index 4160a06b..45bd8ae4 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_vendor_silent_mute_exit.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_vendor_silent_mute_exit.rs @@ -154,7 +154,7 @@ fn vendor_silent_online_fetch_failure_keeps_error_output() { assert!( chatter .iter() - .any(|l| l.contains("could not fetch patch content")), + .any(|l| l.contains("Could not fetch patch content")), "--silent must keep the fetch-failure error (errors only, \ never nothing); stderr was: {stderr:?}" ); diff --git a/crates/socket-patch-cli/tests/covgap_api_client.rs b/crates/socket-patch-cli/tests/covgap_api_client.rs index ec4e2d63..07b2dfee 100644 --- a/crates/socket-patch-cli/tests/covgap_api_client.rs +++ b/crates/socket-patch-cli/tests/covgap_api_client.rs @@ -30,15 +30,11 @@ fn json_stdout(out: &std::process::Output) -> serde_json::Value { }) } -/// A hash-shaped `--api-token` (the dashboard's stored `sha512-...` value) -/// with no `--org` forces org auto-resolution; the mocked 401 on -/// `GET /v0/organizations` must produce the "Could not auto-detect -/// organization" warning WITH the stored-hash hint naming the `sha512-` -/// prefix and the raw `sktsec_..._api` shape — and the command must still -/// degrade gracefully (slug-less authenticated fetch → 404 → not_found, -/// exit 0), not crash. -#[tokio::test] -async fn get_with_hash_shaped_token_prints_stored_hash_hint_on_401() { +/// Run `get --save-only --yes` plus `extra` flags with a hash-shaped +/// `--api-token` (the dashboard's stored `sha512-...` value) and no `--org`, +/// against a fresh mock that 401s org auto-resolution exactly once and +/// 404s the slug-less authenticated view route exactly once. +async fn run_get_with_hash_shaped_token(extra: &[&str]) -> std::process::Output { let mock = MockServer::start().await; // Org auto-resolution: exactly one 401. `.expect(1)` proves the // resolution round-trip actually fired (no ambient slug short-circuit). @@ -58,20 +54,22 @@ async fn get_with_hash_shaped_token_prints_stored_hash_hint_on_401() { .await; let tmp = tempfile::tempdir().unwrap(); - let out = Command::new(binary()) - .args([ - "get", - UUID, - "--json", - "--save-only", - "--yes", - "--api-url", - &mock.uri(), - "--proxy-url", - &mock.uri(), - "--api-token", - "sha512-deadbeefdeadbeef", - ]) + let uri = mock.uri(); + let mut args = vec!["get", UUID]; + args.extend_from_slice(extra); + args.extend_from_slice(&[ + "--save-only", + "--yes", + "--api-url", + &uri, + "--proxy-url", + &uri, + "--api-token", + "sha512-deadbeefdeadbeef", + ]); + // `mock` is dropped (and its `.expect(1)`s verified) on return. + Command::new(binary()) + .args(&args) // Ambient state must not short-circuit auto-resolution: no env // slug, no offline gate, no socket-cli config (`socket login`). .env_remove("SOCKET_ORG_SLUG") @@ -80,27 +78,65 @@ async fn get_with_hash_shaped_token_prints_stored_hash_hint_on_401() { .env("SOCKET_NO_CONFIG", "1") .current_dir(tmp.path()) .output() - .expect("run socket-patch get"); + .expect("run socket-patch get") +} - let stderr = String::from_utf8_lossy(&out.stderr); +/// The warning text both output modes must print for the 401: the +/// "Could not auto-detect organization" warning WITH the stored-hash hint +/// naming the `sha512-` prefix and the raw `sktsec_..._api` shape, plus +/// the pre-flight token-shape warning. +fn assert_hash_token_warnings(stderr: &str, mode: &str) { assert!( stderr.contains("Warning: Could not auto-detect organization"), - "the failed resolution must warn; stderr={stderr}" + "[{mode}] the failed resolution must warn; stderr={stderr}" ); assert!( - stderr.contains("Hint: SOCKET_API_TOKEN starts with `sha512-`"), - "the 401 + hash-shaped token must trigger the stored-hash hint \ - naming the prefix; stderr={stderr}" + stderr.contains("Hint: --api-token starts with `sha512-`"), + "[{mode}] the 401 + hash-shaped token must trigger the stored-hash \ + hint naming the prefix and the flag the token came from; stderr={stderr}" + ); + assert!( + stderr.contains("Warning: --api-token does not look like a Socket API token"), + "[{mode}] the shape warning names the flag, not SOCKET_API_TOKEN; stderr={stderr}" ); assert!( stderr.contains("Set it to the raw `sktsec_..._api` value instead."), - "the hint must tell the operator what to configure; stderr={stderr}" + "[{mode}] the hint must tell the operator what to configure; stderr={stderr}" + ); + assert!( + stderr.contains("looks like an SRI-format hash"), + "[{mode}] the token-shape warning must print; stderr={stderr}" + ); +} + +/// Human mode: the 401 produces the stored-hash hint on stderr, and the +/// command degrades gracefully (slug-less authenticated fetch → 404 → +/// not found, exit 0) instead of crashing. +#[tokio::test] +async fn get_with_hash_shaped_token_prints_stored_hash_hint_on_401() { + let out = run_get_with_hash_shaped_token(&[]).await; + let stderr = String::from_utf8_lossy(&out.stderr); + assert_hash_token_warnings(&stderr, "human"); + assert_eq!( + out.status.code(), + Some(0), + "graceful not-found must exit 0; stderr={stderr}" ); +} - // The command itself degrades gracefully: 404 on the slug-less - // authenticated view route → not_found envelope, exit 0. - let code = out.status.code().unwrap_or(-1); - assert_eq!(code, 0, "graceful not-found must exit 0; stderr={stderr}"); +/// `--json` keeps stdout machine-readable but does not mute warnings: the +/// same hint and token-shape warning reach stderr, and stdout is a valid +/// `not_found` envelope. +#[tokio::test] +async fn get_with_hash_shaped_token_under_json_keeps_warnings_and_envelope() { + let out = run_get_with_hash_shaped_token(&["--json"]).await; + let stderr = String::from_utf8_lossy(&out.stderr); + assert_hash_token_warnings(&stderr, "json"); + assert_eq!( + out.status.code(), + Some(0), + "graceful not-found must exit 0; stderr={stderr}" + ); let v = json_stdout(&out); assert_eq!( v["status"], "not_found", @@ -108,3 +144,22 @@ async fn get_with_hash_shaped_token_prints_stored_hash_hint_on_401() { ); assert_eq!(v["found"], 0, "not_found envelope reports zero found: {v}"); } + +/// `--silent` is "errors only": the same misconfiguration prints neither +/// the token-shape warning nor the org auto-detect warning, and the +/// command still degrades to exit 0. +#[tokio::test] +async fn get_with_hash_shaped_token_under_silent_prints_no_warnings() { + let out = run_get_with_hash_shaped_token(&["--json", "--silent"]).await; + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("Could not auto-detect organization"), + "--silent must mute the org auto-detect warning; stderr={stderr}" + ); + assert!( + !stderr.contains("SRI-format hash"), + "--silent must mute the token-shape warning; stderr={stderr}" + ); + assert_eq!(out.status.code(), Some(0), "stderr={stderr}"); + assert_eq!(json_stdout(&out)["status"], "not_found"); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_apply.rs b/crates/socket-patch-cli/tests/covgap_commands_apply.rs index 35616cce..1ec297bd 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_apply.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_apply.rs @@ -215,7 +215,7 @@ fn check_json_in_sync_emits_success_envelope() { assert_eq!(env["command"], "apply", "envelope: {env}"); assert_eq!(env["status"], "success", "envelope: {env}"); assert!( - env["events"].as_array().map_or(true, |e| e.is_empty()), + env["events"].as_array().is_none_or(|e| e.is_empty()), "an in-sync check reports no drift events: {env}" ); } @@ -331,7 +331,7 @@ fn reconcile_announces_removed_stale_go_redirect_in_human_mode() { "pruning an orphan is a clean no-op; stderr={stderr}" ); assert!( - stdout.contains("Removed 1 stale go patch redirect(s):"), + stdout.contains("Removed 1 stale Go patch redirect:"), "the removal must be announced; stdout={stdout}" ); assert!( @@ -364,7 +364,7 @@ fn reconcile_dry_run_says_would_remove_and_touches_nothing() { "dry-run reconcile is a clean no-op; stderr={stderr}" ); assert!( - stdout.contains("Would remove 1 stale go patch redirect(s):"), + stdout.contains("Would remove 1 stale Go patch redirect:"), "dry-run must use the conditional verb; stdout={stdout}" ); assert!(stdout.contains(GO_PURL), "stdout={stdout}"); @@ -427,7 +427,7 @@ fn offline_mismatch_blob_gap_warns_and_fails_in_human_mode() { let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); assert_eq!(code, 1, "the blob-less mismatch must fail; stderr={stderr}"); assert!( - stderr.contains("need their full patched blob, but --offline prevents fetching"), + stderr.contains("the full patched blob, but --offline prevents fetching"), "the offline prefetch warning must print; stderr={stderr}" ); assert!( @@ -472,7 +472,7 @@ async fn online_mismatch_prefetch_prints_download_line_in_human_mode() { "the default policy warn-overwrites the mismatch; stderr={stderr}" ); assert!( - stderr.contains("Downloading 1 full patched blob(s) for mismatched file(s)"), + stderr.contains("Downloaded 1 full patched blob for mismatched files"), "the human progress line must print before the prefetch; stderr={stderr}" ); assert!( @@ -583,12 +583,12 @@ fn no_matching_packages_prints_warning_block_and_fails() { "an in-scope patch with no installed package fails the run; stderr={stderr}" ); assert!( - stderr.contains("Warning: No packages found that match available patches"), - "stderr={stderr}" + stderr.contains("Error: The targeted manifest patch matched no installed package:"), + "a failing run labels its cause an error; stderr={stderr}" ); assert!( - stderr.contains("1 targeted manifest patch(es) were in scope"), - "the warning must carry the in-scope count; stderr={stderr}" + stderr.contains(" - pkg:npm/"), + "the error must list the unmatched purls; stderr={stderr}" ); assert!( stderr.contains("--cwd points to the right directory"), @@ -636,11 +636,11 @@ fn dry_run_after_apply_reports_already_patched_count() { "stdout={stdout}" ); assert!( - stdout.contains("1 package(s) already patched"), + stdout.contains("1 package already patched"), "the no-op package must be counted as already patched; stdout={stdout}" ); assert!( - stdout.contains("0 package(s) can be patched"), + stdout.contains("0 packages can be patched"), "an already-patched package must not double-count as patchable; stdout={stdout}" ); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_fetch_stage.rs b/crates/socket-patch-cli/tests/covgap_commands_fetch_stage.rs index 12bd4efd..ca704c48 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_fetch_stage.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_fetch_stage.rs @@ -3,7 +3,7 @@ //! //! - the non-quiet offline "no local source" report: the count header, //! the 5-PURL cap, the "... and N more" continuation, and the -//! `repair` hint (`report_offline_missing`); +//! re-run-online hint (`report_offline_missing`); //! - the non-quiet online staging progress: the download announcement //! and the diff→per-file-blob fallback messages; //! - the vendor mem-stager's per-file failure arms for malformed patch @@ -132,7 +132,7 @@ fn apply_offline_nonquiet_lists_capped_missing_purls_and_repair_hint() { "offline + 7 sourceless patches must fail; stderr={stderr}" ); assert!( - stderr.contains("Error: 7 patch(es) have no local source and --offline is set:"), + stderr.contains("Error: 7 patches have no local source and --offline is set:"), "the header must carry the TOTAL count, not the listed count; stderr={stderr}" ); let listed: Vec<&str> = stderr @@ -149,8 +149,8 @@ fn apply_offline_nonquiet_lists_capped_missing_purls_and_repair_hint() { "the 2 unlisted patches are summarized; stderr={stderr}" ); assert!( - stderr.contains("Run \"socket-patch repair\" to download missing artifacts."), - "the repair hint closes the report; stderr={stderr}" + stderr.contains("Run `socket-patch repair` to download missing artifacts."), + "apply's repair remedy closes the report; stderr={stderr}" ); } @@ -161,8 +161,9 @@ fn apply_offline_nonquiet_lists_capped_missing_purls_and_repair_hint() { /// A human-mode (no `--json`/`--silent`) online apply in the default /// `diff` download mode, where the server has no diff archive but serves /// the per-file blob: the run announces the primary download (with the -/// mode tag), reports the diff failure, announces the per-file blob -/// fallback, reports its success — and applies. `.socket/` stays +/// mode tag), announces the per-file blob fallback in place of the +/// unavailable diff archive (whose 404 is not reported: the blobs cover +/// it), reports its success — and applies. `.socket/` stays /// untouched (downloads land in the overlay tempdir). #[tokio::test] async fn apply_online_nonquiet_prints_download_progress_and_diff_fallback() { @@ -235,23 +236,28 @@ async fn apply_online_nonquiet_prints_download_progress_and_diff_fallback() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - // The four progress lines of the staging flow, in the shapes users see. + // The progress lines of the staging flow, in the shapes users see — + // on stderr (stdout is for results). assert!( - stdout.contains("Downloading missing patch artifacts (mode: diff)..."), - "the primary download is announced with its mode tag; stdout={stdout}" + stderr.contains("Downloading missing patch artifacts (mode: diff)..."), + "the primary download is announced with its mode tag; stderr={stderr}" ); assert!( - stdout.contains("Failed to download 1 blob(s)") - && stdout.contains("Diff archive not found on server"), - "the diff fetch failure is reported before the fallback; stdout={stdout}" + !stderr.contains("Failed to download") && !stderr.contains("Diff archive not found"), + "a missing diff archive the blob fallback covers is not reported as a failure; \ + stderr={stderr}" ); assert!( - stdout.contains("Falling back to per-file blob downloads for 1 blob(s)..."), - "the per-file blob fallback is announced with the gap size; stdout={stdout}" + stderr.contains("1 diff archive unavailable; fetching 1 per-file blob instead..."), + "the per-file blob fallback is announced with the gap size; stderr={stderr}" ); assert!( - stdout.contains("Downloaded 1 blob(s)"), - "the fallback's own result line is printed; stdout={stdout}" + stderr.contains("Downloaded 1 blob"), + "the fallback's own result line is printed; stderr={stderr}" + ); + assert!( + !stdout.contains("Downloading") && !stdout.contains("Downloaded"), + "no progress on stdout; stdout={stdout}" ); // The fallback actually applied the patch… @@ -340,7 +346,7 @@ fn run_vendor_human(root: &Path, mock_uri: &str) -> (i32, String, String) { } /// Shared postconditions for every malformed-view variant: exit 1, the -/// fetch was really attempted (announcement on stdout), the non-quiet +/// fetch was really attempted (announcement on stderr), the non-quiet /// summary block names the failed purl on stderr, and the fail-closed run /// wrote nothing (no blobs — mem staging is disk-free — and no vendor /// tree). @@ -350,11 +356,11 @@ fn assert_failed_closed(root: &Path, code: i32, stdout: &str, stderr: &str) { "a malformed view response must fail the run; stdout={stdout}\nstderr={stderr}" ); assert!( - stdout.contains("Fetching 1 patch(es)' content (kept in memory)..."), - "the run must have reached the view fetch (not bailed earlier); stdout={stdout}" + stderr.contains("Fetching content for 1 patch..."), + "the run must have reached the view fetch (not bailed earlier); stderr={stderr}" ); assert!( - stderr.contains("Error: could not fetch patch content for 1 patch(es):"), + stderr.contains("Error: Could not fetch patch content for 1 patch:"), "the summary block carries the failed count; stderr={stderr}" ); assert!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index e757b661..3f6c7759 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -667,7 +667,7 @@ async fn get_uuid_readonly_socket_dir_fails_manifest_write_preserving_manifest() } /// Human-mode uuid path: an update prints `Updated: 1 (replacing …)`, and -/// a same-uuid re-get lands on the `Skipped: 1 (already exists)` print — +/// a same-uuid re-get lands on the `already has this patch recorded` note — /// both exiting 0 with the manifest converged on the fetched uuid. #[tokio::test] #[serial] @@ -686,7 +686,7 @@ async fn get_uuid_human_update_then_rerun_skips_preserving_manifest() { assert_eq!(run(args).await, 0, "the update run must succeed"); assert_eq!(manifest_json(tmp.path())["patches"][PURL]["uuid"], UUID); - // Second run: same uuid → the human `Skipped: 1` print; still exit 0 + // Second run: same uuid → the human "already recorded" note; still exit 0 // and the manifest still records the same uuid. let mut args = default_args(UUID, tmp.path()); args.common.api_url = Some(uri); @@ -784,7 +784,7 @@ async fn package_search_without_fuzzy_match_is_no_match_in_both_modes() { let server = MockServer::start().await; let tmp = tempfile::tempdir().unwrap(); install_npm_fixture(tmp.path(), "leftpad", "1.0.0"); - let (code, stdout, _stderr) = run_get_bin( + let (code, stdout, stderr) = run_get_bin( tmp.path(), &server.uri(), &["zzqxjvwq", "--package", "--save-only"], @@ -794,8 +794,11 @@ async fn package_search_without_fuzzy_match_is_no_match_in_both_modes() { stdout.contains("No packages matching \"zzqxjvwq\" found."), "human no_match message; stdout={stdout}" ); + // Crawl progress is stderr narration: the transient + // "Enumerating packages..." line never reaches a pipe, the result + // line does (singular for one package). assert!( - stdout.contains("Enumerating packages...") && stdout.contains("packages"), + !stdout.contains("Enumerating packages") && stderr.contains("Found 1 package\n"), "the crawl progress prints must appear; stdout={stdout}" ); assert!(received_paths(&server).await.is_empty()); @@ -828,8 +831,8 @@ async fn human_package_search_api_error_reports_fetch_failure() { "a 500 from the package search must exit 1; stdout={stdout}\nstderr={stderr}" ); assert!( - stdout.contains("checking for available patches"), - "the match-count progress line must print first; stdout={stdout}" + stderr.contains(&format!("Best match: pkg:npm/{NAME}@1.0.0\n")), + "the best-match line must print first; stderr={stderr}" ); assert!( stderr.contains("Error:"), @@ -1036,7 +1039,7 @@ async fn engine_readonly_socket_fails_closed_before_any_fetch() { /// Read-only `.socket` with the lock file pre-staged (so the acquire /// succeeds) and no blobs to write (`persist_blobs: false`): the fetched /// record's manifest write is the first write — its failure must surface as -/// the `Error writing manifest` envelope, with no manifest materializing. +/// the `Failed to write manifest` envelope, with no manifest materializing. #[cfg(unix)] #[tokio::test] #[serial] @@ -1068,7 +1071,7 @@ async fn engine_readonly_socket_fails_manifest_write() { json["error"] .as_str() .unwrap_or_default() - .contains("writing manifest"), + .contains("Failed to write manifest"), "the error must name the manifest write; json={json}" ); assert!( @@ -1116,7 +1119,7 @@ async fn engine_manifest_write_failure_unwinds_the_blobs_it_wrote() { json["error"] .as_str() .unwrap_or_default() - .contains("writing manifest"), + .contains("Failed to write manifest"), "json={json}" ); let mut left: Vec = std::fs::read_dir(&blobs) @@ -1515,13 +1518,13 @@ async fn human_vendored_uuid_dry_run_prints_line() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("[dry-run] Would download and vendor 1 patch(es)."), + stdout.contains("[dry-run] Would download and vendor 1 patch. No changes made."), "stdout={stdout}" ); assert!(!tmp.path().join(".socket").exists()); } -/// Human search-path vendored dry-run line (the `patch(es)` count flavor). +/// Human search-path vendored dry-run line (the pluralized count flavor). #[tokio::test] async fn human_vendored_search_dry_run_prints_count() { let server = MockServer::start().await; @@ -1538,7 +1541,7 @@ async fn human_vendored_search_dry_run_prints_count() { ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("[dry-run] Would download and vendor 1 patch(es)."), + stdout.contains("[dry-run] Would download and vendor 1 patch. No changes made."), "the narrowed selection is exactly one patch; stdout={stdout}" ); assert!(!tmp.path().join(".socket").exists()); @@ -1815,9 +1818,11 @@ async fn human_cve_search_empty_prints_search_label_and_not_found() { &["CVE-2099-40990", "--save-only"], ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + // The search progress is a transient stderr status line: it never + // lands on stdout (or in a pipe at all). assert!( - stdout.contains("Searching patches for CVE: CVE-2099-40990"), - "stdout={stdout}" + !stdout.contains("Searching patches for") && !stderr.contains("Searching patches for"), + "stdout={stdout}\nstderr={stderr}" ); assert!( stdout.contains("No patches found for CVE: CVE-2099-40990"), @@ -1969,13 +1974,21 @@ async fn human_ghsa_all_uninstalled_advises_all_releases() { let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[GHSA]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("none of those versions are installed here") + stdout.contains("none of them are installed here") && stdout.contains("Use --all-releases to fetch them anyway."), "stdout={stdout}" ); + // The terminal message already says it: no per-version [skip] flood + // unless --verbose asks for the detail. + assert!( + !stderr.contains("[skip]"), + "per-version [skip] lines are verbose-only; stderr={stderr}" + ); + let (code, _stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[GHSA, "--verbose"]); + assert_eq!(code, 0, "stderr={stderr}"); assert!( - stderr.contains("version not installed"), - "the per-version [skip] lines must print; stderr={stderr}" + stderr.contains("(version not installed)"), + "--verbose prints the per-version [skip] lines; stderr={stderr}" ); assert!(!tmp.path().join(".socket").exists()); } @@ -2071,7 +2084,7 @@ async fn engine_human_readonly_socket_manifest_write_failure_still_errors() { json["error"] .as_str() .unwrap_or_default() - .contains("writing manifest"), + .contains("Failed to write manifest"), "json={json}" ); assert!(!socket.join("manifest.json").exists()); @@ -2124,15 +2137,21 @@ async fn human_search_fixes_line_falls_back_to_advisory_id_without_cves() { .mount(&server) .await; - // Empty project: the run ends in the all-uninstalled terminal, but the - // search listing (the surface under test) prints first. + // Empty project + --all-releases (no installed-version narrowing, so + // the listing — the surface under test — prints) + --dry-run (stop + // at the preview: no view fetch, no writes). let tmp = tempfile::tempdir().unwrap(); - let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[cve]); + let (code, stdout, stderr) = run_get_bin( + tmp.path(), + &server.uri(), + &[cve, "--all-releases", "--dry-run"], + ); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( stdout.contains("Fixes: GHSA-nocv-1111-2222 (high)"), "a CVE-less advisory must be summarized by its id; stdout={stdout}" ); + assert!(!tmp.path().join(".socket").exists()); } /// Human search-path vendored SUCCESS: the vendored flow commits the @@ -2607,3 +2626,377 @@ async fn vendored_uuid_json_leaves_unselected_ledger_entries_alone() { .join(format!("{NAME}-1.0.0.tgz")); assert!(artifact.is_file(), "stdout={stdout}\nstderr={stderr}"); } + +// =========================================================================== +// (9) terminal-UI polish: agent dry-run, --silent failures, forced-type +// validation, proxy 403 → paid_required, narrowed listing. +// =========================================================================== + +/// Agent-mode `--dry-run` (search path) previews and writes NOTHING: no +/// `.socket/`, no view fetch, no prompt, exit 0. +#[tokio::test] +async fn agent_search_dry_run_writes_nothing() { + let server = MockServer::start().await; + mount_ghsa_fanout(&server).await; + mount_real_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let index_before = std::fs::read(tmp.path().join("node_modules/covgap-pkg/index.js")).unwrap(); + + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[GHSA, "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains(&format!(" [would-add] {PURL}\n")) + && stdout.contains("[dry-run] Would download and apply 1 patch. No changes made."), + "stdout={stdout}" + ); + // The listing shows only the installed version; the other one is + // summarized once on stderr. + assert!( + stdout.contains("Found 1 patch:") && !stdout.contains(PURL_V2), + "stdout={stdout}" + ); + assert!( + stderr.contains( + "Skipped 1 patch for 1 package version not installed here \ + (use --all-releases to include it)." + ), + "stderr={stderr}" + ); + assert!( + !stderr.contains("[Y/n]"), + "a dry run never prompts; stderr={stderr}" + ); + assert!(!tmp.path().join(".socket").exists()); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/covgap-pkg/index.js")).unwrap(), + index_before + ); + assert!( + !received_paths(&server) + .await + .iter() + .any(|p| p.contains("/patches/view/")), + "a dry run must not fetch patch views" + ); +} + +/// Agent-mode `--dry-run --json` (uuid path): one envelope with +/// `dryRun: true` and a `would_update` record carrying `oldUuid`; the +/// manifest bytes stay untouched. +#[tokio::test] +async fn agent_uuid_dry_run_json_classifies_against_the_manifest() { + let server = MockServer::start().await; + mount_real_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + seed_manifest_with(tmp.path(), PURL, UUID_B); + let before = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &[UUID, "--dry-run", "--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "success", "{v}"); + assert_eq!(v["dryRun"], true, "{v}"); + assert_eq!(v["applied"], 0, "{v}"); + assert_eq!(v["patches"][0]["action"], "would_update", "{v}"); + assert_eq!(v["patches"][0]["oldUuid"], UUID_B, "{v}"); + assert_eq!( + before, + std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap() + ); +} + +/// `--silent` is "errors only", never "nothing": a failed nested apply +/// still says why the run exits 1. +#[tokio::test] +async fn silent_apply_failure_still_prints_an_error() { + let server = MockServer::start().await; + // A view whose beforeHash matches nothing on disk and whose file is + // missing: the nested apply fails. + mount_view_files( + &server, + UUID, + PURL, + serde_json::json!({ + "package/missing.js": { + "beforeHash": "0".repeat(64), + "afterHash": git_hash(AFTER_BYTES), + "blobContent": b64(AFTER_BYTES), + } + }), + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[UUID, "--silent"]); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + assert!(stdout.is_empty(), "stdout={stdout}"); + assert!( + stderr.contains( + "Error: Some patches could not be applied (re-run without --silent for details)." + ), + "stderr={stderr}" + ); + + // Loud twin: the plain error line, no --silent hint. + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let (code, _stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[UUID]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains("Error: Some patches could not be applied.\n"), + "stderr={stderr}" + ); +} + +/// A forced `--id` / `--cve` / `--ghsa` identifier is shape-checked before +/// any network call: a readable error, exit 1, zero requests. +#[tokio::test] +async fn forced_identifier_type_is_validated_locally() { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + for (flag, what) in [ + ("--id", "is not a valid patch UUID"), + ("--cve", "is not a valid CVE ID"), + ("--ghsa", "is not a valid GHSA ID"), + ] { + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &["lodash", flag]); + assert_eq!(code, 1, "{flag}: stdout={stdout}\nstderr={stderr}"); + assert!( + stderr.contains(&format!("Error: \"lodash\" {what} (expected ")), + "{flag}: stderr={stderr}" + ); + let (code, stdout, _) = run_get_bin(tmp.path(), &server.uri(), &["lodash", flag, "--json"]); + assert_eq!(code, 1); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "error", "{v}"); + assert!(v["error"].as_str().unwrap().contains(what), "{v}"); + } + assert!( + received_paths(&server).await.is_empty(), + "no request may be sent" + ); +} + +/// The public proxy answers a paid patch with 403: that is the same clean +/// `paid_required` outcome (exit 0) as a tier=paid view, not a raw +/// "Forbidden" error. +#[tokio::test] +async fn proxy_403_on_uuid_is_paid_required() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/patch/view/{UUID}"))) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + let uri = server.uri(); + let tmp = tempfile::tempdir().unwrap(); + let run = |extra: &[&str]| { + let mut args = vec!["get", UUID, "--yes", "--api-url", uri.as_str()]; + args.extend_from_slice(extra); + common::run_with_env( + tmp.path(), + &args, + &[ + ("SOCKET_PATCH_PROXY_URL", uri.as_str()), + ("SOCKET_TELEMETRY_DISABLED", "1"), + ], + ) + }; + + let (code, stdout, stderr) = run(&["--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["status"], "paid_required", "{v}"); + assert_eq!(v["patches"][0]["uuid"], UUID, "{v}"); + assert_eq!(v["patches"][0]["tier"], "paid", "{v}"); + + let (code, stdout, stderr) = run(&[]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains(&format!( + "This patch requires a paid subscription to download.\n Patch: {UUID}\n \ + Upgrade at: https://socket.dev/pricing" + )), + "stdout={stdout}" + ); + assert!(!stderr.contains("Forbidden"), "stderr={stderr}"); + assert!(!tmp.path().join(".socket").exists()); +} + +/// A free user's CVE search: a free patch for one installed package and a +/// paid patch for ANOTHER installed package. The narrowed listing must +/// still show the paid fix as `[PAID] (no access)` (an installed package's +/// fix is never silently hidden), while a paid patch for a version that is +/// not installed stays out of it. No `Selected:` block (the one free patch +/// was the only candidate), and stdout starts with the result itself. +#[tokio::test] +async fn narrowed_listing_keeps_installed_paid_no_access_patch() { + let server = MockServer::start().await; + let cve = "CVE-2024-5151"; + let paid = |uuid: &str, purl: &str| { + serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "paid", "license": "MIT", "tier": "paid", + "vulnerabilities": {} + }) + }; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/{cve}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [ + { + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "free", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }, + paid(UUID_B, "pkg:npm/covgap-paid-other@2.0.0"), + paid(UUID_V2, "pkg:npm/covgap-paid-other@9.0.0"), + ], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + install_npm_fixture(tmp.path(), "covgap-paid-other", "2.0.0"); + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[cve, "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.starts_with("Found 2 patches:\n"), + "stdout must start with the listing; stdout={stdout:?}" + ); + assert!( + stdout.contains("pkg:npm/covgap-paid-other@2.0.0 [PAID] (no access)"), + "stdout={stdout}" + ); + assert!( + !stdout.contains("covgap-paid-other@9.0.0"), + "a paid patch for an uninstalled version must not be listed; stdout={stdout}" + ); + assert!(!stdout.contains("Selected:"), "stdout={stdout}"); + assert!( + stdout.contains(&format!(" [would-add] {PURL}\n")), + "stdout={stdout}" + ); + // The paid skip is not the user's to act on: no skip summary counts it. + assert!(!stderr.contains("Skipped "), "stderr={stderr}"); + assert!(!tmp.path().join(".socket").exists()); +} + +/// Agent `--dry-run` runs the same per-release variant narrowing as the +/// wet run: of two PyPI variants of one installed version, only the one +/// matching the installed distribution is previewed. +#[tokio::test] +async fn agent_dry_run_previews_only_the_installed_release_variant() { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let installed = b"installed wheel bytes\n".as_slice(); + fake_pypi_venv(tmp.path(), "covgapsix", "1.0.0", installed); + let venv = tmp.path().join(".venv"); + + let base = "pkg:pypi/covgapsix@1.0.0"; + let purl_wheel = format!("{base}?artifact_id=wheel"); + let purl_sdist = format!("{base}?artifact_id=sdist"); + let files_for = |before: &[u8]| { + serde_json::json!({ + "covgapsix.py": { + "beforeHash": git_hash(before), + "afterHash": git_hash(b"patched\n"), + "blobContent": b64(b"patched\n"), + } + }) + }; + mount_view_files(&server, UUID, &purl_wheel, files_for(installed)).await; + mount_view_files(&server, UUID_B, &purl_sdist, files_for(b"other dist\n")).await; + let patch = |uuid: &str, purl: &str| { + serde_json::json!({ + "uuid": uuid, "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }) + }; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-ghsa/{GHSA}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [patch(UUID, &purl_wheel), patch(UUID_B, &purl_sdist)], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + + let venv_str = venv.to_string_lossy().into_owned(); + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &[ + "get", + GHSA, + "--dry-run", + "--json", + "--api-url", + &server.uri(), + "--api-token", + "fake-token-for-tests", + "--org", + ORG, + "--yes", + ], + &[ + ("SOCKET_TELEMETRY_DISABLED", "1"), + ("VIRTUAL_ENV", venv_str.as_str()), + ], + ); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let v = parse_single_json_doc(&stdout); + assert_eq!(v["dryRun"], true, "{v}"); + let adds: Vec<&serde_json::Value> = v["patches"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["action"] == "would_add") + .collect(); + assert_eq!(adds.len(), 1, "only the installed variant; {v}"); + assert_eq!(adds[0]["purl"], purl_wheel.as_str(), "{v}"); + assert!(!tmp.path().join(".socket").exists()); +} + +/// Human `get --mode vendored` whose every download fails: no record +/// reaches the vendor step, the vendor engine is quiet about an empty record +/// set, and the scan vendor step (in its `get` flavor) closes the run with +/// "No vendorable patches in scope." instead of ending with no summary. +#[tokio::test] +async fn human_vendored_search_all_downloads_failed_prints_empty_run_line() { + let server = MockServer::start().await; + mount_ghsa_fanout(&server).await; + // No views mounted -> every view fetch 404s. + + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + + let (code, stdout, stderr) = run_get_bin( + tmp.path(), + &server.uri(), + &[ + GHSA, + "--mode", + "vendored", + "--vendor-source", + "build", + "--all-releases", + ], + ); + assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains("No vendorable patches in scope."), + "stdout={stdout}\nstderr={stderr}" + ); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_list.rs b/crates/socket-patch-cli/tests/covgap_commands_list.rs index e571b9e2..480f7765 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_list.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_list.rs @@ -260,7 +260,7 @@ fn list_non_json_minimal_record_omits_empty_sections() { ); assert_eq!(code, 0, "stderr:\n{stderr}"); assert!( - stdout.contains("Found 2 patch(es)"), + stdout.contains("Found 2 patches:"), "both records must list; got: {stdout}" ); @@ -278,7 +278,7 @@ fn list_non_json_minimal_record_omits_empty_sections() { // omission assertions on B below cannot pass vacuously. let ghsa_block = package_block(&stdout, "pkg:npm/ghsa-only@1.0.0"); assert!( - ghsa_block.contains("Vulnerabilities (1):") && ghsa_block.contains("Severity: high"), + ghsa_block.contains("Vulnerabilities (1):") && ghsa_block.contains("Severity: HIGH"), "the populated record must print its vulnerability section; got: {ghsa_block}" ); assert!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_remove.rs b/crates/socket-patch-cli/tests/covgap_commands_remove.rs index 9590544e..ab4c469f 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_remove.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_remove.rs @@ -12,6 +12,8 @@ //! remove_invariants.rs / remove_duality_invariants.rs / //! interactive_prompts_e2e.rs (do not edit those files). +#[path = "common/pty_io.rs"] +mod pty_io; use std::path::{Path, PathBuf}; #[path = "common/mod.rs"] @@ -377,7 +379,7 @@ fn remove_corrupt_vendor_ledger_fails_closed_human() { let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--yes", "--offline"], &[]); assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("Error: cannot read .socket/vendor/state.json"), + stderr.contains("Error: Cannot read .socket/vendor/state.json"), "human mode must put the error line on stderr; got:\n{stderr}" ); assert!( @@ -746,7 +748,7 @@ fn remove_hosted_preserve_state_notes_no_preservable_state() { "the preserve-state hosted note must reach stderr; got:\n{stderr}" ); assert!( - stdout.contains("Manifest entries and vendored artifacts preserved"), + stdout.contains("Manifest entry preserved (--preserve-state)"), "the preserve-state summary must print; got:\n{stdout}" ); @@ -795,7 +797,7 @@ fn remove_corrupt_hosted_ledger_warns_and_continues_human() { "the warning must reach stderr; got:\n{stderr}" ); assert!( - stdout.contains("Removed 1 patch(es) from manifest:"), + stdout.contains("Removed 1 patch from manifest:"), "the removal must still report; got:\n{stdout}" ); let manifest: serde_json::Value = @@ -884,7 +886,7 @@ fn remove_hosted_only_human_lists_redirects_and_unwinds() { let (code, stdout, stderr) = run_remove(tmp.path(), &[NPM_PURL, "--yes", "--offline"], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains("The following hosted redirect(s) will be unwound and removed:"), + stderr.contains("The following hosted redirect will be unwound and removed:"), "the hosted-only listing must reach stderr; got:\n{stderr}" ); assert!( @@ -1188,7 +1190,7 @@ fn remove_mixed_drift_keep_is_partial_failure_human() { "the error must carry the normalize remedy; got:\n{stderr}" ); assert!( - stdout.contains("Removed 1 patch(es) from manifest:"), + stdout.contains("Removed 1 patch from manifest:"), "the partial removal must still report; got:\n{stdout}" ); } @@ -1439,13 +1441,11 @@ fn remove_multi_variant_blast_radius_prints_expansion() { let (code, stdout, stderr) = run_remove(tmp.path(), &[base, "--yes", "--offline"], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stderr.contains(&format!( - "{base} matches 2 release variant(s) — all will be removed:" - )), + stderr.contains(&format!("{base} matches 2 release variants — all will be removed:")), "the blast-radius line must reach stderr; got:\n{stderr}" ); assert!( - stdout.contains("Removed 2 patch(es) from manifest:"), + stdout.contains("Removed 2 patches from manifest:"), "both variants must be removed; got:\n{stdout}" ); let manifest: serde_json::Value = @@ -1509,11 +1509,11 @@ fn remove_already_original_human_prints_count_line() { let (code, stdout, stderr) = run_remove(tmp.path(), &[purl, "--yes", "--offline"], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains("1 package(s) already in original state"), + stdout.contains("1 package already in original state"), "the already-original count line must print; got:\n{stdout}" ); assert!( - stdout.contains("Removed 1 patch(es) from manifest:"), + stdout.contains("Removed 1 patch from manifest:"), "the removal must still report; got:\n{stdout}" ); let manifest: serde_json::Value = @@ -1552,7 +1552,7 @@ fn remove_preserve_state_vendored_human_wet_surfaces() { "the per-key preserve line must print; got:\n{stdout}" ); assert!( - stdout.contains("Manifest entries and vendored artifacts preserved"), + stdout.contains("Manifest entry and vendored artifact preserved"), "the preserve summary must print; got:\n{stdout}" ); // All state kept: manifest, ledger, artifact. @@ -1616,7 +1616,7 @@ fn remove_cleanup_failures_warn_not_fatal() { "cleanup failures must never fail the remove; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Removed 1 patch(es) from manifest:"), + stdout.contains("Removed 1 patch from manifest:"), "the removal must succeed; got:\n{stdout}" ); assert!( @@ -1734,7 +1734,6 @@ fn remove_rollback_infrastructure_error_surfaces_rollback_failed() { mod pty { use super::*; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; - use std::io::{Read, Write}; use std::time::Duration; fn binary() -> PathBuf { @@ -1778,12 +1777,9 @@ mod pty { let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -1792,17 +1788,13 @@ mod pty { }); let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input.as_bytes()); - let _ = writer.flush(); + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); drop(writer); let status = child.wait().expect("child.wait"); drop(pair.master); - let output = reader_handle.join().expect("reader thread join"); - ( - status.exit_code() as i32, - String::from_utf8_lossy(&output).to_string(), - ) + let output = reader_handle.finish(); + (status.exit_code() as i32, String::from_utf8_lossy(&output).to_string()) } /// Declining the hosted-only confirm prompt must cancel cleanly (exit @@ -1828,7 +1820,7 @@ mod pty { ); // Vacuity guard: the hosted-only confirm prompt MUST have run. assert!( - output.contains("Remove 1 hosted redirect(s) and unwind their lockfile wiring?"), + output.contains("Remove 1 hosted redirect and unwind its lockfile wiring?"), "the hosted-only confirm prompt must have shown; got: {output}" ); assert!( @@ -1930,7 +1922,7 @@ fn remove_detached_preserve_state_keeps_artifact_and_ledger_entry() { "the preserve preview line must print; stdout=\n{stdout}" ); assert!( - stderr.contains("will be unwired (artifacts and ledger entries preserved)"), + stderr.contains("would be unwired (artifacts and ledger entries preserved)"), "the listing must be honest about --preserve-state; stderr=\n{stderr}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair.rs b/crates/socket-patch-cli/tests/covgap_commands_repair.rs index 40e182a1..ee7dab1a 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair.rs @@ -4,17 +4,16 @@ //! //! Ranges pinned (audited at d5e1815, file unchanged since): //! * the loud `manifest_not_found` / `repair_failed` stderr prints, -//! * the loud "All {mode} artifacts are present locally." summary, +//! * the loud "All {artifacts} are present locally." summary, //! * the `... and N more` truncation of the offline warning (>5 missing) //! and the dry-run preview (>10 missing), -//! * the loud orphan-archive removal print, including the -//! `.replace("blob(s)", "{label} archive(s)")` coupling to -//! `format_cleanup_result`'s exact wording, +//! * the loud orphan-archive removal print, each directory's summary +//! naming its own artifact kind, //! * the archive-cleanup failure arm (stderr warning + `cleanup_failed` //! skip event, exit stays 0, loop continues to the packages pass), //! * an unremovable `apply.lock` (read-only `.socket`) stays non-fatal //! and silent (exit stays 0), -//! * the loud "Rebuilt N vendored artifact(s)." summary after the +//! * the loud "Rebuilt N vendored artifacts." summary after the //! vendored-repair phase. //! //! Everything runs offline or against a wiremock server — no real hosts. @@ -222,7 +221,7 @@ fn repair_failed_human_mode_prints_error_to_stderr() { // Human-mode summaries // --------------------------------------------------------------------------- -/// The loud "All {mode} artifacts are present locally." summary. Every +/// The loud "All {artifacts} are present locally." summary. Every /// existing loud run used the default diff mode with no `.tar.gz` /// present (always "missing"), and every all-present run was `--json` — /// so the print never executed. `--download-mode file` with the referenced @@ -245,8 +244,8 @@ fn repair_all_present_human_mode_prints_summary() { "expected exit 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("All file artifacts are present locally."), - "the all-present summary must name the requested mode; stdout=\n{stdout}" + stdout.contains("All blobs are present locally."), + "the all-present summary must name the requested artifact kind; stdout=\n{stdout}" ); assert!( stdout.contains("Repair complete."), @@ -271,17 +270,22 @@ fn repair_offline_warning_truncates_missing_list_after_five() { .output() .expect("run socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); + // The warning (and its list) is diagnostic output: stderr. + let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!( out.status.code(), Some(0), "offline missing artifacts are a warning, not a failure; stdout=\n{stdout}" ); assert!( - stdout - .contains("Warning: 12 file artifact(s) are missing (offline mode - not downloading)"), - "the warning header must carry the full missing count; stdout=\n{stdout}" + stderr.contains("Warning: 12 blobs are missing (offline mode - not downloading):"), + "the warning header must carry the full missing count; stderr=\n{stderr}" ); - let items = item_lines(&stdout); + assert!( + !stdout.contains("Warning"), + "the warning must not pollute stdout; stdout=\n{stdout}" + ); + let items = item_lines(&stderr); assert_eq!( items.len(), 5, @@ -296,8 +300,8 @@ fn repair_offline_warning_truncates_missing_list_after_five() { ); } assert!( - stdout.contains(" ... and 7 more"), - "the overflow line must report the remaining 12 - 5 = 7 ids; stdout=\n{stdout}" + stderr.contains(" ... and 7 more"), + "the overflow line must report the remaining 12 - 5 = 7 ids; stderr=\n{stderr}" ); } @@ -324,7 +328,7 @@ fn repair_dry_run_preview_truncates_missing_list_after_ten() { "dry-run preview must succeed; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Found 12 missing file artifact(s)"), + stdout.contains("Found 12 missing blobs"), "the online header must carry the full missing count; stdout=\n{stdout}" ); assert!( @@ -348,10 +352,8 @@ fn repair_dry_run_preview_truncates_missing_list_after_ten() { ); } -/// The loud orphan-archive removal print — including the -/// `.replace("blob(s)", "{label} archive(s)")` rewrite of -/// `format_cleanup_result`'s wording, a cross-crate string coupling only a -/// test can pin. One orphan in `diffs/` and one in `packages/`, each next +/// The loud orphan-archive removal print — each directory's summary names +/// its own artifact kind (`format_cleanup_result_for` takes the noun). One orphan in `diffs/` and one in `packages/`, each next /// to the referenced `.tar.gz` that must survive. #[test] fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { @@ -378,25 +380,24 @@ fn repair_removes_orphan_archives_human_mode_prints_relabeled_summary() { Some(0), "expected exit 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); - // The relabeled summaries: `format_cleanup_result` says - // "Removed 1 unused blob(s) (...)"; the archive arms must rewrite the - // noun per directory. + // Each directory's summary names its own artifact kind (the formatter + // takes the noun; no string rewriting of the blob wording). assert!( - stdout.contains("Removed 1 unused diff archive(s)"), - "the diffs sweep must print the relabeled summary; stdout=\n{stdout}" + stdout.contains("Removed 1 unused diff archive (17 B freed)"), + "the diffs sweep must print its own summary; stdout=\n{stdout}" ); assert!( - stdout.contains("Removed 1 unused package archive(s)"), - "the packages sweep must print the relabeled summary; stdout=\n{stdout}" + stdout.contains("Removed 1 unused package archive (16 B freed)"), + "the packages sweep must print its own summary; stdout=\n{stdout}" ); assert!( - !stdout.contains("unused blob(s)"), - "no archive line may leak the unrelabeled 'blob(s)' wording; stdout=\n{stdout}" + !stdout.contains("blob"), + "no archive line may use the blob wording; stdout=\n{stdout}" ); // Bonus pin: with the referenced diff archive present, the default diff // mode takes the all-present branch too. assert!( - stdout.contains("All diff artifacts are present locally."), + stdout.contains("All diff archives are present locally."), "diff mode with the referenced archive present is all-present; stdout=\n{stdout}" ); // Disk effects: orphans gone, referenced archives intact. @@ -519,7 +520,7 @@ fn repair_archive_cleanup_failure_warns_and_continues() { /// True when the loud stdout carries the packages sweep summary. fn stdout_reports_package_sweep(stdout: &str) -> bool { - stdout.contains("Removed 1 unused package archive(s)") + stdout.contains("Removed 1 unused package archive (") } // --------------------------------------------------------------------------- @@ -582,7 +583,7 @@ fn repair_exits_zero_and_stays_quiet_when_lock_file_unremovable() { } // --------------------------------------------------------------------------- -// Loud vendored-repair summary — "Rebuilt N vendored artifact(s)." +// Loud vendored-repair summary — "Rebuilt N vendored artifacts." // --------------------------------------------------------------------------- const UUID: &str = "11111111-1111-4111-8111-111111111111"; @@ -728,7 +729,7 @@ fn run_cli(root: &Path, mock_uri: &str, argv: &[&str], json: bool) -> (i32, Stri ) } -/// The loud "Rebuilt N vendored artifact(s)." summary after the +/// The loud "Rebuilt N vendored artifacts." summary after the /// vendored-repair phase — uncovered only because the sibling e2e suite /// drives every repair through `--json`. Reuses the hermetic /// offline-rebuild fixture (installed copy + seeded after-blob), so the @@ -766,7 +767,7 @@ async fn repair_offline_rebuild_human_mode_prints_rebuilt_summary() { let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair", "--offline"], false); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); assert!( - stdout.contains("Rebuilt 1 vendored artifact(s)."), + stdout.contains("Rebuilt 1 vendored artifact."), "the loud run must print the vendored-rebuild summary; stdout=\n{stdout}" ); assert!(tgz.is_file(), "the tarball was rebuilt offline"); diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs index 2ba021e3..002845bb 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs @@ -1688,7 +1688,7 @@ async fn repair_human_output_lines() { let (code, stdout, stderr) = run_cli_human(tmp.path(), &mock.uri(), &["repair"]); assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); assert!( - stdout.contains("Rebuilding 1 broken vendored artifact(s)"), + stdout.contains("Rebuilding 1 broken vendored artifact..."), "the rebuild header is printed: {stdout}" ); assert!( @@ -1704,7 +1704,7 @@ async fn repair_human_output_lines() { let (code, stdout, stderr) = run_cli_human(tmp.path(), &mock.uri(), &["repair"]); assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); assert!( - stderr.contains(&format!("Cannot repair vendored artifact for {PURL}")), + stderr.contains(&format!("Error: Cannot repair vendored artifact for {PURL}")), "the failure line is printed to stderr: {stderr}" ); } diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 65bfb6b6..acb43b5d 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -24,6 +24,8 @@ //! types so fixtures can never drift from the on-disk schema (the //! `in_process_rollback_hosted.rs` convention). +#[path = "common/pty_io.rs"] +mod pty_io; use std::path::{Path, PathBuf}; use serde_json::{json, Value}; @@ -291,16 +293,16 @@ fn human_dry_run_summary_lists_counts_and_would_free() { "the dry-run header must print; stdout=\n{stdout}" ); assert!( - stdout.contains("1 package(s) can be rolled back"), + stdout.contains("1 package can be rolled back"), "the can-rollback count must print; stdout=\n{stdout}" ); assert!( - stdout.contains("Would remove 1 patch(es) from manifest:") + stdout.contains("Would remove 1 patch from manifest:") && stdout.contains(&format!(" - {}", fx.purl)), "the would-be manifest removal must be previewed; stdout=\n{stdout}" ); assert!( - stdout.contains("Would free") && stdout.contains("bytes of unused blobs/archives"), + stdout.contains("Would free") && stdout.contains("of unused blobs and archives"), "the GC preview must print its would-free line; stdout=\n{stdout}" ); @@ -362,7 +364,7 @@ fn human_wet_reports_already_original_and_not_installed() { "the no-op must print its '(already original)' line; stdout=\n{stdout}" ); assert!( - stderr.contains("Warning: 1 manifest patch(es) had no matching installed package:") + stderr.contains("Warning: 1 manifest patch had no matching installed package:") && stderr.contains(&format!(" - {ghost_purl}")), "the not-installed warning block must print on stderr; stderr=\n{stderr}" ); @@ -409,7 +411,7 @@ fn human_verbose_hash_mismatch_details() { "a hash mismatch must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Failed to rollback:") && stdout.contains(purl), + stdout.contains("Failed to roll back:") && stdout.contains(purl), "the wet failure section must name the package; stdout=\n{stdout}" ); assert!( @@ -462,7 +464,7 @@ fn human_preserve_state_closing_message() { "preserve-state rollback exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Manifest entries and vendored artifacts preserved"), + stdout.contains("Manifest entry preserved (--preserve-state)"), "the preserve-state closing message must print; stdout=\n{stdout}" ); // The system IS restored, the state is NOT. @@ -999,7 +1001,7 @@ fn vendored_wet_human_messages() { "the wet revert line must print; stdout=\n{stdout}" ); assert!( - stdout.contains("unwired packages keep their patched bytes"), + stdout.contains("1 unwired package keeps its patched bytes"), "the reinstall note must print; stdout=\n{stdout}" ); assert_eq!( @@ -1502,6 +1504,8 @@ fn hosted_human_wet_announces_and_unwinds() { code, 0, "the hosted-only rollback succeeds; stdout=\n{stdout}\nstderr=\n{stderr}" ); + // No manifest at all: "No patches found in manifest" would be a + // misleading line right above the hosted unwind. assert!( !stdout.contains("No patches found in manifest"), "the empty-manifest announce must not print when the hosted leg has work; \ @@ -1512,7 +1516,7 @@ fn hosted_human_wet_announces_and_unwinds() { "the wet unwind line must print; stdout=\n{stdout}" ); assert!( - stdout.contains("unwired packages keep their patched bytes"), + stdout.contains("1 unwired package keeps its patched bytes"), "the reinstall note must print; stdout=\n{stdout}" ); assert_eq!( @@ -2161,7 +2165,6 @@ fn manifest_write_failure_warns_and_exits_one() { mod interactive { use super::*; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; - use std::io::{Read, Write}; use std::time::Duration; /// Spawn the binary inside a PTY, send `input`, collect all output — @@ -2210,12 +2213,9 @@ mod interactive { .expect("spawn socket-patch in PTY"); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -2224,14 +2224,13 @@ mod interactive { }); let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input.as_bytes()); - let _ = writer.flush(); + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); drop(writer); let status = child.wait().expect("child.wait"); drop(pair.master); - let output = reader_handle.join().expect("reader thread join"); + let output = reader_handle.finish(); ( status.exit_code() as i32, String::from_utf8_lossy(&output).to_string(), @@ -2266,7 +2265,9 @@ mod interactive { ); assert_eq!(code, 0, "declining must exit 0; got: {output}"); assert!( - output.contains("Roll back 1 patch(es) and remove them from the local manifest? [Y/n]"), + output.contains( + "Roll back 1 patch and remove it from the local manifest? [Y/n]" + ), "the composed confirm prompt must render verbatim; got: {output}" ); assert!( @@ -2497,7 +2498,7 @@ fn manifest_deleted_under_held_lock_fails_with_invalid_manifest() { } /// Human twin of `hosted_persist_failure_lands_in_hosted_failed`: the -/// wet-run ledger persist failure prints the "Error: failed to persist +/// wet-run ledger persist failure prints the "Error: Failed to persist /// the hosted redirect ledger" stderr line, exit 1 — after the replay /// already restored the wired file. #[cfg(unix)] @@ -2525,7 +2526,7 @@ fn hosted_persist_failure_prints_human_error_line() { "a ledger persist failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("Error: failed to persist the hosted redirect ledger"), + stderr.contains("Error: Failed to persist the hosted redirect ledger"), "the human persist-failure line must print on stderr; stderr=\n{stderr}" ); assert_eq!( @@ -2536,7 +2537,7 @@ fn hosted_persist_failure_prints_human_error_line() { } /// Human twin of `manifest_write_failure_warns_and_exits_one`: the failed -/// manifest update prints the "Error: failed to update the manifest:" +/// manifest update prints the "Error: Failed to update the manifest:" /// stderr line, exit 1, manifest byte-identical — after the file restore /// already landed. #[cfg(target_os = "macos")] @@ -2579,7 +2580,7 @@ fn manifest_write_failure_prints_human_error_line() { "a manifest write failure must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("Error: failed to update the manifest:"), + stderr.contains("Error: Failed to update the manifest:"), "the human write-failure line must print on stderr; stderr=\n{stderr}" ); // The restore itself DID land... @@ -2660,15 +2661,15 @@ fn human_dry_run_summary_reports_already_original_and_failed() { "the dry-run header must print; stdout=\n{stdout}" ); assert!( - stdout.contains("0 package(s) can be rolled back"), + stdout.contains("0 packages can be rolled back"), "a no-op and a failure leave nothing rollback-able; stdout=\n{stdout}" ); assert!( - stdout.contains("1 package(s) already in original state"), + stdout.contains("1 package already in original state"), "the already-original summary line must print; stdout=\n{stdout}" ); assert!( - stdout.contains("1 package(s) cannot be rolled back"), + stdout.contains("1 package cannot be rolled back"), "the cannot-rollback summary line must print; stdout=\n{stdout}" ); // Preview, no mutations. @@ -2718,7 +2719,7 @@ fn dry_run_blob_stage_survives_directory_squatting_blob_hash() { stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("1 package(s) already in original state"), + stdout.contains("1 package already in original state"), "the entry must still verify as already original; stdout=\n{stdout}" ); assert!( @@ -3285,3 +3286,57 @@ fn discovered_local_go_redirect_drops_wiring_not_cache_copy() { "the rolled-back entry must leave the manifest; manifest={m}" ); } + +/// A dry run over a locally modified file exits 1 AND says why: the +/// reason appears exactly once, under the verification counts (a dry run +/// has no other failure report). +#[test] +fn dry_run_failure_prints_reason_once() { + let fx = patched_fixture(); + std::fs::write(fx.pkg_dir.join("index.js"), b"locally-edited\n").expect("drift file"); + + let (code, stdout, stderr) = run(fx.root.path(), &["rollback", "--offline", "--dry-run"]); + assert_eq!( + code, 1, + "a dry run that cannot roll back exits 1; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stdout.contains("1 package cannot be rolled back"), + "the count line must print; stdout=\n{stdout}" + ); + let combined = format!("{stdout}{stderr}"); + assert!( + stdout.contains("Failed to roll back:"), + "the dry run must carry the failure section; stdout=\n{stdout}" + ); + assert_eq!( + combined.matches("modified after patching").count(), + 1, + "the reason must appear exactly once; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert_eq!( + combined.matches(fx.purl).count(), + 1, + "the package must be named exactly once; stdout=\n{stdout}\nstderr=\n{stderr}" + ); +} + +/// The unscoped empty-manifest announce still prints when it should: a +/// manifest exists, lists no patches, and no vendored or hosted leg has +/// work. +#[test] +fn empty_manifest_announces_no_patches() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_socket_manifest(tmp.path(), &[]); + + let (code, stdout, stderr) = run(tmp.path(), &["rollback", "--offline", "-y"]); + assert_eq!( + code, 0, + "an empty manifest is a clean no-op; stdout=\n{stdout}\nstderr=\n{stderr}" + ); + assert!( + stdout.contains("No patches found in manifest"), + "the empty-manifest announce must print; stdout=\n{stdout}\nstderr=\n{stderr}" + ); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 52cd51fb..1a6c8088 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -555,15 +555,18 @@ async fn wet_takeover_refuses_unrevertable_vendored_flavor_fail_closed() { let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); assert_eq!(code, 0, "human refusal run exits 0; stderr=\n{stderr}"); assert!( - stdout.contains("Redirected 0 package(s)"), + stdout.contains("Redirected 0 packages; rewrote 0 files."), "anchor: the human redirect branch ran; stdout=\n{stdout}" ); assert!( - stderr.contains(&format!("skipped {PURL} (vendored_revert_failed)")), + stderr.contains(&format!( + " Skipped {PURL}: its vendored state could not be reverted (see the warning)" + )) && stderr.contains("No patches could be redirected:"), "the human skipped line must name purl + reason; stderr=\n{stderr}" ); assert!( - stderr.contains(" warning: ") && stderr.contains("could not be reverted"), + stderr.contains("Warning (redirect_vendored_revert_failed): ") + && stderr.contains("could not be reverted"), "the takeover pre-warning must reach human stderr; stderr=\n{stderr}" ); } @@ -966,7 +969,7 @@ async fn successful_wet_hosted_run_leaves_only_vendor_under_socket() { let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains("Redirected 1 package(s); rewrote"), + stdout.contains("Redirected 1 package; rewrote"), "the run must have redirected (and therefore locked); stdout=\n{stdout}" ); assert!( @@ -1105,7 +1108,7 @@ async fn hosted_human_paid_only_discovery_stops_with_the_paid_hint() { "stdout=\n{stdout}" ); assert!( - stdout.contains("additional patch(es) available with paid subscription"), + stdout.contains("1 additional patch is available with a paid subscription"), "the table's paid nudge still prints; stdout=\n{stdout}" ); assert!( @@ -1861,8 +1864,10 @@ async fn live_hosted_overlap_fires_redirect_supersedes_vendored() { let (code, _stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); assert_eq!(code, 0, "human overlap run exits 0; stderr=\n{stderr}"); assert!( - stderr.contains(" warning: hosted redirect superseded the vendored ledger for:") - && stderr.contains(XPURL), + stderr.contains( + "Warning (redirect_supersedes_vendored): Hosted redirect superseded the vendored \ + ledger for:" + ) && stderr.contains(XPURL), "the supersedes warning must reach human stderr; stderr=\n{stderr}" ); } @@ -1900,15 +1905,18 @@ async fn human_dry_run_prints_would_rewrite_pnpm_guidance_and_vex_skip() { "dry-run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 1 package(s)") && stdout.contains("; would rewrite"), + stdout.contains( + "Would redirect 1 package and rewrite 2 files (--dry-run: nothing was changed)." + ), "the dry-run summary must use the preview verb; stdout=\n{stdout}" ); assert!( - stderr.contains("Skipping VEX generation (--dry-run)."), + stderr.contains("Skipping VEX generation (--dry-run: nothing was redirected)."), "the requested-but-skipped VEX must be announced; stderr=\n{stderr}" ); assert!( - stderr.contains(" warning: ") && stderr.contains("trustLockfile"), + stderr.contains("Warning (redirect_pnpm_trust_lockfile): ") + && stderr.contains("trustLockfile"), "the pnpm trust guidance must reach human stderr; stderr=\n{stderr}" ); assert!( @@ -1952,11 +1960,11 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { "scan --vex exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 1 package(s); rewrote"), + stdout.contains("Redirected 1 package; rewrote"), "anchor: the wet-run summary verb; stdout=\n{stdout}" ); assert!( - stderr.contains("Wrote OpenVEX document with 1 statement(s) to") + stderr.contains("Wrote OpenVEX document with 1 statement to") && stderr.contains("out.vex.json"), "the VEX summary must name the count and the path; stderr=\n{stderr}" ); @@ -1994,11 +2002,14 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() { "rush run exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 1 package(s); rewrote"), + stdout.contains("Redirected 1 package; rewrote"), "anchor: the rush lock must be rewritten; stdout=\n{stdout}" ); assert!( - stderr.contains(" warning: pnpm-lock.yaml was edited outside `rush update`"), + stderr.contains( + "Warning (redirect_rush_repo_state_stale): pnpm-lock.yaml was edited outside \ + `rush update`" + ), "the rush repo-state warning must reach human stderr; stderr=\n{stderr}" ); } @@ -2080,3 +2091,253 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() { "the stale ledger survives (the warning tells the user to fix it)" ); } + +// ───────────── human-output wording (terminal UI polish) ───────────── + +/// A failed reference resolution prints one `Error: ...` line (capitalized, +/// with a retry hint) and exits 1 without touching the project. +#[tokio::test] +async fn human_reference_failure_prints_an_error_line_and_exits_1() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ "error": "boom" }))) + .mount(&server) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + let lock_before = std::fs::read(tmp.path().join("package-lock.json")).unwrap(); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); + assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); + let line = stderr + .lines() + .find(|l| l.contains("resolve patch references")) + .unwrap_or_else(|| panic!("no error line; stderr=\n{stderr}")); + assert!( + line.starts_with("Error: Failed to resolve patch references: ") + && line.ends_with("(nothing was changed; re-run to retry)"), + "{line}" + ); + assert!(!stdout.contains("Redirected"), "stdout=\n{stdout}"); + assert_eq!( + std::fs::read(tmp.path().join("package-lock.json")).unwrap(), + lock_before + ); + + // --silent keeps the error (errors only, never nothing). + let (code, _stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--silent"], &[]); + assert_eq!(code, 1); + assert!( + stderr.contains("Error: Failed to resolve patch references: "), + "stderr=\n{stderr}" + ); +} + +/// A malformed redirect ledger aborts with an `Error: The redirect ledger +/// ...` line (it used to print a bare lowercase sentence). +#[tokio::test] +async fn human_malformed_ledger_prints_an_error_prefix() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + std::fs::create_dir_all(tmp.path().join(".socket/vendor")).unwrap(); + std::fs::write( + tmp.path().join(".socket/vendor/redirect-state.json"), + "{bad", + ) + .unwrap(); + + let (code, _stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 1, "stderr=\n{stderr}"); + assert!( + stderr + .lines() + .any(|l| l.starts_with("Error: The redirect ledger ") && l.contains("malformed")), + "stderr=\n{stderr}" + ); +} + +/// Stdout below the discovery report: human hosted `scan` prints the +/// results table and its `Summary:` block (plus any indented continuation +/// lines) before the engine's own output. +fn engine_stdout(stdout: &str) -> String { + let mut lines = stdout.lines().peekable(); + let mut seen_summary = false; + let mut out = Vec::new(); + while let Some(line) = lines.next() { + if !seen_summary { + if line.starts_with("Summary: ") { + seen_summary = true; + while lines.peek().is_some_and(|l| l.starts_with(' ')) { + lines.next(); + } + } + continue; + } + out.push(line); + } + if !seen_summary { + return stdout.to_string(); + } + let mut joined = out.join("\n"); + if !out.is_empty() { + joined.push('\n'); + } + joined +} + +/// Wet run, then an idempotent re-run: the first prints the singular +/// summary plus next steps on stdout; the second says the package is +/// already redirected instead of "Redirected 1 package(s); rewrote 0 +/// file(s)", and prints no next steps. +#[tokio::test] +async fn human_rerun_says_already_redirected_and_first_run_prints_next_steps() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); + assert_eq!(code, 0, "stderr=\n{stderr}"); + assert_eq!( + engine_stdout(&stdout), + "Redirected 1 package; rewrote 1 file.\n\ + Commit .socket/vendor/redirect-state.json and package-lock.json to keep the \ + redirect.\n\ + Reinstall from the updated lockfile (e.g. `npm ci`) so the installed packages pick \ + up the patched artifacts, then run `socket-patch vex` to verify them.\n", + "stderr=\n{stderr}" + ); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &[], &[]); + assert_eq!(code, 0, "stderr=\n{stderr}"); + assert_eq!( + engine_stdout(&stdout), + "1 package is already redirected; nothing to rewrite.\n", + "stderr=\n{stderr}" + ); + let (code, stdout, _) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0); + assert_eq!( + engine_stdout(&stdout), + "1 package is already redirected; nothing to rewrite.\n" + ); +} + +/// A granted patch whose package has no lock entry is listed by purl on +/// stderr (it used to vanish: only a raw rewriter warning hinted at it), +/// under a `No patches could be redirected:` headline when nothing was. +#[tokio::test] +async fn human_unconfirmed_purl_is_listed_with_a_headline() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + // A lock with no entry for the installed package: nothing pins it. + std::fs::write( + tmp.path().join("package-lock.json"), + r#"{ "name": "consumer", "version": "0.0.0", "lockfileVersion": 3, "requires": true, + "packages": { "": { "name": "consumer", "version": "0.0.0" } } } +"#, + ) + .unwrap(); + + let (code, stdout, stderr) = scan_hosted(tmp.path(), &server.uri(), &["--dry-run"], &[]); + assert_eq!(code, 0, "a no-op redirect still exits 0; stderr=\n{stderr}"); + assert!( + stdout.contains("Would redirect 0 packages and rewrite 0 files"), + "stdout=\n{stdout}" + ); + assert!( + stderr.contains(&format!( + "No patches could be redirected:\n Not redirected {PURL}: no lockfile entry \ + pinning it could be redirected" + )), + "stderr=\n{stderr}" + ); +} + +/// The pnpm trust guidance across runs. The first wet run prints the full +/// guidance (a headline plus ` - ` bullets); an idempotent re-run that +/// changed nothing pnpm-related prints exactly the one-line reminder on +/// stderr while `--json` still carries the full detail; and deleting +/// pnpm-workspace.yaml (the heal path re-creates it) brings the full +/// guidance back. +#[tokio::test] +async fn human_pnpm_rerun_prints_only_the_reminder_and_heal_restores_guidance() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root); + const REMINDER: &str = "Warning (redirect_pnpm_trust_lockfile): 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\n"; + + let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + engine_stdout(&stdout).starts_with("Redirected 1 package; rewrote 2 files.\n"), + "{stdout}" + ); + // Everything from the pnpm warning on (the lines above it are the + // token-format notice and discovery progress). + let pnpm_part = |stderr: &str| -> String { + stderr + .find("Warning (redirect_pnpm_trust_lockfile): ") + .map(|i| stderr[i..].to_string()) + .unwrap_or_default() + }; + assert!( + pnpm_part(&stderr).contains("\n - ") && pnpm_part(&stderr) != REMINDER, + "the first run prints the full guidance; stderr=\n{stderr}" + ); + assert!(root.join("pnpm-workspace.yaml").is_file()); + + let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + engine_stdout(&stdout), + "1 package is already redirected; nothing to rewrite.\n" + ); + assert_eq!( + pnpm_part(&stderr), + REMINDER, + "a no-op re-run prints only the reminder; stderr=\n{stderr}" + ); + + let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]); + assert_eq!(code, 0, "{doc:#}"); + let detail = warning_detail(&doc, "redirect_pnpm_trust_lockfile"); + assert!( + detail.contains("trustLockfile: true") && detail.contains("--store-dir"), + "--json keeps the full guidance on a re-run: {detail}" + ); + + std::fs::remove_file(root.join("pnpm-workspace.yaml")).unwrap(); + let (code, stdout, stderr) = scan_hosted(root, &server.uri(), &[], &[]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + root.join("pnpm-workspace.yaml").is_file(), + "the heal re-creates it" + ); + assert!( + pnpm_part(&stderr).contains("\n - ") && pnpm_part(&stderr) != REMINDER, + "the heal run prints the full guidance again; stderr=\n{stderr}" + ); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 4bcbbb55..37054b07 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -30,6 +30,8 @@ //! `cli_scan_silent.rs` pattern) so ambient developer/CI configuration //! cannot reroute the branch under test. Network tests use wiremock. +#[path = "common/pty_io.rs"] +mod pty_io; use std::path::{Path, PathBuf}; use std::process::Command; @@ -146,23 +148,50 @@ async fn mount_batch_one( cve_ids: &[&str], can_access_paid: bool, ) { + mount_batch_one_delayed( + mock, + purl, + uuid, + tier, + cve_ids, + can_access_paid, + std::time::Duration::ZERO, + ) + .await; +} + +/// [`mount_batch_one`], answering only after `delay`. +async fn mount_batch_one_delayed( + mock: &MockServer, + purl: &str, + uuid: &str, + tier: &str, + cve_ids: &[&str], + can_access_paid: bool, + delay: std::time::Duration, +) { + let body = serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [{ + "uuid": uuid, + "purl": purl, + "tier": tier, + "cveIds": cve_ids, + "ghsaIds": [], + "severity": "high", + "title": "covgap test patch" + }] + }], + "canAccessPaidPatches": can_access_paid, + }); Mock::given(method("POST")) .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "packages": [{ - "purl": purl, - "patches": [{ - "uuid": uuid, - "purl": purl, - "tier": tier, - "cveIds": cve_ids, - "ghsaIds": [], - "severity": "high", - "title": "covgap test patch" - }] - }], - "canAccessPaidPatches": can_access_paid, - }))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(body) + .set_delay(delay), + ) .mount(mock) .await; } @@ -206,9 +235,19 @@ async fn mount_by_package( /// by-package, and the patch view with an inline blob (fixture shape /// mirrors `cli_scan_silent.rs` / `scan_sync_e2e.rs`). async fn mount_one_patch_api(mock: &MockServer, purl: &str, before: &[u8]) { + mount_one_patch_api_delayed(mock, purl, before, std::time::Duration::ZERO).await; +} + +/// [`mount_one_patch_api`] with the batch query answering after `delay`. +async fn mount_one_patch_api_delayed( + mock: &MockServer, + purl: &str, + before: &[u8], + delay: std::time::Duration, +) { let before_hash = git_sha256(before); let after_hash = git_sha256(b"after\n"); - mount_batch_one(mock, purl, UUID, "free", &[], false).await; + mount_batch_one_delayed(mock, purl, UUID, "free", &[], false, delay).await; mount_by_package(mock, purl, UUID, serde_json::json!({})).await; // base64 of "after\n" — inline so the apply step needs no blob endpoint. Mock::given(method("GET")) @@ -490,11 +529,11 @@ async fn scan_paid_patch_without_access_nudges_and_downloads_nothing() { "the table column must render free+paid counts; got {stdout:?}" ); assert!( - stdout.contains("Summary: 1 package(s) with 0 free patch(es)"), + stdout.contains("Summary: 1 package with 0 free patches"), "the no-access summary counts FREE patches only; got {stdout:?}" ); assert!( - stdout.contains("+ 1 additional patch(es) available with paid subscription"), + stdout.contains("+ 1 additional patch is available with a paid subscription"), "the paid nudge must print; got {stdout:?}" ); assert!( @@ -542,7 +581,7 @@ async fn scan_paid_patch_with_access_counts_all_and_reports_detail_failure() { "a failed detail fetch fails the scan; stdout={stdout}" ); assert!( - stdout.contains("Summary: 1 package(s) with 1 available patch(es)"), + stdout.contains("Summary: 1 package with 1 available patch"), "the can-access summary counts all patches; got {stdout:?}" ); assert!( @@ -554,7 +593,7 @@ async fn scan_paid_patch_with_access_counts_all_and_reports_detail_failure() { "no nudge for a subscriber; got {stdout:?}" ); assert!( - stderr.contains("Could not fetch patch details."), + stderr.contains("Error: could not fetch patch details for"), "the terminal detail-failure must reach stderr; got {stderr:?}" ); } @@ -615,7 +654,7 @@ async fn scan_human_table_renders_update_marker_and_vuln_overflow() { "the human update marker must render; got {stdout:?}" ); assert!( - stdout.contains("1 package(s) have newer patches available."), + stdout.contains("1 package has a newer patch available."), "the newer-patches summary must print; got {stdout:?}" ); // Deterministic order: collect_vuln_ids sorts CVEs, so the first two @@ -625,16 +664,15 @@ async fn scan_human_table_renders_update_marker_and_vuln_overflow() { "3+ vuln ids must truncate to two plus (+N); got {stdout:?}" ); assert!( - stdout.contains("[dry-run] Would download and apply 1 patch(es). No changes made."), + stdout.contains("[dry-run] Would download and apply 1 patch. No changes made."), "dry-run must stop before the confirm; got {stdout:?}" ); } -/// The per-package detail-fetch warning on the non-silent human path (the -/// terminal "Could not fetch patch details." was previously reached only -/// via --silent runs, skipping the warning line). +/// When every detail fetch fails, the human path prints ONE Error line that +/// names the package and the cause (no per-package Warning repeating it). #[tokio::test] -async fn scan_human_detail_fetch_failure_warns_per_package() { +async fn scan_human_detail_fetch_failure_errors_once() { let mock = MockServer::start().await; let purl = "pkg:npm/minimist@1.2.2"; mount_batch_one(&mock, purl, UUID, "free", &[], false).await; @@ -654,12 +692,69 @@ async fn scan_human_detail_fetch_failure_warns_per_package() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); assert_eq!(code, 1, "stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains(&format!("Warning: could not fetch details for {purl}")), - "the per-package warning must name the purl on stderr; got {stderr:?}" + stderr.contains(&format!("Error: could not fetch patch details for {purl}: ")), + "the terminal error names the purl and the cause; got {stderr:?}" + ); + assert!( + !stderr.contains("Warning: could not fetch details"), + "a total failure must not repeat the cause as a Warning first; got {stderr:?}" + ); +} + +/// A partial detail-fetch failure warns per failed package on stderr and +/// carries on with the packages that did resolve. +#[tokio::test] +async fn scan_human_partial_detail_fetch_failure_warns_per_package() { + let mock = MockServer::start().await; + let ok = "pkg:npm/minimist@1.2.2"; + let bad = "pkg:npm/lodash@4.17.20"; + let body = serde_json::json!({ + "packages": [ + {"purl": ok, "patches": [{ + "uuid": UUID, "purl": ok, "tier": "free", "cveIds": [], + "ghsaIds": [], "severity": "high", "title": "t" + }]}, + {"purl": bad, "patches": [{ + "uuid": "33333333-3333-4333-8333-333333333333", "purl": bad, + "tier": "free", "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "t" + }]} + ], + "canAccessPaidPatches": false, + }); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&mock) + .await; + mount_by_package(&mock, ok, UUID, serde_json::json!({})).await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", + encode_purl(bad) + ))) + .respond_with(ResponseTemplate::new(500)) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + write_npm_package(tmp.path(), "lodash", "4.17.20", b"x\n"); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert_eq!( + stderr + .matches(&format!("Warning: could not fetch details for {bad}: ")) + .count(), + 1, + "one warning naming the failed purl; got {stderr:?}" ); + assert!(!stderr.contains("Error:"), "a partial failure is not an error: {stderr:?}"); assert!( - stderr.contains("Could not fetch patch details."), - "the terminal error follows the warning; got {stderr:?}" + stdout.contains("[dry-run] Would download and apply 1 patch."), + "the resolved package still goes through; got {stdout:?}" ); } @@ -703,9 +798,7 @@ async fn scan_human_skips_vendored_purls_without_downloading() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--yes"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( - stdout.contains(&format!( - "[skip] {purl} (vendored — run scan --vendor to update)" - )), + stdout.contains(&format!("[skip] {purl} (vendored; run `socket-patch scan --mode vendored` to update it)")), "the vendored skip line must name the purl and the remedy; got {stdout:?}" ); assert!( @@ -779,11 +872,11 @@ async fn scan_human_preview_renders_vulnerability_details() { "the per-vuln summary line carries its CVE label; got {stdout:?}" ); assert!( - stdout.contains("- no-cve issue"), - "a CVE-less vuln's summary prints without a label; got {stdout:?}" + stdout.contains("- GHSA-dddd-eeee-ffff: no-cve issue"), + "a CVE-less vuln's summary is labeled with its advisory id; got {stdout:?}" ); assert!( - stdout.contains("[dry-run] Would download and apply 1 patch(es). No changes made."), + stdout.contains("[dry-run] Would download and apply 1 patch. No changes made."), "dry-run stops before any mutation; got {stdout:?}" ); } @@ -1022,7 +1115,7 @@ fn scan_human_vex_success_prints_wrote_line() { ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( - stdout.contains("Wrote OpenVEX document with 1 statement(s) to"), + stdout.contains("Wrote OpenVEX document with 1 statement to"), "the human VEX success line must print; got {stdout:?}" ); assert!( @@ -1072,7 +1165,7 @@ async fn scan_human_pnp_refusal_prints_alongside_other_ecosystems() { "refusals never flip the exit; stdout={stdout}; stderr={stderr}" ); assert!( - stderr.contains("Found 1 packages"), + stderr.contains("Found 1 package ("), "the gem must be discovered (non-empty path); got {stderr:?}" ); assert!( @@ -1096,6 +1189,40 @@ async fn scan_human_pnp_refusal_prints_alongside_other_ecosystems() { ); } +// --------------------------------------------------------------------------- +// Empty batch response: the human result line +// --------------------------------------------------------------------------- + +/// One installed package, a batch response with no patches: the status +/// line finishes with exactly `No patches found for 1 package` (singular, +/// on its own line — no stale status tail). +#[tokio::test] +async fn scan_human_empty_batch_reports_no_patches_for_one_package() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .expect(1) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.5", b"module.exports = {};\n"); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr + .lines() + .any(|l| l == "No patches found for 1 package"), + "expected the exact result line; got {stderr:?}" + ); +} + // --------------------------------------------------------------------------- // Non-TTY human scans: the mode-less scan is report-only, explicit intent // auto-proceeds @@ -1130,7 +1257,7 @@ async fn scan_bare_human_non_tty_is_report_only() { "the per-patch preview still prints; got {stdout:?}" ); assert!( - stdout.contains("To apply a patch, run:") && stdout.contains("socket-patch get "), + stdout.contains("To apply a single patch, run:") && stdout.contains("socket-patch get "), "the get-hint must print; got {stdout:?}" ); assert!( @@ -1172,7 +1299,7 @@ async fn scan_human_non_tty_explicit_intent_auto_proceeds() { let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), flags); assert_eq!(code, 0, "flags={flags:?}: stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains("Non-interactive mode detected, proceeding with default."), + stderr.contains("Non-interactive mode detected, proceeding automatically."), "flags={flags:?}: explicit intent keeps confirm()'s non-TTY auto-accept; got {stderr:?}" ); assert_eq!( @@ -1200,7 +1327,7 @@ async fn scan_human_non_tty_prune_counts_as_intent() { .await; assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( - stderr.contains("Non-interactive mode detected, proceeding with default."), + stderr.contains("Non-interactive mode detected, proceeding automatically."), "--prune auto-proceeds through confirm(); got {stderr:?}" ); assert!( @@ -1336,22 +1463,22 @@ async fn scan_hosted_human_prints_table_updates_and_confirms() { "the results table must print in hosted mode; got {stdout:?}" ); assert!( - stdout.contains("Summary: 1 package(s) with 1 free patch(es)"), + stdout.contains("Summary: 1 package with 1 free patch"), "the summary must print in hosted mode; got {stdout:?}" ); assert!( stdout.contains("[UPDATE]") - && stdout.contains("1 package(s) have newer patches available."), + && stdout.contains("1 package has a newer patch available."), "update detection must run in hosted mode; got {stdout:?}" ); // `--mode hosted` is explicit intent: the new prompt auto-accepts on a // non-TTY stdin and the engine runs. assert!( - stderr.contains("Non-interactive mode detected, proceeding with default."), + stderr.contains("Non-interactive mode detected, proceeding automatically."), "the hosted confirm must run (and auto-accept) on a non-TTY; got {stderr:?}" ); assert!( - stdout.contains("Redirected 0 package(s)"), + stdout.contains("Redirected 0 packages"), "the engine must run after the prompt; got {stdout:?}" ); let reqs = recorded(&mock).await; @@ -1381,7 +1508,6 @@ async fn scan_hosted_human_prints_table_updates_and_confirms() { #[cfg(unix)] mod pty { use super::*; - use std::io::{Read, Write}; use std::time::Duration; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; @@ -1390,6 +1516,20 @@ mod pty { /// until exit (the `interactive_prompts_e2e.rs` harness pattern: /// reader thread + kill-after-timeout watchdog, no polling). fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) { + run_in_pty_with(args, cwd, &[], "", input, timeout) + } + + /// [`run_in_pty`] with extra child `env`, first writing `typeahead` + /// straight after spawn — keystrokes a user types while the scan is + /// still running, before any prompt is on screen. + fn run_in_pty_with( + args: &[&str], + cwd: &Path, + env: &[(&str, &str)], + typeahead: &str, + input: &str, + timeout: Duration, + ) -> (i32, String) { let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -1417,16 +1557,16 @@ mod pty { } cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); + for (k, v) in env { + cmd.env(k, v); + } let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -1435,14 +1575,18 @@ mod pty { }); let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input.as_bytes()); - let _ = writer.flush(); + if !typeahead.is_empty() { + use std::io::Write as _; + let _ = writer.write_all(typeahead.as_bytes()); + let _ = writer.flush(); + } + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); drop(writer); let status = child.wait().expect("child.wait"); drop(pair.master); - let output = reader_handle.join().expect("reader thread join"); + let output = reader_handle.finish(); ( status.exit_code() as i32, String::from_utf8_lossy(&output).to_string(), @@ -1485,11 +1629,11 @@ mod pty { // The prompt genuinely ran (a regression auto-proceeding in a TTY // would skip it — and would mutate, failing below too). assert!( - output.contains("Download and apply 1 patch(es)?"), + output.contains("Download and apply 1 patch?"), "the confirm prompt must have shown; got:\n{output}" ); assert!( - output.contains("To apply a patch, run:"), + output.contains("To apply a single patch, run:"), "the decline hint must print; got:\n{output}" ); assert!( @@ -1511,7 +1655,341 @@ mod pty { assert_eq!(view_gets(&reqs), 0, "declining must not download the patch"); } - /// The hosted twin: declining "Redirect N package(s) …?" exits 0 with + /// Declining the vendored-mode prompt points at the vendored `get`, + /// not the in-place one (which would apply instead of vendoring). + #[tokio::test(flavor = "multi_thread")] + async fn scan_vendored_decline_hint_names_vendored_get() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--mode", + "vendored", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "n\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + + assert_eq!(code, 0, "declining is not an error; output:\n{output}"); + assert!( + output.contains("Download and vendor 1 patch?"), + "the vendored prompt must have shown; got:\n{output}" + ); + assert!( + output.contains("To vendor a single patch, run:") + && output.contains("socket-patch get --mode vendored"), + "the decline hint must name the vendored get; got:\n{output}" + ); + assert!( + !output.contains("To apply a single patch"), + "no agent-mode hint in vendored mode; got:\n{output}" + ); + } + + /// `scan --json` on a real terminal must never open the interactive + /// "Select one" menu over its machine-read output: with several free + /// patches for one package it picks the top-ranked one, like a non-TTY + /// run, and finishes on its own. + #[tokio::test(flavor = "multi_thread")] + async fn scan_json_on_a_tty_never_opens_the_select_menu() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let second = "22222222-2222-4222-8222-222222222222"; + let body = serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [ + { "uuid": UUID, "purl": purl, "tier": "free", "cveIds": ["CVE-2024-1"], + "ghsaIds": [], "severity": "high", "title": "a" }, + { "uuid": second, "purl": purl, "tier": "free", "cveIds": ["CVE-2024-2"], + "ghsaIds": [], "severity": "high", "title": "b" }, + ] + }], + "canAccessPaidPatches": false, + }); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&mock) + .await; + let patch = |uuid: &str, published: &str| { + serde_json::json!({ + "uuid": uuid, "purl": purl, "publishedAt": published, + "description": "d", "license": "MIT", "tier": "free", + "vulnerabilities": {}, + }) + }; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{}", + encode_purl(purl) + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [patch(UUID, "2024-01-01T00:00:00Z"), patch(second, "2025-01-01T00:00:00Z")], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--json", + "--mode", + "agent", + "--dry-run", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "", + Duration::from_secs(30), + ) + }) + .await + .expect("spawn_blocking join"); + + assert!( + !output.contains("Select one") && !output.contains("Multiple patches available"), + "no interactive menu under --json; got:\n{output}" + ); + assert_eq!(code, 0, "the run must finish on its own; output:\n{output}"); + // The pty merges stdout and stderr; the envelope is the only `{…}`. + let text = output.replace("\r\n", "\n"); + let json_text = &text[text.find('{').expect("JSON envelope") + ..=text.rfind('}').expect("JSON envelope end")]; + let json: serde_json::Value = serde_json::from_str(json_text) + .unwrap_or_else(|e| panic!("envelope must parse ({e}); got:\n{output}")); + assert_eq!(json["status"], "success", "{json}"); + let planned = json["apply"]["patches"] + .as_array() + .unwrap_or_else(|| panic!("apply.patches must be an array: {json}")); + assert_eq!(planned.len(), 1, "exactly one patch selected: {json}"); + assert_eq!( + planned[0]["uuid"], second, + "the top-ranked (newer) patch is picked, not the first listed: {json}" + ); + } + + /// The live status line on a real terminal: every progress message is + /// replaced (never overwritten in place), so what the user sees is the + /// result lines only — no "Found 1 patch for 1 packagesatch 1/1)" + /// residue from the longer "Querying API ... (batch 1/1)" line. + #[tokio::test(flavor = "multi_thread")] + async fn scan_progress_on_a_tty_renders_without_stale_tail() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty( + &[ + "scan", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + "n\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + assert_eq!(code, 0, "output:\n{output}"); + + // The raw stream really used the live line (so this test would + // catch a regression to bare `\r` rewrites)... + assert!( + output.contains("\r\x1b[2KQuerying API for patches... (batch 1/1)"), + "expected a live status update; raw={output:?}" + ); + // ...and the screen shows only clean result lines. + let screen = crate::pty_io::render(output.as_bytes()); + assert!( + screen.iter().any(|l| l == "Found 1 package (1 npm)"), + "screen:\n{}", + screen.join("\n") + ); + assert!( + screen.iter().any(|l| l == "Found 1 patch for 1 package"), + "screen:\n{}", + screen.join("\n") + ); + for transient in ["Scanning packages", "Querying API", "Fetching patch details"] { + assert!( + !screen.iter().any(|l| l.contains(transient)), + "transient status {transient:?} must not stay on screen:\n{}", + screen.join("\n") + ); + } + } + + /// Keystrokes typed while the scan is still querying the API must not + /// answer the default-yes download prompt: `confirm` discards + /// typeahead before showing it. Without the flush, the early "n\n" + /// would decline; with it, the Enter sent at the prompt takes the + /// default (yes) and the patch is applied. + #[tokio::test(flavor = "multi_thread")] + async fn scan_typeahead_before_the_prompt_is_discarded() { + let mock = MockServer::start().await; + let purl = "pkg:npm/typeahead-target@1.0.0"; + let before = b"before\n"; + // The batch answers late, so the early "n\n" is certainly sitting + // in the terminal's input queue before the prompt appears. + mount_one_patch_api_delayed(&mock, purl, before, Duration::from_millis(1500)).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "typeahead-target", "1.0.0", before); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty_with( + &[ + "scan", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + &[], + "n\n", + "\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + + assert_eq!(code, 0, "output:\n{output}"); + assert!( + output.contains("Download and apply 1 patch? [Y/n] "), + "the default-yes prompt must have shown; got:\n{output}" + ); + assert!( + !output.contains("To apply a single patch, run:"), + "the early \"n\" must not have declined the prompt; got:\n{output}" + ); + assert_eq!( + std::fs::read(tmp.path().join("node_modules/typeahead-target/index.js")).unwrap(), + b"after\n", + "the Enter at the prompt takes the default and applies; got:\n{output}" + ); + } + + /// Under `SOCKET_DEBUG` core prints `[socket-patch debug] ...` lines + /// straight to stderr. The live status line is off then, so those + /// lines never land glued onto the end of a progress message. + #[tokio::test(flavor = "multi_thread")] + async fn scan_debug_mode_on_a_tty_keeps_debug_lines_off_the_status() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let uri = mock.uri(); + let cwd = tmp.path().to_path_buf(); + let (code, output) = tokio::task::spawn_blocking(move || { + run_in_pty_with( + &[ + "scan", + "--api-url", + &uri, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ], + &cwd, + &[("SOCKET_DEBUG", "1")], + "", + "n\n", + Duration::from_secs(60), + ) + }) + .await + .expect("spawn_blocking join"); + assert_eq!(code, 0, "output:\n{output}"); + + let screen = crate::pty_io::render(output.as_bytes()); + let debug: Vec<&String> = screen + .iter() + .filter(|l| l.contains("[socket-patch debug]")) + .collect(); + assert!( + !debug.is_empty(), + "SOCKET_DEBUG must produce debug lines; screen:\n{}", + screen.join("\n") + ); + for line in debug { + assert!( + line.starts_with("[socket-patch debug]"), + "a debug line was glued onto other output: {line:?}" + ); + } + assert!( + !output.contains("Querying API for patches..."), + "no transient status is drawn in debug mode; raw={output:?}" + ); + assert!( + screen.iter().any(|l| l == "Found 1 patch for 1 package"), + "the result lines still print; screen:\n{}", + screen.join("\n") + ); + } + + /// The hosted twin: declining "Redirect N packages …?" exits 0 with /// the hosted get-hint and never enters the engine (no reference /// resolve, no `.socket/`). #[tokio::test(flavor = "multi_thread")] @@ -1551,7 +2029,7 @@ mod pty { assert_eq!(code, 0, "declining is not an error; output:\n{output}"); assert!( - output.contains("Redirect 1 package(s) to the hosted patch server?"), + output.contains("Redirect 1 package to the hosted patch server?"), "the hosted confirm prompt must have shown; got:\n{output}" ); assert!( @@ -1749,7 +2227,7 @@ async fn scan_human_vendored_dry_run_names_would_refuse_records() { "a preview never flips the exit; stdout={stdout}; stderr={stderr}" ); assert!( - stdout.contains("[dry-run] Would download and vendor 1 patch(es). No changes made."), + stdout.contains("[dry-run] Would download and vendor 0 of 1 patch (1 would be refused). No changes made."), "the count line stays; got {stdout:?}" ); assert!( @@ -1782,3 +2260,218 @@ async fn scan_human_vendored_dry_run_names_would_refuse_records() { "silent dry run prints nothing:\n{stdout}" ); } + +// --------------------------------------------------------------------------- +// Human-path wording and flow fixes (terminal UI polish) +// --------------------------------------------------------------------------- + +/// Mount a batch endpoint that finds no patches at all. +async fn mount_empty_batch(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; +} + +/// `scan --prune` with nothing to apply still garbage-collects, like the +/// JSON path (it used to return early and silently skip the GC), and a +/// `--dry-run` previews it without touching the manifest. +#[tokio::test] +async fn scan_human_prune_runs_gc_even_when_no_patches_are_available() { + let mock = MockServer::start().await; + mount_empty_batch(&mock).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.5", b"x\n"); + seed_manifest(tmp.path(), &[("pkg:npm/gone@1.0.0", OLD_UUID)]); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune", "--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("[dry-run] GC would prune 1 manifest entry and remove 0 orphan files"), + "the dry run previews the GC; got {stdout:?}" + ); + let manifest = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + assert!(manifest.contains("pkg:npm/gone@1.0.0"), "a preview must not prune"); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--prune"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains("No patches available for installed packages."), + "{stdout:?}" + ); + assert!( + stdout.contains("GC: pruned 1 manifest entry and removed 0 orphan files"), + "the GC must run on the early exit; got {stdout:?}" + ); + let manifest = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")).unwrap(); + assert!(!manifest.contains("pkg:npm/gone@1.0.0"), "{manifest}"); +} + +/// An empty crawl never prunes (too destructive), but says so instead of +/// silently dropping `--prune`; `--silent` keeps it quiet. +#[test] +fn scan_prune_on_empty_crawl_warns_the_gc_was_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--prune"]); + assert_eq!(code, 0, "stderr={stderr:?}"); + assert!( + stderr.contains("Warning: --prune skipped: no installed packages were found"), + "{stderr:?}" + ); + assert!(stdout.contains("No packages found."), "{stdout:?}"); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--prune", "--silent"]); + assert_eq!(code, 0); + assert!(stdout.is_empty() && stderr.is_empty(), "{stdout:?} {stderr:?}"); +} + +/// `--ecosystems` that filters everything out names the filter instead of +/// telling the user to run `cargo install`/`go install`. +#[test] +fn scan_empty_after_ecosystem_filter_names_the_filter() { + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.5", b"x\n"); + let (code, stdout, stderr) = run_scan(tmp.path(), &["-e", "pypi"]); + assert_eq!(code, 0, "stderr={stderr:?}"); + assert!(stdout.contains("No pypi packages found."), "{stdout:?}"); +} + +/// Global installs have no project lockfile: `--mode hosted --global` is +/// a usage error, not a silent "redirected 0 packages". +#[test] +fn scan_hosted_rejects_global() { + let tmp = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--global"]); + assert_eq!(code, 2, "stderr={stderr:?}"); + assert!( + stderr.starts_with( + "Error: --global cannot be used with --mode hosted: global installs have no \ + project lockfile to redirect" + ), + "{stderr:?}" + ); + assert!(stdout.is_empty()); + // Like every usage error (and clap's own), no JSON envelope under --json. + let (code, stdout, _) = run_scan(tmp.path(), &["--mode", "hosted", "--global", "--json"]); + assert_eq!(code, 2); + assert!(stdout.trim().is_empty(), "{stdout:?}"); + let prefix = tempfile::tempdir().unwrap(); + let (code, _, stderr) = run_scan( + tmp.path(), + &[ + "--mode", + "hosted", + "--global-prefix", + prefix.path().to_str().unwrap(), + ], + ); + assert_eq!(code, 2); + assert!( + stderr.starts_with("Error: --global-prefix cannot be used with --mode hosted"), + "{stderr:?}" + ); +} + +/// Usage errors from scan's own flag checks use the capitalized `Error:` +/// prefix like every other error line. +#[test] +fn scan_mode_conflict_error_is_capitalized_and_names_no_hidden_flag() { + let tmp = tempfile::tempdir().unwrap(); + let (code, _, stderr) = run_scan(tmp.path(), &["--mode", "hosted", "--vendor"]); + assert_eq!(code, 2); + assert!( + stderr.starts_with("Error: --mode hosted cannot be used with --vendor"), + "{stderr:?}" + ); + assert!(!stderr.contains("--redirect"), "{stderr:?}"); + // Typing the hidden --redirect gets it explained. + let (code, _, stderr) = run_scan(tmp.path(), &["--mode", "agent", "--redirect"]); + assert_eq!(code, 2); + assert!( + stderr.starts_with( + "Error: --mode agent cannot be used with --redirect: the flags select \ + different modes (--redirect means --mode hosted)" + ), + "{stderr:?}" + ); + let (code, _, stderr) = run_scan(tmp.path(), &["--detached"]); + assert_eq!(code, 2); + assert!( + stderr.starts_with("Error: --detached requires vendored mode"), + "{stderr:?}" + ); +} + +/// A selection the manifest already records at the same uuid is not +/// offered again (it would only be downloaded to be skipped). +#[tokio::test] +async fn scan_human_does_not_offer_an_already_recorded_patch() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + seed_manifest(tmp.path(), &[(purl, UUID)]); + + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains(&format!("[skip] {purl} (already recorded: 11111111)")), + "{stdout:?}" + ); + assert!( + stdout.contains("All selected patches are already recorded in the manifest"), + "{stdout:?}" + ); + assert!(!stdout.contains("Patches to apply:"), "{stdout:?}"); + assert!(!stderr.contains("Download and apply"), "{stderr:?}"); + assert_eq!(view_gets(&recorded(&mock).await), 0, "nothing is downloaded"); +} + +/// The human table's PACKAGE column grows to fit the PURL (the old fixed +/// 40-column cut dropped the version), and the rule matches the table. +#[tokio::test] +async fn scan_human_table_shows_full_purl_with_version() { + let mock = MockServer::start().await; + let purl = "pkg:npm/@typescript-eslint/typescript-estree@6.0.0"; + mount_batch_one(&mock, purl, UUID, "free", &["CVE-2024-1"], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + let pkg_dir = tmp + .path() + .join("node_modules/@typescript-eslint/typescript-estree"); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + r#"{ "name": "@typescript-eslint/typescript-estree", "version": "6.0.0" }"#, + ) + .unwrap(); + let (code, stdout, stderr) = run_scan_human(tmp.path(), &mock.uri(), &["--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let row = stdout + .lines() + .find(|l| l.contains("CVE-2024-1") && l.starts_with("pkg:npm/")) + .unwrap_or_else(|| panic!("no table row in {stdout:?}")); + assert!(row.starts_with(&format!("{purl} ")), "{row:?}"); + let header = stdout.lines().find(|l| l.starts_with("PACKAGE")).unwrap(); + // The right-aligned count ends where the PATCHES header ends. + assert_eq!( + header.find("PATCHES").map(|i| i + "PATCHES".len()), + row.find(" 1 ").map(|i| i + 2), + "PATCHES header sits over the count: {stdout}" + ); + // The rule is exactly as wide as the widest table line. + let rule = stdout.lines().find(|l| l.starts_with("===")).unwrap(); + assert_eq!(rule.len(), header.len().max(row.len()), "{stdout}"); +} diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs index 1b92e9ef..b98c05ab 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_vendor_flow.rs @@ -644,7 +644,7 @@ async fn scan_vendor_staging_error_interactive_prints_error_line() { ); assert!( stderr.contains( - "Error (no_local_source): patch artifacts unavailable (offline or download failure)" + "Error (no_local_source): Patch artifacts unavailable (offline or download failure)." ), "the human arm must name the code and message on stderr; \ stdout={stdout}; stderr={stderr}" diff --git a/crates/socket-patch-cli/tests/covgap_commands_setup.rs b/crates/socket-patch-cli/tests/covgap_commands_setup.rs index 066edf7a..7ebc1d8b 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_setup.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_setup.rs @@ -8,6 +8,8 @@ //! default (non-`setup-e2e`) test configuration never compiles — these //! always-on ports are what actually count for coverage. +#[path = "common/pty_io.rs"] +mod pty_io; use std::path::Path; #[path = "common/mod.rs"] @@ -577,7 +579,7 @@ fn vex_drops_all_patches_when_projects_present_but_unwired() { } // --------------------------------------------------------------------------- -// confirm_proceed's non-TTY branch (180-181): piped stdin, no --yes, no +// ui::confirm_or_proceed's non-TTY branch: piped stdin, no --yes, no // --json — the normal CI shape. Auto-proceeds with a stderr note. // --------------------------------------------------------------------------- @@ -588,7 +590,7 @@ fn setup_non_tty_auto_proceeds_without_yes() { write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); // `Command::output()` (inside the shared runner) closes the child's - // stdin, so stdin_is_tty() is false. + // stdin, so stdin is not a terminal. let (code, stdout, stderr) = run(cwd, &["setup"]); assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( @@ -606,11 +608,10 @@ fn setup_non_tty_auto_proceeds_without_yes() { } #[test] -fn setup_non_tty_auto_proceed_note_prints_under_silent() { - // Documents the CURRENT contract: `--silent` mutes the human report but - // prompting (and therefore the non-TTY auto-proceed note) follows the - // shared confirm semantics unchanged — the note still reaches stderr. - // If the contract is later tightened to mute it, flip this assertion. +fn setup_non_tty_auto_proceed_note_is_muted_under_silent() { + // `--silent` is errors-only: the non-TTY auto-proceed note is + // informational, so it is muted like the rest of the human report — + // while the run still proceeds and wires the hook. let tmp = tempfile::tempdir().expect("tempdir"); let cwd = tmp.path(); write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); @@ -622,9 +623,8 @@ fn setup_non_tty_auto_proceed_note_prints_under_silent() { "--silent must mute stdout; got: {stdout:?}" ); assert!( - stderr.contains("Non-interactive mode detected"), - "current contract: the auto-proceed note is confirm-flow output, not \ - muted by --silent; stderr=\n{stderr}" + !stderr.contains("Non-interactive mode detected"), + "--silent must mute the auto-proceed note; stderr=\n{stderr}" ); assert!( read(&cwd.join("package.json")).contains("socket-patch"), @@ -639,7 +639,6 @@ fn setup_non_tty_auto_proceed_note_prints_under_silent() { #[cfg(unix)] mod pty { - use std::io::{Read, Write}; use std::path::Path; use std::time::Duration; @@ -682,12 +681,9 @@ mod pty { let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -696,13 +692,12 @@ mod pty { }); let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input.as_bytes()); - let _ = writer.flush(); + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); drop(writer); let status = child.wait().expect("child.wait"); drop(pair.master); - let output = reader_handle.join().expect("reader join"); + let output = reader_handle.finish(); ( status.exit_code() as i32, String::from_utf8_lossy(&output).to_string(), @@ -730,7 +725,7 @@ fn remove_interactive_decline_aborts_without_change() { "declining the remove must exit cleanly; got: {output}" ); assert!( - output.contains("Remove these install hooks? (y/N):"), + output.contains("Remove these install hooks? [y/N]"), "the interactive remove confirm must have been shown; got: {output}" ); assert!( @@ -847,10 +842,16 @@ fn setup_exclude_in_empty_dir_writes_no_socket_dir() { fn setup_exclude_persists_when_hooks_are_already_configured() { let tmp = tempfile::tempdir().expect("tempdir"); let cwd = tmp.path(); + // A real `packages/b` member: an `--exclude` that matches nothing is + // dropped before persistence (a typo must not be persisted). write( &cwd.join("package.json"), - &format!("{{ \"name\": \"root\", \"version\": \"1.0.0\", {WIRED_SCRIPTS_FRAGMENT} }}"), + &format!( + "{{ \"name\": \"root\", \"version\": \"1.0.0\", \"workspaces\": [\"packages/*\"], \ + {WIRED_SCRIPTS_FRAGMENT} }}" + ), ); + write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); let (code, v) = run_json( cwd, @@ -901,7 +902,13 @@ fn setup_exclude_write_failure_surfaces_persist_warning() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::tempdir().expect("tempdir"); let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); + // A real `packages/b` member: an `--exclude` that matches nothing is + // dropped before persistence and never reaches the write. + write( + &cwd.join("package.json"), + r#"{ "name": "root", "workspaces": ["packages/*"] }"#, + ); + write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); let manifest_path = cwd.join(".socket/manifest.json"); let original = r#"{"patches":{}}"#; write(&manifest_path, original); @@ -1173,7 +1180,9 @@ fn check_human_report_renders_needs_and_error_lines() { ); assert!( stdout.contains( - "1 manifest(s) need configuration, 1 error(s). Run `socket-patch setup` to fix." + "1 manifest needs configuration, 1 error. Run `socket-patch setup` to add the \ + missing install hooks. Fix the errors above, then re-run `socket-patch setup \ + --check`." ), "the summary must count needs and errors; stdout=\n{stdout}" ); @@ -1283,7 +1292,7 @@ fn remove_human_dry_run_summary_renders_both_removed_forms() { "a deleted lifecycle key must render as (removed); stdout=\n{stdout}" ); assert!( - stdout.contains("1 item(s) would have socket-patch removed"), + stdout.contains("1 item would have socket-patch removed"), "the human dry-run summary must count the pending removals; stdout=\n{stdout}" ); assert_eq!( @@ -1381,7 +1390,7 @@ fn remove_human_write_stage_error_counts_and_exits_nonzero() { assert_eq!(code, 1, "a failed write must exit 1; stdout=\n{stdout}"); assert!( - stdout.contains("1 error(s)"), + stdout.contains(" 1 error\n"), "the human summary must count the write failure; stdout=\n{stdout}" ); assert!( @@ -1538,7 +1547,7 @@ fn setup_human_preview_counts_already_configured() { "the wired root must be counted as a skip; stdout=\n{stdout}" ); assert!( - stdout.contains("1 item(s) updated"), + stdout.contains("1 item updated"), "the unwired member must still be updated; stdout=\n{stdout}" ); assert!( @@ -1554,18 +1563,33 @@ fn setup_human_preview_counts_already_configured() { fn setup_human_summary_surfaces_persist_warning() { let tmp = tempfile::tempdir().expect("tempdir"); let cwd = tmp.path(); - write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); + // A real `packages/b` member: an `--exclude` that matches nothing is + // dropped before persistence and never reaches the fail-closed read. + write( + &cwd.join("package.json"), + r#"{ "name": "root", "workspaces": ["packages/*"] }"#, + ); + write(&cwd.join("packages/b/package.json"), UNWIRED_PACKAGE_JSON); let corrupt = "not json {{{"; write(&cwd.join(".socket/manifest.json"), corrupt); - let (code, stdout, _stderr) = run(cwd, &["setup", "--yes", "--exclude", "packages/b"]); + let (code, stdout, stderr) = run(cwd, &["setup", "--yes", "--exclude", "packages/b"]); assert_eq!( code, 0, "the skip is a warning, not an error; stdout=\n{stdout}" ); assert!( - stdout.contains("warning: not persisting --exclude"), - "the human summary must surface the fail-closed persistence skip; stdout=\n{stdout}" + stderr.contains("Warning: Not persisting --exclude"), + "stderr must surface the fail-closed persistence skip; stderr=\n{stderr}" + ); + assert_eq!( + stderr.matches("Not persisting --exclude").count(), + 1, + "reported once, not again in the summary; stderr=\n{stderr}" + ); + assert!( + !stdout.to_lowercase().contains("not persisting"), + "warnings stay off stdout; stdout=\n{stdout}" ); assert_eq!( read(&cwd.join(".socket/manifest.json")), @@ -1597,11 +1621,11 @@ fn setup_human_summary_counts_errors() { assert_eq!(code, 1, "a partial failure must exit 1; stdout=\n{stdout}"); assert!( - stdout.contains("1 item(s) updated"), + stdout.contains("1 item updated"), "the readable root must still be updated; stdout=\n{stdout}" ); assert!( - stdout.contains("1 error(s)"), + stdout.contains(" 1 error\n"), "the human summary must count the unreadable member; stdout=\n{stdout}" ); } @@ -1640,8 +1664,8 @@ fn remove_human_surfaces_poetry_lock_refresh_warning() { "a failed lock refresh is a warning, not an error; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("warning: could not run `poetry"), - "the human summary must warn that the refresh could not run; stdout=\n{stdout}" + stderr.contains("Warning: Could not run `poetry"), + "stderr must warn that the refresh could not run; stderr=\n{stderr}" ); // The edit itself must still have happened: the hook extra is gone. (For // the classic-Poetry inline-table form, current remove semantics strip @@ -1680,11 +1704,73 @@ fn remove_json_surfaces_poetry_lock_refresh_warning() { ); } +/// `--yes` shows no prompt, so the prompt separator must not stack on the +/// blank line that opens "Applying changes..." / "Removing install hooks...". +#[test] +fn setup_and_remove_with_yes_print_no_double_blank_line() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cwd = tmp.path(); + write(&cwd.join("package.json"), UNWIRED_PACKAGE_JSON); + + let (code, stdout, stderr) = run(cwd, &["setup", "--yes"]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stderr.contains("\nApplying changes..."), + "stderr=\n{stderr}" + ); + assert!(!stderr.contains("\n\n\n"), "stderr=\n{stderr:?}"); + + let (code, stdout, stderr) = run(cwd, &["setup", "--remove", "--yes"]); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stderr.contains("\nRemoving install hooks..."), + "stderr=\n{stderr}" + ); + assert!(!stderr.contains("\n\n\n"), "stderr=\n{stderr:?}"); +} + +/// A mistyped `--exclude` is warned about and NOT persisted, so later runs +/// and clones do not inherit the typo; a matching value in the same run is. +#[test] +fn setup_does_not_persist_an_unmatched_exclude() { + let tmp = tempfile::tempdir().expect("tempdir"); + let cwd = tmp.path(); + write( + &cwd.join("package.json"), + r#"{ "name": "root", "workspaces": ["packages/*"] }"#, + ); + write(&cwd.join("packages/a/package.json"), UNWIRED_PACKAGE_JSON); + + let (code, stdout, stderr) = run( + cwd, + &["setup", "--yes", "--exclude", "nope", "--exclude", "packages/a"], + ); + assert_eq!(code, 0, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert_eq!( + stderr + .matches("Warning: --exclude \"nope\" matched no workspace member") + .count(), + 1, + "stderr=\n{stderr}" + ); + let manifest: serde_json::Value = + serde_json::from_str(&read(&cwd.join(".socket/manifest.json"))).expect("manifest json"); + assert_eq!( + manifest["setup"]["exclude"], + serde_json::json!(["packages/a"]), + "only the matching exclude is persisted" + ); + assert!( + !read(&cwd.join("packages/a/package.json")).contains("socket-patch"), + "the excluded member stays unwired" + ); +} + // ───────────── --check: unreadable vendor ledger on a manifest-free project ───────────── /// Contract §5: `--check` (property 4) reads the vendor ledger even without a /// manifest, and a ledger it cannot read or parse is surfaced as the -/// `Warning: unreadable vendor state (…)` line plus a `vendor_ledger` error +/// `Warning: Unreadable vendor state (…)` line plus a `vendor_ledger` error /// entry — verdict `error`, exit 1 — never as a `configured` verdict. The /// manifest-free vendored project (the only `scan`/`get --mode vendored` /// posture) is exactly where the corrupt ledger used to be swallowed: hooks @@ -1731,7 +1817,7 @@ fn check_reports_an_unreadable_vendor_ledger_instead_of_configured() { ); let (_code, _stdout, stderr) = run(cwd, &["setup", "--check", "--json"]); assert!( - stderr.contains("unreadable vendor state") && stderr.contains("corrupt"), + stderr.contains("Warning: Unreadable vendor state") && stderr.contains("corrupt"), "the contract's warning line reaches stderr; stderr=\n{stderr}" ); @@ -1739,11 +1825,11 @@ fn check_reports_an_unreadable_vendor_ledger_instead_of_configured() { let (code, stdout, stderr) = run(cwd, &["setup", "--check"]); assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); assert!( - stdout.contains(".socket/vendor/state.json") && stdout.contains("1 error(s)"), + stdout.contains(".socket/vendor/state.json") && stdout.contains("1 error."), "stdout=\n{stdout}" ); assert!( - stderr.contains("unreadable vendor state"), + stderr.contains("Warning: Unreadable vendor state"), "stderr=\n{stderr}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_update.rs b/crates/socket-patch-cli/tests/covgap_commands_update.rs index 7f572011..d0b7b7ea 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_update.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_update.rs @@ -10,6 +10,8 @@ //! self_update_e2e.rs / interactive_prompts_e2e.rs (do not edit those //! files). +#[path = "common/pty_io.rs"] +mod pty_io; #[path = "common/mod.rs"] mod common; #[path = "common/update_fixture.rs"] @@ -205,7 +207,7 @@ async fn update_dry_run_human_reports_update_available() { } // --------------------------------------------------------------------------- -// Interactive decline (update.rs:251-255) — PTY-driven: output::confirm +// Interactive decline (update.rs:251-255) — PTY-driven: ui::confirm // auto-proceeds with default-yes on non-TTY stdin (and under --yes/--json), // so only a real terminal reaches the cancel branch. Runner copied from // interactive_prompts_e2e.rs (do not edit that file), adapted to spawn the @@ -218,7 +220,6 @@ async fn update_dry_run_human_reports_update_available() { mod pty { use super::*; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; - use std::io::{Read, Write}; use std::path::Path; use std::time::Duration; @@ -274,12 +275,9 @@ mod pty { let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -288,13 +286,12 @@ mod pty { }); let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input.as_bytes()); - let _ = writer.flush(); + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input.as_bytes()); drop(writer); let status = child.wait().expect("child.wait"); drop(pair.master); - let output = reader_handle.join().expect("reader thread join"); + let output = reader_handle.finish(); ( status.exit_code() as i32, String::from_utf8_lossy(&output).to_string(), @@ -331,7 +328,8 @@ mod pty { // auto-proceeds (which the intact-binary check below would catch // only by accident of the dead endpoint). assert!( - output.contains(&format!("Update socket-patch {CURRENT} \u{2192} {CURRENT}?")), + // Pinned to the running version + --force: a reinstall. + output.contains(&format!("Reinstall socket-patch {CURRENT}?")), "update must have shown the interactive confirm prompt; got: {output}" ); assert!( @@ -339,15 +337,15 @@ mod pty { "update must NOT have taken the non-TTY auto-proceed branch in a PTY; got: {output}" ); assert!( - output.contains("Update cancelled."), - "'n' must report cancellation; got: {output}" + output.contains("Reinstall cancelled."), + "'n' must report cancellation, naming the reinstall; got: {output}" ); assert_eq!( code, 1, "a declined update exits 1 (codebase convention); got: {output}" ); assert!( - !output.contains("Updated socket-patch"), + !output.contains("Updated socket-patch") && !output.contains("Reinstalled socket-patch"), "a declined update must not report a swap; got: {output}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs index a1b2e5f7..a948b005 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vendor.rs @@ -597,7 +597,7 @@ fn human_vendor_prints_summary_committables_and_reinstall_hint() { let (code, stdout, stderr) = human_vendor(&fx, &[]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("Vendored 1 package(s); 0 skipped; 0 failed."), + stdout.contains("Vendored 1 package."), "summary line: {stdout}" ); assert!( @@ -611,20 +611,18 @@ fn human_vendor_prints_summary_committables_and_reinstall_hint() { } /// Human `--dry-run`: the `Would vendor` verb and NO commit/reinstall -/// hints (nothing was written). NOTE the pinned count: a dry-run success -/// is translated to a `Verified` event (counted under `summary.verified`, -/// not `applied`), while the human line prints `summary.applied` — so a -/// would-vendor package prints as `Would vendor 0`; the JSON cross-check -/// below anchors where the package actually lands. A future fix that -/// prints the verified count must consciously update this pin. +/// hints (nothing was written). A dry-run success is translated to a +/// `Verified` event (counted under `summary.verified`, not `applied`); the +/// human line counts it as a would-vendor package, and the JSON +/// cross-check below anchors where the package actually lands. #[test] fn human_dry_run_prints_would_vendor_and_no_commit_hints() { let fx = npm_fixture(); let (code, stdout, stderr) = human_vendor(&fx, &["--dry-run"]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("Would vendor 0 package(s); 0 skipped; 0 failed."), - "dry-run verb (applied stays 0 — the success is a Verified event): {stdout}" + stdout.contains("Would vendor 1 package."), + "dry-run verb, counting the Verified preview event: {stdout}" ); let (json_code, env) = vendor_cli(fx.root(), &["--dry-run"]); assert_eq!(json_code, 0, "{env:#}"); @@ -660,7 +658,7 @@ fn human_not_installed_prints_cannot_vendor_to_stderr() { "stderr carries the on-disk cause: {stderr}" ); assert!( - stdout.contains("Vendored 1 package(s); 1 skipped; 0 failed."), + stdout.contains("Vendored 1 package; 1 not installed."), "summary counts the skip: {stdout}" ); } @@ -674,7 +672,7 @@ async fn human_revert_prints_reverted_summary() { let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("Reverted 1 vendored package(s); 0 failed."), + stdout.contains("Reverted 1 vendored package."), "revert summary: {stdout}" ); assert!( @@ -684,8 +682,8 @@ async fn human_revert_prints_reverted_summary() { assert_eq!(fx.lock_bytes(), fx.original_lock, "lock restored"); } -/// Human `--revert` over a drifted lock: `Reverted 0 …` plus the -/// `Kept N drifted package(s)` explainer (counts come from the drift-skip +/// Human `--revert` over a drifted lock: no `Reverted …` line, only the +/// `Kept 1 drifted package` explainer (counts come from the drift-skip /// keep, not advisory warnings). #[tokio::test] async fn human_revert_drift_keep_prints_kept_explainer() { @@ -702,11 +700,11 @@ async fn human_revert_drift_keep_prints_kept_explainer() { let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); assert_eq!(code, 0, "a drift keep is not an error:\n{stdout}\n{stderr}"); assert!( - stdout.contains("Reverted 0 vendored package(s); 0 failed."), - "nothing reverted: {stdout}" + !stdout.contains("Reverted"), + "nothing reverted, so no package line: {stdout}" ); assert!( - stdout.contains("Kept 1 drifted package(s)"), + stdout.contains("Kept 1 drifted package:"), "the drift-keep explainer: {stdout}" ); assert!(fx.tgz_path().is_file(), "kept artifacts survive"); @@ -1054,7 +1052,7 @@ fn human_corrupt_manifest_prints_could_not_read() { let (code, stdout, stderr) = human_vendor(&fx, &[]); assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stderr.contains("Error: could not read manifest:"), + stderr.contains("Error: Could not read manifest:"), "the human explanation for the flipped exit code: {stderr}" ); assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); @@ -1081,7 +1079,7 @@ async fn human_corrupt_committed_artifact_prints_repair_hint() { "the human line must carry the repair remedy: {stderr}" ); assert!( - stdout.contains("Vendored 0 package(s); 0 skipped; 1 failed."), + stdout.contains("Vendored 0 packages; 1 failed."), "the summary counts the failure: {stdout}" ); } @@ -1123,7 +1121,7 @@ async fn human_fetch_failure_prints_fetch_failed() { "the human fetch-failure line: {stderr}" ); assert!( - stdout.contains("Vendored 0 package(s); 0 skipped; 1 failed."), + stdout.contains("Vendored 0 packages; 1 failed."), "a fetch failure is counted as failed, not skipped: {stdout}" ); assert!( @@ -1147,7 +1145,7 @@ fn human_corrupt_redirect_ledger_prints_cannot_vendor() { "stderr names the refused purl: {stderr}" ); assert!( - stdout.contains("Vendored 0 package(s); 0 skipped; 1 failed."), + stdout.contains("Vendored 0 packages; 1 failed."), "the fail-closed refusal is counted: {stdout}" ); assert_eq!(fx.lock_bytes(), fx.original_lock, "lock untouched"); @@ -1206,7 +1204,7 @@ fn human_lockfile_missing_refusal_prints_cannot_vendor() { "the refusal detail surfaces verbatim: {stderr}" ); assert!( - stdout.contains("Vendored 0 package(s); 0 skipped; 1 failed."), + stdout.contains("Vendored 0 packages; 1 failed."), "a non-benign refusal is counted as failed: {stdout}" ); } @@ -1238,7 +1236,7 @@ fn human_patch_failure_prints_failed_to_vendor() { "the human line carries the apply failure: {stderr}" ); assert!( - stdout.contains("Vendored 0 package(s); 0 skipped; 1 failed."), + stdout.contains("Vendored 0 packages; 1 failed."), "the failed patch is counted: {stdout}" ); assert!( @@ -1249,7 +1247,7 @@ fn human_patch_failure_prints_failed_to_vendor() { } /// Human corrupt-ledger `--revert` surface: the -/// `Error: could not read .socket/vendor/state.json` stderr line beside +/// `Error: Could not read the vendor ledger` stderr line beside /// the `vendor_state_unreadable` exit contract section 1 pins under --json. #[test] fn human_corrupt_state_revert_prints_could_not_read() { @@ -1260,7 +1258,7 @@ fn human_corrupt_state_revert_prints_could_not_read() { let (code, stdout, stderr) = human_vendor(&fx, &["--revert"]); assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stderr.contains("Error: could not read .socket/vendor/state.json:"), + stderr.contains("Error: Could not read the vendor ledger"), "the human explanation for the flipped exit code: {stderr}" ); } @@ -1280,7 +1278,7 @@ async fn human_revert_failure_prints_failed_to_revert() { "stderr names the failed purl: {stderr}" ); assert!( - stdout.contains("Reverted 0 vendored package(s); 1 failed."), + stdout.contains("Reverted 0 vendored packages; 1 failed."), "the summary counts the failure: {stdout}" ); } @@ -1298,7 +1296,7 @@ async fn human_revert_dry_run_prints_would_revert_and_mutates_nothing() { let (code, stdout, stderr) = human_vendor(&fx, &["--revert", "--dry-run"]); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("Would revert 1 vendored package(s); 0 failed."), + stdout.contains("Would revert 1 vendored package."), "the dry-run verb and count: {stdout}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index fb307b42..4d55cd82 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -381,7 +381,7 @@ fn corrupt_vendor_ledger_warns_and_fails_closed_in_human_mode() { assert!(out.stdout.is_empty(), "no document when nothing attests"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("unreadable vendor state"), + stderr.contains("Warning: Unreadable vendor state"), "the degrade must be disclosed on stderr. got: {stderr}" ); assert!( @@ -442,21 +442,22 @@ fn corrupt_vendor_ledger_json_mode_pins_channel_behavior() { assert_eq!(skipped["errorCode"], "package_not_found", "{skipped}"); assert!(!vex_path.exists(), "no document when nothing attests"); - // Channel pin (current behavior): `load_vendor_context`'s unreadable- - // vendor-state warning is gated only on --silent, so in --json mode it - // still lands on stderr rather than in the envelope's warnings[] (the - // error envelope carries no warnings at all). If the gating is ever - // reworked to fold this into warnings[] like note_warning does, update - // this pin alongside. + // Channel pin: like every other vex advisory, the unreadable-vendor- + // state warning rides the envelope's warnings[] under --json (the error + // envelope included) and stays off stderr. let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("unreadable vendor state"), - "current contract: the degrade warning goes to stderr even under \ - --json. got: {stderr}" + !stderr.contains("nreadable vendor state"), + "--json keeps the advisory off stderr. got: {stderr}" ); + let warnings = env["warnings"].as_array().unwrap_or_else(|| panic!("{env}")); + let w = warnings + .iter() + .find(|w| w["code"] == "vendor_state_unreadable") + .unwrap_or_else(|| panic!("vendor_state_unreadable warning expected: {env}")); assert!( - env["warnings"].is_null(), - "current contract: the error envelope carries no warnings[]: {env}" + w["detail"].as_str().unwrap().contains("corrupt"), + "the detail carries load_state's cause: {w}" ); } @@ -536,6 +537,42 @@ fn auto_detect_multi_manifest_warns_on_stderr() { ); } +#[test] +fn auto_detect_multi_manifest_warning_reaches_json_envelope() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + scaffold_multi_manifest_project(cwd); + + let out_path = cwd.join("out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--no-verify", + "--json", + "--output", + out_path.to_str().unwrap(), + ]) + .output() + .expect("invoke vex"); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); + let w = env["warnings"] + .as_array() + .and_then(|ws| ws.iter().find(|w| w["code"] == "product_multiple_manifests")) + .unwrap_or_else(|| panic!("product_multiple_manifests warning expected: {env}")); + assert!( + w["detail"].as_str().unwrap().contains("Multiple project manifests"), + "{w}" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("Multiple project manifests"), + "--json keeps the advisory off stderr. got: {stderr}" + ); +} + #[test] fn auto_detect_multi_manifest_warning_suppressed_by_silent() { let tmp = tempfile::tempdir().unwrap(); @@ -874,7 +911,7 @@ fn corrupt_vendor_ledger_without_manifest_is_disclosed_before_manifest_not_found assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}"); assert!(out.stdout.is_empty(), "no document"); assert!( - stderr.contains("unreadable vendor state") && stderr.contains("corrupt"), + stderr.contains("Warning: Unreadable vendor state") && stderr.contains("corrupt"), "the unreadable ledger must be disclosed: {stderr}" ); assert!( @@ -903,7 +940,7 @@ fn corrupt_vendor_ledger_without_manifest_is_disclosed_before_manifest_not_found let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}"); assert!( - !stderr.contains("Warning: unreadable vendor state"), + !stderr.contains("Unreadable vendor state"), "--silent mutes the advisory: {stderr}" ); assert!( diff --git a/crates/socket-patch-cli/tests/covgap_output.rs b/crates/socket-patch-cli/tests/covgap_output.rs index 7ec4c5da..ba7ac9eb 100644 --- a/crates/socket-patch-cli/tests/covgap_output.rs +++ b/crates/socket-patch-cli/tests/covgap_output.rs @@ -1,13 +1,13 @@ -//! Coverage-gap tests for `src/output.rs`'s interactive TTY branches +//! Coverage-gap tests for the interactive TTY branches of `src/ui/prompt.rs` //! (2026-09 coverage audit): //! -//! * `confirm()`'s bare-Enter -> `default_yes` return (output.rs:77). -//! Every production caller passes `default_yes = true`, so this line IS -//! the "Enter proceeds with the destructive action" contract — the -//! sibling pty suite drives `y`, `n`, and non-UTF-8 answers through -//! `output::confirm` but never a bare Enter (its bare-Enter test hits -//! `setup`'s separate `confirm_proceed` reader). -//! * `select_one()`'s `dialoguer::Select` branch (output.rs:101-107), whose +//! * `confirm()`'s bare-Enter -> `default_yes` return. Every production +//! caller passes `default_yes = true`, so this IS the "Enter proceeds +//! with the destructive action" contract — the sibling pty suite drives +//! `y`, `n`, and non-UTF-8 answers through `ui::confirm` but never a +//! bare Enter (its bare-Enter test hits `setup`'s default-no +//! `confirm_or_proceed`). +//! * `select_one()`'s `dialoguer::Select` branch, whose //! sole production caller is `get`'s free-user multi-patch selection: //! the Enter-accepts-first-ranked-option happy path and the //! quit -> `interact_opt` `Ok(None)` -> `SelectError::Cancelled` exit path. @@ -16,7 +16,8 @@ #![cfg(unix)] -use std::io::{Read, Write}; +#[path = "common/pty_io.rs"] +mod pty_io; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -42,12 +43,63 @@ fn binary() -> PathBuf { /// all output until the child exits. Returns `(exit_code, output)`. /// /// Same choreography as the sibling `interactive_prompts_e2e.rs` harness -/// (reader thread on the master, detached SIGKILL watchdog, write-then-EOF -/// on the writer, no polling/sleeps), which this file cannot edit — plus a +/// (reader thread on the master, detached SIGKILL watchdog, input written +/// once a prompt is on screen via `pty_io::send_when_prompted` — `confirm` +/// discards earlier typeahead — then EOF on the writer) — plus a /// pinned `TERM` because the `dialoguer`/`console` menu these tests drive /// derives key handling and rendering from the terminal type, which the /// ambient environment (some CI shells) may not set at all. fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32, String) { + let (code, _signal, output) = run_in_pty_raw(Sigint::Default, args, cwd, input, timeout); + (code, output) +} + +/// [`run_in_pty`] with a second answer: `then` is written once the y/n +/// confirm (`[Y/n] `) is on screen, after `input` answered the first +/// prompt (the dialoguer menu). +fn run_in_pty_then( + args: &[&str], + cwd: &Path, + input: &str, + then: &str, + timeout: Duration, +) -> (i32, String) { + let (code, _signal, output) = + run_in_pty_inner(Sigint::Default, args, cwd, input, Some(then), timeout); + (code, output) +} + +/// The SIGINT disposition the binary starts with under [`run_in_pty_raw`]. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Sigint { + /// Inherited default: Ctrl-C kills the process. + Default, + /// Ignored (as `nohup` and some launchers do), via + /// `sh -c 'trap "" INT; exec ...'`. + Ignored, +} + +/// [`run_in_pty`], also returning the name of the signal that ended the +/// child (`None` for a normal exit), with the binary started under the +/// given [`Sigint`] disposition. +fn run_in_pty_raw( + sigint: Sigint, + args: &[&str], + cwd: &Path, + input: &str, + timeout: Duration, +) -> (i32, Option, String) { + run_in_pty_inner(sigint, args, cwd, input, None, timeout) +} + +fn run_in_pty_inner( + sigint: Sigint, + args: &[&str], + cwd: &Path, + input: &str, + then: Option<&str>, + timeout: Duration, +) -> (i32, Option, String) { let input = input.as_bytes(); let pty_system = native_pty_system(); let pair = pty_system @@ -59,7 +111,15 @@ fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32 }) .expect("openpty"); - let mut cmd = CommandBuilder::new(binary()); + let mut cmd = if sigint == Sigint::Ignored { + let mut cmd = CommandBuilder::new("/bin/sh"); + cmd.arg("-c"); + cmd.arg("trap '' INT; exec \"$0\" \"$@\""); + cmd.arg(binary()); + cmd + } else { + CommandBuilder::new(binary()) + }; for a in args { cmd.arg(a); } @@ -113,12 +173,9 @@ fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32 .expect("spawn socket-patch in PTY"); drop(pair.slave); - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); // Watchdog: detached kill after `timeout`; a no-op if the child exits // naturally first. @@ -129,20 +186,44 @@ fn run_in_pty(args: &[&str], cwd: &Path, input: &str, timeout: Duration) -> (i32 }); let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input); - let _ = writer.flush(); + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input); + if let Some(then) = then { + use std::io::Write; + reader_handle.wait_for_count("[Y/n] ", 1, Duration::from_secs(10)); + let _ = writer.write_all(then.as_bytes()); + let _ = writer.flush(); + } drop(writer); let status = child.wait().expect("child.wait"); drop(pair.master); - let output = reader_handle.join().expect("reader thread join"); + let output = reader_handle.finish(); let code = status.exit_code() as i32; - (code, String::from_utf8_lossy(&output).to_string()) + ( + code, + status.signal().map(str::to_string), + String::from_utf8_lossy(&output).to_string(), + ) +} + +const HIDE_CURSOR: &str = "\x1b[?25l"; +const SHOW_CURSOR: &str = "\x1b[?25h"; + +/// The menu hid the cursor at least once, and the last hide was followed +/// by a show: the user's terminal is left with a visible cursor. +fn assert_cursor_restored(output: &str) { + let hidden = output + .rfind(HIDE_CURSOR) + .unwrap_or_else(|| panic!("the menu must have hidden the cursor; got: {output:?}")); + assert!( + output[hidden..].contains(SHOW_CURSOR), + "the cursor must be shown again after the menu; got: {output:?}" + ); } // --------------------------------------------------------------------------- -// output::confirm — bare Enter takes the printed [Y/n] default (line 77) +// ui::confirm — bare Enter takes the printed [Y/n] default // --------------------------------------------------------------------------- const REMOVE_MANIFEST: &str = r#"{ @@ -167,7 +248,7 @@ fn write_remove_manifest(root: &Path) { #[test] fn remove_interactive_bare_enter_proceeds_with_default_yes() { - // `output::confirm`'s empty-answer arm returns `default_yes`, and every + // `ui::confirm`'s empty-answer arm returns `default_yes`, and every // production caller passes `default_yes = true` — so a bare Enter at // remove's "[Y/n]" prompt must PROCEED with the removal, matching the // hint the prompt printed. The sibling pty suite covers `y`, `n`, and @@ -191,7 +272,7 @@ fn remove_interactive_bare_enter_proceeds_with_default_yes() { // auto-proceeds. Match the distinctive prompt verbatim (the loose // "Remove"/"patch(es)" pair is also satisfied by the success line). assert!( - output.contains("Remove 1 patch(es) and rollback files?"), + output.contains("Remove 1 patch from the manifest without rolling back its files?"), "remove must have shown the interactive confirm prompt verbatim; got: {output}" ); // Pin the printed default: the hint must advertise YES-by-default — @@ -222,7 +303,7 @@ fn remove_interactive_bare_enter_proceeds_with_default_yes() { } // --------------------------------------------------------------------------- -// output::select_one — the dialoguer::Select interactive branch (101-107) +// ui::select_one — the dialoguer::Select interactive branch (ui/prompt.rs) // --------------------------------------------------------------------------- /// Collect the paths of every request the mock actually received. Used to @@ -309,7 +390,7 @@ async fn mount_two_free_patches(mock: &MockServer, purl: &str, encoded: &str) { #[test] fn get_interactive_dialoguer_enter_accepts_first_ranked_option() { // Free user + two free patches for one PURL → `select_one` reaches its - // `dialoguer::Select` branch (output.rs:101-105). Enter must accept the + // `dialoguer::Select` branch (ui/prompt.rs). Enter must accept the // menu's `.default(0)` — the FIRST-ranked patch (UUID_A by the uuid // tiebreak) — and `get` must then fetch exactly that patch's view. // @@ -326,16 +407,14 @@ fn get_interactive_dialoguer_enter_accepts_first_ranked_option() { let uri = mock.uri(); let tmp = tempfile::tempdir().unwrap(); - // `--yes` skips only the later "Download 1 patch(es)?" confirm — it does - // NOT bypass select_one, whose interactive gate is stdin_is_tty() alone — - // keeping this test's single keystroke aimed at the dialoguer menu. - // "\r" is the Enter key at a raw-mode terminal. - let (code, output) = run_in_pty( + // No `--yes`: it answers the menu with its default without showing it. + // "\r" (Enter at a raw-mode terminal) accepts the menu's default, then + // "\n" answers the "Download 1 patch?" confirm that follows. + let (code, output) = run_in_pty_then( &[ "get", purl, "--save-only", - "--yes", "--api-url", &uri, "--api-token", @@ -345,6 +424,7 @@ fn get_interactive_dialoguer_enter_accepts_first_ranked_option() { ], tmp.path(), "\r", + "\n", Duration::from_secs(20), ); assert_eq!( @@ -397,10 +477,59 @@ fn get_interactive_dialoguer_enter_accepts_first_ranked_option() { ); } +#[test] +fn get_yes_answers_the_menu_with_its_default_without_showing_it() { + // `--yes` skips interactive prompts, the patch menu included: it takes + // the menu's default (the top-ranked patch) — even at a terminal. + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let purl = "pkg:npm/covgap-multi-yes@1.0.0"; + let encoded = "pkg%3Anpm%2Fcovgap-multi-yes%401.0.0"; + let mock = rt.block_on(async { + let mock = MockServer::start().await; + mount_two_free_patches(&mock, purl, encoded).await; + mock + }); + let uri = mock.uri(); + + let tmp = tempfile::tempdir().unwrap(); + let (code, output) = run_in_pty( + &[ + "get", + purl, + "--save-only", + "--yes", + "--api-url", + &uri, + "--api-token", + "fake", + "--org", + ORG_SLUG, + ], + tmp.path(), + "", + Duration::from_secs(20), + ); + assert_eq!(code, 0, "{output}"); + assert!( + !output.contains("Multiple patches available"), + "--yes must not open the menu; got: {output}" + ); + let body = std::fs::read_to_string(tmp.path().join(".socket/manifest.json")) + .expect("get --save-only must write .socket/manifest.json"); + let manifest: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(manifest["patches"][purl]["uuid"], UUID_A, "{body}"); + // The listing showed two patches; the pick is named before saving. + let screen = crate::pty_io::render(output.as_bytes()).join("\n"); + assert!( + screen.contains("Selected:\n pkg:npm/covgap-multi-yes@1.0.0 [FREE] 11111111"), + "{screen}" + ); +} + #[test] fn get_interactive_dialoguer_quit_cancels_with_exit_zero() { // Cancelling the dialoguer menu → `interact_opt()` returns `Ok(None)` → - // `select_one` maps it to `SelectError::Cancelled` (output.rs:107) → + // `select_one` maps it to `SelectError::Cancelled` (ui/prompt.rs) → // get prints "Selection cancelled." and exits 0 without downloading // anything (get.rs:660-663). // @@ -427,7 +556,6 @@ fn get_interactive_dialoguer_quit_cancels_with_exit_zero() { "get", purl, "--save-only", - "--yes", "--api-url", &uri, "--api-token", @@ -454,6 +582,10 @@ fn get_interactive_dialoguer_quit_cancels_with_exit_zero() { output.contains("Selection cancelled."), "Esc must surface the Cancelled message; got: {output}" ); + // dialoguer shows the cursor again itself on a clean q/Esc cancel, so + // this passes even without CursorGuard; the Ctrl-C test below + // (`..._ctrl_c_restores_cursor_and_dies_by_sigint`) is the real guard. + assert_cursor_restored(&output); assert_eq!( code, 0, "a user-cancelled selection is a clean exit, not an error; got: {output}" @@ -479,3 +611,105 @@ fn get_interactive_dialoguer_quit_cancels_with_exit_zero() { "the by-package listing must have been queried before the menu; recorded paths={paths:?}" ); } + +#[test] +fn get_interactive_dialoguer_ctrl_c_restores_cursor_and_dies_by_sigint() { + // Ctrl-C at the menu: console reads the raw ^C byte and raises SIGINT. + // `select_one`'s guard must show the cursor again before the default + // SIGINT action kills the process — dialoguer leaves it hidden. + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let purl = "pkg:npm/covgap-multi-ctrlc@1.0.0"; + let encoded = "pkg%3Anpm%2Fcovgap-multi-ctrlc%401.0.0"; + let mock = rt.block_on(async { + let mock = MockServer::start().await; + mount_two_free_patches(&mock, purl, encoded).await; + mock + }); + let uri = mock.uri(); + + let tmp = tempfile::tempdir().unwrap(); + let (code, signal, output) = run_in_pty_raw( + Sigint::Default, + &[ + "get", + purl, + "--save-only", + "--api-url", + &uri, + "--api-token", + "fake", + "--org", + ORG_SLUG, + ], + tmp.path(), + "\x03", + Duration::from_secs(20), + ); + assert!( + output.contains(&format!("Multiple patches available for {purl}")), + "the dialoguer select prompt must have rendered; got: {output}" + ); + // Died of the re-raised SIGINT, not the watchdog's SIGKILL and not a + // normal exit. + let signal = signal.unwrap_or_else(|| { + panic!("Ctrl-C must end the process by SIGINT; exited with {code}; got: {output:?}") + }); + assert!( + signal.contains("Interrupt"), + "expected SIGINT, got signal {signal:?}; output: {output:?}" + ); + assert_cursor_restored(&output); + assert!( + !tmp.path().join(".socket/manifest.json").exists(), + "an interrupted selection must not write a manifest" + ); +} + +#[test] +fn get_interactive_dialoguer_ctrl_c_with_sigint_ignored_cancels_cleanly() { + // Started with SIGINT ignored, the raised SIGINT must stay ignored: + // the cursor guard may not swap in a handler that re-raises into the + // default (fatal) action. The menu then just returns Cancelled. + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let purl = "pkg:npm/covgap-multi-sigign@1.0.0"; + let encoded = "pkg%3Anpm%2Fcovgap-multi-sigign%401.0.0"; + let mock = rt.block_on(async { + let mock = MockServer::start().await; + mount_two_free_patches(&mock, purl, encoded).await; + mock + }); + let uri = mock.uri(); + + let tmp = tempfile::tempdir().unwrap(); + let (code, signal, output) = run_in_pty_raw( + Sigint::Ignored, + &[ + "get", + purl, + "--save-only", + "--api-url", + &uri, + "--api-token", + "fake", + "--org", + ORG_SLUG, + ], + tmp.path(), + "\x03", + Duration::from_secs(20), + ); + assert!( + output.contains(&format!("Multiple patches available for {purl}")), + "the dialoguer select prompt must have rendered; got: {output}" + ); + assert_eq!( + signal, None, + "an ignored SIGINT must not kill the process; got: {output:?}" + ); + assert!( + output.contains("Selection cancelled."), + "Ctrl-C with SIGINT ignored must cancel the menu; got: {output}" + ); + assert_eq!(code, 0, "{output}"); + assert_cursor_restored(&output); +} diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index 1b11ad2c..b8770d4c 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -209,7 +209,8 @@ async fn scan_discovers_fake_registry_crates() { "Expected human scan to report exactly 'Found 2 packages (2 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found"), "scan reported no packages despite a populated registry:\n{combined}" ); @@ -262,11 +263,12 @@ async fn scan_discovers_vendor_crates() { // substring form was a loophole. `(1 cargo)` proves the single discovered // package is the vendored crate and not an accidental npm/pypi pickup. assert!( - combined.contains("Found 1 packages (1 cargo)"), - "Expected human scan to report exactly 'Found 1 packages (1 cargo)', got:\n{combined}" + combined.contains("Found 1 package (1 cargo)"), + "Expected human scan to report exactly 'Found 1 package (1 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found"), "scan reported no packages despite a populated vendor dir:\n{combined}" ); diff --git a/crates/socket-patch-cli/tests/e2e_composer.rs b/crates/socket-patch-cli/tests/e2e_composer.rs index dd929e8d..9c4ef401 100644 --- a/crates/socket-patch-cli/tests/e2e_composer.rs +++ b/crates/socket-patch-cli/tests/e2e_composer.rs @@ -248,13 +248,13 @@ async fn scan_discovers_composer1_packages() { ); // --- Human path: the single package must be attributed *entirely* to the - // php ecosystem. Assert the contiguous `Found 1 packages (1 php)` string + // php ecosystem. Assert the contiguous `Found 1 package (1 php)` string // (see the Composer 2 test for why two independent substrings are too // weak). let combined = scan_human(&project_dir, &proxy.uri()).await; assert!( - combined.contains("Found 1 packages (1 php)"), - "Expected human scan to report exactly 'Found 1 packages (1 php)', got:\n{combined}" + combined.contains("Found 1 package (1 php)"), + "Expected human scan to report exactly 'Found 1 package (1 php)', got:\n{combined}" ); assert!( !combined.contains("No packages found"), diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 43514c34..5edfa980 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -584,7 +584,7 @@ fn apply_vex_write_failure_names_path() { .as_str() .expect("error.message is a string"); assert!( - msg.contains("failed to write VEX document") && msg.contains("no-such-dir"), + msg.contains("Failed to write VEX document") && msg.contains("no-such-dir"), "the error must name the operation and the path; got {msg:?}" ); } diff --git a/crates/socket-patch-cli/tests/e2e_golang.rs b/crates/socket-patch-cli/tests/e2e_golang.rs index 497e9772..165c33c3 100644 --- a/crates/socket-patch-cli/tests/e2e_golang.rs +++ b/crates/socket-patch-cli/tests/e2e_golang.rs @@ -347,8 +347,8 @@ async fn scan_discovers_case_encoded_modules() { output.status.code() ); assert!( - combined.contains("Found 1 packages (1 go)"), - "Expected human scan to report 'Found 1 packages (1 go)', got:\n{combined}" + combined.contains("Found 1 package (1 go)"), + "Expected human scan to report 'Found 1 package (1 go)', got:\n{combined}" ); assert!( !combined.contains("No packages found"), diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index d8714f7d..40676a01 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -182,7 +182,8 @@ async fn scan_discovers_maven_artifacts() { // the word "packages", which is exactly what let the old assertion // pass when discovery was disabled. assert!( - !combined.contains("No packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found"), "scan reported zero packages — Maven discovery did not run:\n{combined}" ); assert!( @@ -313,7 +314,7 @@ async fn scan_discovers_gradle_project_artifacts() { String::from_utf8_lossy(&human.stderr) ); assert!( - h_combined.contains("Found 1 packages") && h_combined.contains("(1 maven)"), + h_combined.contains("Found 1 package (1 maven)"), "expected the Gradle project to discover exactly 1 Maven artifact, got:\n{h_combined}" ); diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index b103d8c3..a7793d70 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -228,7 +228,8 @@ async fn scan_discovers_global_cache_packages() { // the bug the old substring check ("packages" ⊂ "No packages found.") // masked. assert!( - !combined.contains("No packages found") && !combined.contains("No global packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found") && !combined.contains("No global packages found"), "scan failed to discover the fake global cache:\n{combined}" ); // Exactly the two packages we planted (Newtonsoft.Json, System.Text.Json), @@ -286,7 +287,8 @@ async fn scan_discovers_legacy_packages() { output.status.code() ); assert!( - !combined.contains("No packages found") && !combined.contains("No global packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found") && !combined.contains("No global packages found"), "scan failed to discover the legacy packages/ layout:\n{combined}" ); // Exactly the single legacy package we planted (Newtonsoft.Json.13.0.3), diff --git a/crates/socket-patch-cli/tests/e2e_safety_lock.rs b/crates/socket-patch-cli/tests/e2e_safety_lock.rs index 37cfb3ba..e1bdf897 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_lock.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_lock.rs @@ -170,7 +170,7 @@ fn lock_held_human_mode_mentions_other_process() { // the wait hint. Held always means a live process, so the only // honest advice is to wait (or budget a wait via --lock-timeout). assert!( - stderr.contains("Error: another socket-patch process is operating in this directory"), + stderr.contains("Error: Another socket-patch process is operating in this directory"), "stderr should carry the lock_held error line, got:\n{stderr}" ); assert!( @@ -204,7 +204,7 @@ fn lock_held_silent_mode_still_reports_error() { "silent human mode must not print to stdout, got:\n{stdout}" ); assert!( - stderr.contains("Error: another socket-patch process is operating in this directory"), + stderr.contains("Error: Another socket-patch process is operating in this directory"), "--silent means errors only, not no errors: the lock_held line \ must reach stderr, got:\n{stderr}" ); diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index ba1a7340..25fdf98e 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -886,7 +886,7 @@ fn verify_mode_includes_applied_omits_unapplied() { // Both omissions must surface on stderr, each routed with its own // verification reason (the warning format is - // "omitting patch for from VEX ()"). + // "Warning: omitting from VEX: ()"). let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("unapplied-pkg") && stderr.contains("file_not_found"), diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 258283b5..ebde8db5 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -1519,7 +1519,7 @@ fn standalone_output_write_failure_names_path_and_exits_2() { ); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("failed to write VEX document") && stderr.contains("no-such-dir"), + stderr.contains("Failed to write VEX document") && stderr.contains("no-such-dir"), "the error must name the operation and the path; got {stderr:?}" ); } diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index 5c87276d..e419d8c6 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -496,13 +496,21 @@ fn get_help_lists_all_identifier_flags() { "--ghsa", "--package", "--save-only", - "--one-off", + "--all-releases", + "--mode", ] { assert!( stdout.contains(flag), "get --help missing flag {flag}; got: {stdout}" ); } + // `--one-off` always fails with "not yet implemented": it stays + // parseable (scripts get that explicit error) but is not advertised. + assert!(!stdout.contains("--one-off"), "{stdout}"); + // Help text is for users: no implementation notes from the source. + for leak in ["value_parser", "parse_bool_flag", "No env binding", "locally- installed"] { + assert!(!stdout.contains(leak), "get --help leaks {leak:?}: {stdout}"); + } } #[tokio::test] diff --git a/crates/socket-patch-cli/tests/get_modes_e2e.rs b/crates/socket-patch-cli/tests/get_modes_e2e.rs index 166b66d3..b20104e1 100644 --- a/crates/socket-patch-cli/tests/get_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/get_modes_e2e.rs @@ -547,7 +547,7 @@ async fn get_hosted_silent_prints_nothing_to_stdout() { run_get(tmp2.path(), &server.uri(), &[UUID1, "--mode", "hosted"]); assert_eq!(loud_code, 0, "stderr:\n{loud_stderr}"); assert!( - loud_stdout.contains("Redirected 1 package(s)"), + loud_stdout.contains("Redirected 1 package; rewrote 1 file."), "non-silent hosted run must print the redirect summary; got {loud_stdout:?}" ); } diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs new file mode 100644 index 00000000..09f363a2 --- /dev/null +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -0,0 +1,148 @@ +//! `--help` is user-facing text: implementation notes (clap internals, +//! function names, contract cross-references) belong in `//` comments, and +//! a hyphenated word must not be split across doc-comment lines (clap joins +//! the lines with a space: "lock- contending"). + +use clap::CommandFactory; +use socket_patch_cli::Cli; + +/// Tokens that only make sense to someone reading the source. +const DEV_TOKENS: &[&str] = &[ + "clap", + "`None`", + "value_parser", + "GlobalArgs", + "GLOBAL_ARG_ENV_VARS", + "resolve_mode_flags", + "get_api_client", + "parse_argv_with_shortcuts", + "DEFAULT_SOCKET_API_URL", + "CLI_CONTRACT", + "Internal parse target", + "#[command", +]; + +fn leaks(text: &str) -> Vec { + let mut found: Vec = DEV_TOKENS + .iter() + .filter(|t| text.contains(**t)) + .map(|t| t.to_string()) + .collect(); + // A line-wrap-split hyphenated word: "lock- contending". + let chars: Vec = text.chars().collect(); + for w in chars.windows(4) { + if w[0].is_ascii_lowercase() && w[1] == '-' && w[2] == ' ' && w[3].is_ascii_lowercase() { + found.push(format!("split hyphen {:?}", w.iter().collect::())); + } + } + found +} + +fn long_help(path: &[&str]) -> String { + let mut cmd = Cli::command(); + cmd.build(); + let mut cur = &mut cmd; + for name in path { + cur = cur + .find_subcommand_mut(name) + .unwrap_or_else(|| panic!("no subcommand {name}")); + } + cur.render_long_help().to_string() +} + +#[test] +fn owned_help_pages_have_no_developer_notes() { + // `self-update` is covered by `self_update_help_shows_the_public_spelling` + // (its [VERSION] arg help lives in commands/update.rs). + for path in [&[][..], &["vex"], &["setup"]] { + let text = long_help(path); + let found = leaks(&text); + assert!(found.is_empty(), "{path:?} --help leaks {found:?}:\n{text}"); + } +} + +#[test] +fn global_options_help_has_no_developer_notes_on_any_subcommand() { + let mut cmd = Cli::command(); + cmd.build(); + for sub in cmd.get_subcommands() { + for arg in sub.get_arguments() { + if arg.get_help_heading() != Some("Global options") { + continue; + } + let text = format!( + "{} {}", + arg.get_help().map(|h| h.to_string()).unwrap_or_default(), + arg.get_long_help() + .map(|h| h.to_string()) + .unwrap_or_default() + ); + let found = leaks(&text); + assert!( + found.is_empty(), + "{} --{}: {found:?}: {text}", + sub.get_name(), + arg.get_id() + ); + } + } +} + +#[test] +fn global_flags_are_grouped_after_command_flags() { + let text = long_help(&["vex"]); + let options = text.find("Options:").expect("Options heading"); + let global = text + .find("Global options:") + .expect("Global options heading"); + let output = text.find("-O, --output ").expect("--output listed"); + let cwd = text.find("--cwd ").expect("--cwd listed"); + assert!( + options < output && output < global && global < cwd, + "{text}" + ); +} + +#[test] +fn self_update_help_shows_the_public_spelling() { + let text = long_help(&["self-update"]); + assert!( + text.starts_with("Update socket-patch itself to the latest release (or to VERSION)."), + "{text}" + ); + assert!( + text.contains("Usage: socket-patch --update [VERSION] [OPTIONS]"), + "{text}" + ); + assert!(!text.contains("socket-patch self-update"), "{text}"); +} + +#[test] +fn vex_product_list_renders_one_item_per_line() { + let text = long_help(&["vex"]); + for item in [ + "1. the git `origin` remote", + "2. package.json:", + "3. pyproject.toml:", + "4. Cargo.toml:", + ] { + assert!( + text.lines().any(|l| l.trim_start().starts_with(item)), + "{item:?} must start its own line:\n{text}" + ); + } +} + +#[test] +fn root_command_list_uses_the_verb_form() { + let text = long_help(&[]); + assert!( + text.contains("Roll back patches to restore original files"), + "{text}" + ); + assert!(!text.contains("Rollback patches"), "{text}"); + assert!( + text.contains("Wire install hooks (npm, Python, Bundler, Composer)"), + "{text}" + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index a5ccfb46..a96d3181 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -1987,12 +1987,12 @@ async fn redirect_human_mode_prints_rewriter_warnings() { "a no-op redirect still exits 0; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stdout.contains("Redirected 0 package(s)"), + stdout.contains("Redirected 0 packages; rewrote 0 files."), "anchor: the run must have taken the human-mode redirect branch; \ stdout=\n{stdout}" ); assert!( - stderr.contains("no package-lock.json"), + stderr.contains("Warning (redirect_npm_no_lockfile): No package-lock.json"), "human mode must print the rewriter's no-lockfile warning (JSON mode \ already carries it); stderr=\n{stderr}" ); @@ -2038,7 +2038,9 @@ async fn redirect_human_mode_warnings_are_not_json_quoted() { .expect("run socket-patch"); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains(&format!("skipped {PURL} (forbidden)")), + stderr.contains(&format!( + "Skipped {PURL}: not entitled to this patch (paid plan or no org access)" + )), "the skipped line must print the bare purl/reason, not JSON-quoted \ values; stderr=\n{stderr}" ); @@ -2070,13 +2072,14 @@ async fn redirect_human_mode_warnings_are_not_json_quoted() { let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stdout.contains("Redirected 1 package(s)"), + stdout.contains("Redirected 1 package; rewrote"), "anchor: the dep must have been redirected so the record fetch runs; \ stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( stderr.contains(&format!( - "warning: {PURL} redirected, but its patch record could not be fetched" + "Warning (record_fetch_failed): {PURL} redirected, but its patch record could not \ + be fetched" )), "the record-fetch warning must print the bare detail string, not a \ JSON-quoted one; stderr=\n{stderr}" diff --git a/crates/socket-patch-cli/tests/in_process_scan.rs b/crates/socket-patch-cli/tests/in_process_scan.rs index 5382a7d2..64959fab 100644 --- a/crates/socket-patch-cli/tests/in_process_scan.rs +++ b/crates/socket-patch-cli/tests/in_process_scan.rs @@ -881,7 +881,7 @@ async fn scan_non_json_with_patches_prints_table() { let code = run_scrubbed(args).await; // Non-JSON path: discovery → batch query → render table → fetch // per-package details. We only mount the batch mock, so detail-fetch - // 404s and scan exits 1 ("Could not fetch patch details"). That exit is + // 404s and scan exits 1 ("Error: could not fetch patch details"). That exit is // deterministic given these mocks. assert_eq!(code, 1, "missing detail mock → detail fetch fails → exit 1"); // Prove the table-rendering path actually ran against real discovered diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs index d22f03f4..745fb6fa 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun.rs @@ -858,7 +858,7 @@ async fn dry_run_human_names_would_refuse_records() { let (exit, stdout, stderr) = get_vendored(tmp.path(), &mock.uri(), PURL, &["--dry-run"]); assert_eq!(exit, 0, "stdout={stdout}\nstderr={stderr}"); assert!( - stdout.contains("[dry-run] Would download and vendor 1 patch(es)."), + stdout.contains("[dry-run] Would download and vendor 1 patch. No changes made."), "{stdout}" ); assert!( diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index ffb1489f..48e5c82a 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -1,13 +1,14 @@ -//! End-to-end tests that drive interactive `dialoguer` prompts via a -//! pseudo-terminal. These exercise the `stdin_is_tty()`-gated -//! confirmation paths in `setup`, `remove`, and `get` that -//! subprocess-with-piped-stdin tests can't reach. +//! End-to-end tests that drive interactive prompts (`ui::confirm`, +//! `ui::confirm_or_proceed`) via a pseudo-terminal. These exercise the +//! stdin-is-a-terminal-gated confirmation paths in `setup`, `remove`, and +//! `get` that subprocess-with-piped-stdin tests can't reach. //! //! PTY support: macOS + Linux. Skipped on Windows. #![cfg(unix)] -use std::io::{Read, Write}; +#[path = "common/pty_io.rs"] +mod pty_io; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -113,12 +114,9 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // closed. The previous design used a chunked read+mpsc loop // because it interleaved with a try_wait poll; the simplified // design serializes wait → drop master → read_to_end joins. - let mut reader = pair.master.try_clone_reader().expect("clone reader"); - let reader_handle = std::thread::spawn(move || { - let mut buf = Vec::new(); - let _ = reader.read_to_end(&mut buf); - buf - }); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); // Watchdog: detach a thread that kills the child after `timeout`. // The cloned ChildKiller is independent of the main `child` @@ -131,12 +129,12 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) let _ = killer.kill(); }); - // Writer: send input then close. PTY buffers absorb the write so - // no pre-sleep is needed — dialoguer/rustyline will read it when - // their prompt loop polls stdin. + // Writer: send input once a prompt is on screen, then close. + // `ui::confirm` discards typeahead right before it prompts, so input + // written any earlier would be thrown away (see + // `pty_io::send_when_prompted`). let mut writer = pair.master.take_writer().expect("take writer"); - let _ = writer.write_all(input); - let _ = writer.flush(); + crate::pty_io::send_when_prompted(&reader_handle, &mut writer, input); drop(writer); // Block until the child exits (watchdog enforces the timeout). @@ -145,7 +143,7 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // returns. drop(pair.master); - let output = reader_handle.join().expect("reader thread join"); + let output = reader_handle.finish(); let code = status.exit_code() as i32; (code, String::from_utf8_lossy(&output).to_string()) } @@ -163,7 +161,7 @@ fn setup_interactive_y_proceeds_with_update() { ) .unwrap(); - // Without --yes, setup prompts "Proceed with these changes? (y/N): ". + // Without --yes, setup prompts "Proceed with these changes? [y/N] ". // Sending "y\n" should make it proceed with the update. let (code, output) = run_in_pty(&["setup"], tmp.path(), "y\n", Duration::from_secs(15)); assert_eq!(code, 0, "setup with 'y' must succeed"); @@ -265,10 +263,9 @@ fn setup_interactive_default_no_aborts() { #[test] fn setup_interactive_non_utf8_answer_aborts_without_panic() { // Same regression class as remove_interactive_non_utf8_answer_ - // declines_without_panic below, but for setup's own prompt reader - // (`confirm_proceed`), a separate implementation from - // `output::confirm`: a Latin-1 paste (`é` = 0xE9) at - // "Proceed with these changes? (y/N): " makes `read_line` return + // declines_without_panic below, but for setup's default-no gate + // (`ui::confirm_or_proceed`): a Latin-1 paste (`é` = 0xE9) at + // "Proceed with these changes? [y/N] " makes `read_line` return // InvalidData, and unwrapping it panics the CLI (exit 101) instead // of treating the unreadable answer as "not yes" (abort). let tmp = tempfile::tempdir().unwrap(); @@ -350,13 +347,13 @@ fn remove_interactive_y_proceeds() { assert_eq!(code, 0); // The interactive confirm MUST have run (printed to the tty via stderr), // not the non-interactive auto-default branch. Match the DISTINCTIVE - // prompt text ("...and rollback files?") rather than the loose pair + // prompt text ("...without rolling back its files?") rather than the loose pair // `contains("Remove") && contains("patch(es)")` — the latter is also // satisfied by the SUCCESS line "Removed 1 patch(es) from manifest:", // so it would stay green even if the confirm prompt were dropped and the // command auto-removed. The exact count ("1") pins single-entry preview. assert!( - output.contains("Remove 1 patch(es) and rollback files?"), + output.contains("Remove 1 patch from the manifest without rolling back its files?"), "remove must have shown the interactive confirm prompt verbatim; got: {output}" ); assert!( @@ -401,7 +398,7 @@ fn remove_interactive_n_cancels() { // `contains("Remove") && contains("patch(es)")` pair could also be matched // by the preview banner, masking a dropped confirm prompt. assert!( - output.contains("Remove 1 patch(es) and rollback files?"), + output.contains("Remove 1 patch from the manifest without rolling back its files?"), "remove must have shown the interactive confirm prompt verbatim; got: {output}" ); assert!( @@ -467,7 +464,7 @@ fn remove_interactive_non_utf8_answer_declines_without_panic() { // The interactive confirm MUST have run (same vacuity guard as the // y/n tests above), and the unreadable answer must land on "no". assert!( - output.contains("Remove 1 patch(es) and rollback files?"), + output.contains("Remove 1 patch from the manifest without rolling back its files?"), "remove must have shown the interactive confirm prompt; got: {output}" ); assert!( @@ -542,7 +539,7 @@ fn remove_detached_interactive_n_cancel_message_respects_silent() { // an early error (e.g. a broken ledger fixture) would pass the absence // assertion below without ever reaching the cancel branch. assert!( - output.contains("Remove 1 vendored patch(es) and revert their vendoring?"), + output.contains("Remove 1 vendored patch and revert its vendoring?"), "detached remove must have shown its confirm prompt; got: {output}" ); assert!( diff --git a/crates/socket-patch-cli/tests/output_helpers_e2e.rs b/crates/socket-patch-cli/tests/output_helpers_e2e.rs index f26d5a0f..263d156e 100644 --- a/crates/socket-patch-cli/tests/output_helpers_e2e.rs +++ b/crates/socket-patch-cli/tests/output_helpers_e2e.rs @@ -1,49 +1,50 @@ -//! Integration coverage for `socket_patch_cli::output` helpers. -//! The pub `format_severity` and `color` functions are widely used +//! Integration coverage for the `socket_patch_cli::ui` color helpers. +//! The pub `severity` and `paint` functions are widely used //! by `commands/scan.rs` + `commands/list.rs` for human-mode display, //! but the integration test suite runs all its scan/list tests in //! `--json` mode (which suppresses the colour wrappers entirely), so //! every ANSI branch was uncovered. These tests drive each branch //! directly via the lib's pub API. -use socket_patch_cli::output::{color, format_severity, select_one, SelectError}; +use socket_patch_cli::args::GlobalArgs; +use socket_patch_cli::ui::{paint, select_one, severity, SelectError}; #[test] -fn format_severity_no_color_returns_input_verbatim() { - assert_eq!(format_severity("critical", false), "critical"); - assert_eq!(format_severity("high", false), "high"); - assert_eq!(format_severity("medium", false), "medium"); - assert_eq!(format_severity("low", false), "low"); - assert_eq!(format_severity("unknown", false), "unknown"); +fn severity_no_color_returns_input_verbatim() { + assert_eq!(severity("critical", false), "critical"); + assert_eq!(severity("high", false), "high"); + assert_eq!(severity("medium", false), "medium"); + assert_eq!(severity("low", false), "low"); + assert_eq!(severity("unknown", false), "unknown"); } #[test] -fn format_severity_critical_wraps_in_bright_red() { +fn severity_critical_wraps_in_bright_red() { // Exact envelope: bright-red open + verbatim text + reset, nothing else. // Critical is the most prominent colour (bright red, 91) — strictly more // prominent than high (plain red, 31). - assert_eq!(format_severity("critical", true), "\x1b[91mcritical\x1b[0m"); + assert_eq!(severity("critical", true), "\x1b[91mcritical\x1b[0m"); } #[test] -fn format_severity_high_wraps_in_red() { - assert_eq!(format_severity("high", true), "\x1b[31mhigh\x1b[0m"); +fn severity_high_wraps_in_red() { + assert_eq!(severity("high", true), "\x1b[31mhigh\x1b[0m"); } #[test] -fn format_severity_medium_wraps_in_yellow() { - assert_eq!(format_severity("medium", true), "\x1b[33mmedium\x1b[0m"); +fn severity_medium_wraps_in_yellow() { + assert_eq!(severity("medium", true), "\x1b[33mmedium\x1b[0m"); } #[test] -fn format_severity_low_wraps_in_cyan() { - assert_eq!(format_severity("low", true), "\x1b[36mlow\x1b[0m"); +fn severity_low_wraps_in_cyan() { + assert_eq!(severity("low", true), "\x1b[36mlow\x1b[0m"); } #[test] -fn format_severity_unknown_passes_through_unwrapped() { +fn severity_unknown_passes_through_unwrapped() { // The `_` arm returns the input verbatim — no ANSI wrapper. - let out = format_severity("nonsense", true); + let out = severity("nonsense", true); assert!( !out.contains("\x1b["), "unknown severity must not wrap: {out:?}" @@ -52,58 +53,58 @@ fn format_severity_unknown_passes_through_unwrapped() { } #[test] -fn format_severity_case_insensitive() { +fn severity_case_insensitive() { // The lowercase match must apply to mixed-case input — AND the displayed // text must be the caller's verbatim, original-case string (production // wraps `{s}`, not the lowercased key). Exact-equality catches both a // miscoloured branch and any impl that lowercases the rendered text. - 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"); + assert_eq!(severity("CRITICAL", true), "\x1b[91mCRITICAL\x1b[0m"); + assert_eq!(severity("High", true), "\x1b[31mHigh\x1b[0m"); + assert_eq!(severity("MEDIUM", true), "\x1b[33mMEDIUM\x1b[0m"); + assert_eq!(severity("Low", true), "\x1b[36mLow\x1b[0m"); } #[test] -fn color_with_use_color_false_returns_input() { - assert_eq!(color("text", "31", false), "text"); +fn paint_with_use_color_false_returns_input() { + assert_eq!(paint("text", "31", false), "text"); } #[test] -fn color_with_use_color_true_wraps_with_code() { - let out = color("text", "31", true); +fn paint_with_use_color_true_wraps_with_code() { + let out = paint("text", "31", true); assert_eq!(out, "\x1b[31mtext\x1b[0m"); } #[test] -fn color_threads_code_parameter_verbatim() { +fn paint_threads_code_parameter_verbatim() { // A single-code ("31") test can't tell a correct impl apart from one that // hardcodes `\x1b[31m...` and ignores its `code` argument. Drive several // distinct codes (including multi-part SGR sequences) and require the exact // code to appear in the envelope; also assert distinct codes diverge. - assert_eq!(color("text", "91", true), "\x1b[91mtext\x1b[0m"); - assert_eq!(color("text", "1;32", true), "\x1b[1;32mtext\x1b[0m"); - assert_eq!(color("text", "0", true), "\x1b[0mtext\x1b[0m"); + assert_eq!(paint("text", "91", true), "\x1b[91mtext\x1b[0m"); + assert_eq!(paint("text", "1;32", true), "\x1b[1;32mtext\x1b[0m"); + assert_eq!(paint("text", "0", true), "\x1b[0mtext\x1b[0m"); assert_ne!( - color("text", "31", true), - color("text", "91", true), + paint("text", "31", true), + paint("text", "91", true), "distinct codes must produce distinct output" ); } #[test] -fn color_with_use_color_false_ignores_code() { +fn paint_with_use_color_false_ignores_code() { // The disabled path must return the input verbatim for ANY code and must // never emit an ANSI escape, regardless of the code argument. - assert_eq!(color("text", "1;32", false), "text"); - assert_eq!(color("", "91", false), ""); - assert!(!color("text", "91", false).contains('\x1b')); + assert_eq!(paint("text", "1;32", false), "text"); + assert_eq!(paint("", "91", false), ""); + assert!(!paint("text", "91", false).contains('\x1b')); } #[test] -fn color_with_empty_text_still_wraps() { +fn paint_with_empty_text_still_wraps() { // Edge case: empty input still gets the ANSI envelope when // colour is enabled. - let out = color("", "31", true); + let out = paint("", "31", true); assert_eq!(out, "\x1b[31m\x1b[0m"); } @@ -116,14 +117,21 @@ fn select_one_empty_options_does_not_yield_out_of_bounds_index() { let empty: Vec = Vec::new(); assert!( matches!( - select_one("pick", &empty, false), + select_one("pick", &empty, &GlobalArgs::default()), Err(SelectError::Cancelled) ), "empty non-JSON select must be Cancelled" ); // JSON mode is still decided first. assert!(matches!( - select_one("pick", &empty, true), + select_one( + "pick", + &empty, + &GlobalArgs { + json: true, + ..GlobalArgs::default() + } + ), Err(SelectError::JsonModeNeedsExplicit) )); } diff --git a/crates/socket-patch-cli/tests/output_modes_e2e.rs b/crates/socket-patch-cli/tests/output_modes_e2e.rs index da1b3587..4d5bba66 100644 --- a/crates/socket-patch-cli/tests/output_modes_e2e.rs +++ b/crates/socket-patch-cli/tests/output_modes_e2e.rs @@ -105,7 +105,7 @@ fn apply_non_json_prints_human_readable_summary() { // The human-readable summary must report the count *and* name the // patched package — not merely print one of two loosely-OR'd words. assert!( - stdout.contains("Summary:") && stdout.contains("1/1 targeted patches applied"), + stdout.contains("Summary:") && stdout.contains("1 of 1 targeted patch applied"), "non-JSON apply should print the patch-count summary; got: {stdout}" ); assert!( @@ -212,12 +212,13 @@ fn apply_dry_run_non_json_prints_verification_summary() { stdout.contains("Patch verification complete") && stdout.contains("can be patched"), "dry-run non-JSON should print the verification summary; got: {stdout}" ); - // Dry-run reports 0 patches *applied* and, critically, must NOT touch - // the file on disk. The old test never checked this, so a dry-run - // that actually mutated files would have passed. + // Dry-run reports what WOULD apply (never an "applied" summary) and, + // critically, must NOT touch the file on disk. The old test never + // checked this, so a dry-run that actually mutated files would have + // passed. assert!( - stdout.contains("0/1 targeted patches applied"), - "dry-run must report nothing applied; got: {stdout}" + stdout.contains("1 package can be patched") && !stdout.contains("Summary:"), + "dry-run must report the would-be work, not an applied summary; got: {stdout}" ); let on_disk = std::fs::read(tmp.path().join("node_modules/dry-target/index.js")).unwrap(); assert_eq!( @@ -251,7 +252,7 @@ fn list_non_json_prints_table() { "list non-JSON must print the CVE id; got: {stdout}" ); assert!( - stdout.contains("Found 1 patch(es)"), + stdout.contains("Found 1 patch:"), "list non-JSON must report the patch count; got: {stdout}" ); } @@ -344,7 +345,7 @@ fn repair_non_json_no_orphans_prints_summary() { // deleted the in-use blob — or skipped the cleanup scan entirely — still // passed. assert!( - stdout.contains("Checked 1 blob(s), all are in use."), + stdout.contains("Checked 1 blob: in use."), "no-orphan repair must report the single blob as in-use; got: {stdout}" ); assert!( @@ -382,7 +383,7 @@ fn repair_non_json_with_orphans_prints_cleanup_summary() { // 2) so a repair that removes too few OR too many blobs fails here; the // old `contains("Removed")` accepted any nonzero count. assert!( - stdout.contains("Removed 2 unused blob(s)"), + stdout.contains("Removed 2 unused blobs"), "repair with orphans must report exactly 2 removed unused blobs; got: {stdout}" ); assert!( @@ -423,7 +424,7 @@ fn remove_non_json_prints_what_will_be_removed() { ); assert_eq!(code, 0); assert!( - stdout.contains("Removed 1 patch(es) from manifest") + stdout.contains("Removed 1 patch from manifest") && stdout.contains("pkg:npm/remove-target@1.0.0"), "non-JSON remove must print confirmation naming the PURL; stdout={stdout}" ); @@ -504,7 +505,7 @@ fn get_non_json_invalid_uuid_falls_through_to_package_search() { // Invalid identifier without --cve/--ghsa/--package etc. The binary // should fall through to package-name search and either succeed or // exit 1 cleanly. We're exercising the type-detection branch. - let (code, stdout, _stderr) = common::run_with_env( + let (code, stdout, stderr) = common::run_with_env( tmp.path(), &[ "get", @@ -530,9 +531,10 @@ fn get_non_json_invalid_uuid_falls_through_to_package_search() { code, 0, "package-name fall-through should exit cleanly; stdout={stdout}" ); + // The routing note is stderr narration (stdout stays for results). assert!( - stdout.contains("as a package name search"), - "get with a bare identifier must fall through to package-name search; got: {stdout}" + stderr.contains("as a package name search"), + "get with a bare identifier must fall through to package-name search; got: {stderr}" ); } @@ -579,8 +581,9 @@ fn get_with_explicit_cve_flag_works() { #[test] fn get_with_explicit_ghsa_flag_works() { let tmp = tempfile::tempdir().unwrap(); - // Non-JSON so we can assert the human-readable routing line on stdout - // and the network error (with the by-ghsa endpoint) on stderr. + // Non-JSON so we can assert the human-readable network error (with the + // by-ghsa endpoint) on stderr. The "Searching patches for GHSA ..." + // progress is a transient status line that never reaches a pipe. let (code, stdout, stderr) = common::run_with_env( tmp.path(), &[ @@ -600,8 +603,8 @@ fn get_with_explicit_ghsa_flag_works() { ); assert_eq!(code, 1, "unreachable API must yield a failure exit"); assert!( - stdout.contains("Searching patches for GHSA: GHSA-1111-2222-3333"), - "--ghsa must announce a GHSA search; got: {stdout}" + !stdout.contains("Searching patches for"), + "search progress must not land on stdout; got: {stdout}" ); assert!( stderr.contains("by-ghsa/GHSA-1111-2222-3333"), diff --git a/crates/socket-patch-cli/tests/repair_invariants.rs b/crates/socket-patch-cli/tests/repair_invariants.rs index b63ae67e..86af601a 100644 --- a/crates/socket-patch-cli/tests/repair_invariants.rs +++ b/crates/socket-patch-cli/tests/repair_invariants.rs @@ -200,7 +200,7 @@ fn repair_redirect_only_project_human_mode_prints_note() { assert_eq!(out.status.code(), Some(0)); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("hosted redirects need no local repair"), + stdout.contains("Hosted redirects need no local repair"), "human mode must print the informational note; got stdout=\n{stdout}" ); } diff --git a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs index 818a8270..d4830cbd 100644 --- a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs @@ -572,7 +572,7 @@ fn path_target_matching_nothing_errors() { "a no-match path pattern must exit 1; stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("path pattern matched no patched packages") + stderr.contains("Path pattern matched no patched packages") && stderr.contains("no/such/dir"), "the error must name the pattern; stderr=\n{stderr}" ); @@ -719,7 +719,7 @@ fn invalid_glob_is_usage_error() { discovery); stdout=\n{stdout}\nstderr=\n{stderr}" ); assert!( - stderr.contains("invalid path pattern"), + stderr.to_lowercase().contains("invalid path pattern"), "stderr must name the problem; stderr=\n{stderr}" ); } diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index af9d4920..520c7e39 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -385,7 +385,7 @@ fn rollback_offline_missing_blob_human_names_package_and_remedy() { ); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("blob(s) are missing") && stderr.contains("--offline"), + stderr.contains("missing and --offline is set"), "stderr must explain the offline gate; stderr=\n{stderr}" ); assert!( @@ -394,7 +394,7 @@ fn rollback_offline_missing_blob_human_names_package_and_remedy() { ); let stdout = String::from_utf8_lossy(&out.stdout); assert!( - stdout.contains("Failed to rollback:") + stdout.contains("Failed to roll back:") && stdout.contains("pkg:npm/__rollback_test__@1.0.0"), "the human summary must name the failed package; stdout=\n{stdout}" ); @@ -462,12 +462,28 @@ fn rollback_undownloadable_blob_envelope_names_blob_and_remedy() { // The env is scrubbed (no SOCKET_API_TOKEN) and socket-cli config is // vetoed by the workspace-pinned SOCKET_NO_CONFIG=1, so every client - // build prints the no-token notice — it must appear exactly once per - // invocation, not once per internal phase. - let notes = stderr.matches("No SOCKET_API_TOKEN set").count(); + // build wants the no-token notice. Under --json it is muted entirely; + // in human mode it must appear exactly once per invocation, not once + // per internal phase. + assert!( + !stderr.contains("No SOCKET_API_TOKEN set"), + "--json must mute the no-token notice; stderr=\n{stderr}" + ); + let human = rollback_cmd(tmp.path()) + .env("SOCKET_TELEMETRY_DISABLED", "1") + .args([ + "--api-url", + "http://127.0.0.1:1/", + "--proxy-url", + "http://127.0.0.1:1/", + ]) + .output() + .expect("run socket-patch"); + let human_stderr = String::from_utf8_lossy(&human.stderr).to_string(); + let notes = human_stderr.matches("No SOCKET_API_TOKEN set").count(); assert_eq!( notes, 1, - "the no-token notice must print exactly once per run; stderr=\n{stderr}" + "the no-token notice must print exactly once per run; stderr=\n{human_stderr}" ); } diff --git a/crates/socket-patch-cli/tests/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan_paths_e2e.rs index 16436009..954c9ec3 100644 --- a/crates/socket-patch-cli/tests/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_paths_e2e.rs @@ -575,7 +575,7 @@ async fn paths_with_hosted_or_vendored_mode_exit_2() { "an invalid glob must be a usage error (exit 2); stdout={stdout}; stderr={stderr}" ); assert!( - stderr.contains("invalid path pattern"), + stderr.to_lowercase().contains("invalid path pattern"), "the error must name the invalid pattern; stderr={stderr}" ); } diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 908269eb..e0b3889e 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -737,6 +737,18 @@ async fn scan_vendor_detached_fetch_failure_reports_error() { "the failed fetch must be reported on stderr, not swallowed; \ stdout={stdout}; stderr={stderr}" ); + // With --yes no prompt was answered, so the header gets no blank line + // of its own (the listing's trailing blank line separates the sections). + assert!( + stderr.contains("Downloading 1 patch...") + && !stderr.starts_with('\n') + && !stderr.contains("\n\nDownloading"), + "no extra blank line before the header under --yes; stderr={stderr}" + ); + assert!( + stdout.contains("Nothing was vendored: 1 patch failed (see above)."), + "the run ends with the empty-run line; stdout={stdout}" + ); } #[tokio::test] diff --git a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs index fe21fa94..a13fb56f 100644 --- a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs @@ -35,13 +35,37 @@ const CURRENT: &str = env!("CARGO_PKG_VERSION"); /// 127.0.0.1 instead of leaking a request to real GitHub. const DEAD_BASE_URL: &str = "http://127.0.0.1:1"; +/// A project-local npm install (`/node_modules/...`) refuses too, +/// but must not suggest `npm update -g`: that updates some other, global +/// copy and leaves this one alone. +#[tokio::test] +async fn npm_project_local_refuses_with_local_hint() { + let install = staged_install_at("app/node_modules/@socketsecurity/socket-patch-x/bin"); + // A project is told apart from a prefix-less global install by the + // package.json beside its node_modules. + std::fs::write(install.root.path().join("app/package.json"), "{}").unwrap(); + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)], + ); + assert_eq!(code, 1, "managed install must refuse.\nstderr:\n{stderr}"); + assert!( + stderr.contains("`npm install @socketsecurity/socket-patch@latest`"), + "a project install must get the in-project upgrade command: {stderr}" + ); + assert!(!stderr.contains("npm update -g"), "{stderr}"); + assert!(stderr.starts_with("Error: This socket-patch binary ("), "{stderr}"); +} + /// An npm-bundled binary (any `node_modules` component) refuses with the /// npm upgrade command — and the refusal happens before ANY release /// traffic: a fully valid, newer release is mounted and its routes must /// never be hit. A wasted download before the refusal is the bug class. #[tokio::test] async fn npm_bundled_refuses_with_npm_hint() { - let install = staged_install_at("node_modules/@socketsecurity/socket-patch-x/bin"); + // A global install (`/lib/node_modules/...`): `npm update -g`. + let install = staged_install_at("lib/node_modules/@socketsecurity/socket-patch-x/bin"); let (served, _) = make_served_binary(); let release = FakeReleaseBuilder::new("9.9.9") .asset_for_current_target(&served) @@ -323,7 +347,7 @@ async fn force_override_warning_survives_json() { #[cfg(unix)] #[tokio::test] async fn symlinked_invocation_still_detected() { - let install = staged_install_at("node_modules/@socketsecurity/socket-patch-x/bin"); + let install = staged_install_at("lib/node_modules/@socketsecurity/socket-patch-x/bin"); let straight = install.root.path().join("straight"); std::fs::create_dir_all(&straight).expect("create symlink dir"); let link = straight.join("socket-patch"); diff --git a/crates/socket-patch-cli/tests/self_update_e2e.rs b/crates/socket-patch-cli/tests/self_update_e2e.rs index 29c604e5..0b34305d 100644 --- a/crates/socket-patch-cli/tests/self_update_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_e2e.rs @@ -55,8 +55,9 @@ async fn update_force_swaps_binary_end_to_end() { "update must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert!( - stdout.contains("Updated socket-patch"), - "human output must report the update: {stdout}" + // A --force install of the running version is a reinstall. + stdout.contains(&format!("Reinstalled socket-patch {CURRENT}")), + "human output must report the reinstall: {stdout}" ); // The installed file now IS the served payload… diff --git a/crates/socket-patch-cli/tests/self_update_failures_e2e.rs b/crates/socket-patch-cli/tests/self_update_failures_e2e.rs index 8602d712..63ebf37c 100644 --- a/crates/socket-patch-cli/tests/self_update_failures_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_failures_e2e.rs @@ -49,7 +49,7 @@ async fn checksum_mismatch_aborts_pre_extraction() { ); assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stderr.contains("checksum"), + stderr.contains("Error: Checksum verification failed"), "human error must name the checksum failure: {stderr}" ); @@ -220,7 +220,7 @@ async fn truncated_download_is_a_checksum_mismatch() { ); assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stderr.contains("checksum"), + stderr.contains("Error: Checksum verification failed"), "truncation must report as a checksum failure: {stderr}" ); @@ -252,7 +252,10 @@ async fn downgrade_refused_without_force() { ); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); assert!( - stdout.contains("already the latest"), + stdout.contains(&format!( + "socket-patch {} is newer than the latest release (0.0.1).", + env!("CARGO_PKG_VERSION") + )), "an older latest must read as a no-op, not an error: {stdout}" ); @@ -283,7 +286,8 @@ async fn explicit_pin_downgrades_without_force() { &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], ); assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); - assert!(stdout.contains("Updated socket-patch"), "{stdout}"); + // A pin below the running version reads as the downgrade it is. + assert!(stdout.contains("Downgraded socket-patch"), "{stdout}"); assert_eq!( sha256_file(&install.bin), served_hash, diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index 85834602..2bb71f37 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -549,9 +549,15 @@ fn setup_honors_exclude_for_a_workspace_member() { fn exclude_persistence_fails_closed_on_corrupt_manifest() { let proj = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); + // A real `packages/b` member: an `--exclude` that matches nothing is + // dropped before persistence, which would make this test vacuous. write( &proj.path().join("package.json"), - r#"{ "name": "root", "version": "1.0.0" }"#, + r#"{ "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }"#, + ); + write( + &proj.path().join("packages/b/package.json"), + r#"{ "name": "b", "version": "1.0.0" }"#, ); let manifest_path = proj.path().join(".socket/manifest.json"); let corrupt = r#"{ "patches": { "pkg:npm/left-pad@1.3.0": TRUNCATED-MID-WRITE"#; @@ -604,9 +610,15 @@ fn exclude_persistence_fails_closed_on_corrupt_manifest() { fn exclude_persistence_fails_closed_silently_under_silent() { let proj = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); + // A real `packages/b` member: an `--exclude` that matches nothing is + // dropped before persistence, which would make this test vacuous. write( &proj.path().join("package.json"), - r#"{ "name": "root", "version": "1.0.0" }"#, + r#"{ "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }"#, + ); + write( + &proj.path().join("packages/b/package.json"), + r#"{ "name": "b", "version": "1.0.0" }"#, ); let manifest_path = proj.path().join(".socket/manifest.json"); let corrupt = r#"{ "patches": { "pkg:npm/left-pad@1.3.0": TRUNCATED-MID-WRITE"#; diff --git a/crates/socket-patch-cli/tests/setup_terminal_output.rs b/crates/socket-patch-cli/tests/setup_terminal_output.rs new file mode 100644 index 00000000..68fa2806 --- /dev/null +++ b/crates/socket-patch-cli/tests/setup_terminal_output.rs @@ -0,0 +1,237 @@ +//! Terminal-output contract for `setup` / `setup --check` / `setup --remove`: +//! the advice a human gets, which stream it lands on, and the preview +//! layout. Host-only fixtures (no toolchains), hermetic runner. + +#[path = "common/mod.rs"] +mod common; + +use std::collections::HashMap; +use std::path::Path; + +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, +}; + +const WIRED_PACKAGE_JSON: &str = "{\"name\":\"root\",\"version\":\"1.0.0\",\"scripts\":{\"postinstall\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\",\"dependencies\":\"npx @socketsecurity/socket-patch apply --silent --ecosystems npm\"}}"; + +fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); +} + +fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { + common::run_with_env(cwd, args, &[("SOCKET_TELEMETRY_DISABLED", "1")]) +} + +/// Hooks wired, but the installed minimist file matches neither hash. +fn drifted_patch_fixture(cwd: &Path) { + write(&cwd.join("package.json"), WIRED_PACKAGE_JSON); + let pkg = cwd.join("node_modules/minimist"); + write( + &pkg.join("package.json"), + r#"{"name":"minimist","version":"1.2.5"}"#, + ); + write(&pkg.join("index.js"), "locally edited\n"); + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: "a".repeat(64), + after_hash: "b".repeat(64), + }, + ); + let mut vulns = HashMap::new(); + vulns.insert( + "GHSA-xvch-5gv4-984h".to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2021-44906".to_string()], + summary: "s".to_string(), + severity: "critical".to_string(), + description: "d".to_string(), + }, + ); + let mut m = PatchManifest::new(); + m.patches.insert( + "pkg:npm/minimist@1.2.5".to_string(), + PatchRecord { + uuid: "11111111-1111-4111-8111-111111111111".to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: vulns, + description: "p".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + }, + ); + write( + &cwd.join(".socket/manifest.json"), + &serde_json::to_string_pretty(&m).unwrap(), + ); +} + +#[test] +fn check_drifted_patch_points_at_apply_not_setup() { + let tmp = tempfile::tempdir().unwrap(); + drifted_patch_fixture(tmp.path()); + + let (code, stdout, stderr) = run(tmp.path(), &["setup", "--check"]); + assert_eq!(code, 1, "stdout=\n{stdout}\nstderr=\n{stderr}"); + assert!( + stdout.contains(" ✓ package.json (configured)"), + "stdout=\n{stdout}" + ); + assert!( + stdout.contains(" ✗ pkg:npm/minimist@1.2.5: patch not applied on disk (hash_mismatch)"), + "stdout=\n{stdout}" + ); + assert!( + stdout.trim_end().ends_with( + "1 patch is not applied on disk. Run `socket-patch apply` to re-apply the patches." + ), + "stdout=\n{stdout}" + ); + assert!( + !stdout.contains("socket-patch setup` to"), + "setup cannot fix drift; stdout=\n{stdout}" + ); + assert!(!stdout.contains("(s)"), "stdout=\n{stdout}"); + // The progress line is stderr chrome. + assert!( + stderr.contains("Searching for package.json"), + "stderr=\n{stderr}" + ); + assert!(!stdout.contains("Searching for"), "stdout=\n{stdout}"); +} + +#[test] +fn check_invalid_json_keeps_parser_detail_and_advice() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("package.json"), "{"); + let (code, stdout, _) = run(tmp.path(), &["setup", "--check"]); + assert_eq!(code, 1, "stdout=\n{stdout}"); + assert!( + stdout.contains(" ! package.json: Invalid package.json: EOF while parsing"), + "stdout=\n{stdout}" + ); + assert!( + stdout + .trim_end() + .ends_with("1 error. Fix the errors above, then re-run `socket-patch setup --check`."), + "stdout=\n{stdout}" + ); +} + +#[test] +fn setup_preview_errors_name_the_file() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("package.json"), + r#"{ "name": "root", "workspaces": ["packages/*"] }"#, + ); + write(&tmp.path().join("packages/bad/package.json"), "{"); + let (code, stdout, _) = run(tmp.path(), &["setup", "--dry-run"]); + assert_eq!(code, 1, "stdout=\n{stdout}"); + let norm = stdout.replace('\\', "/"); + assert!( + norm.contains("\nErrors:\n ! packages/bad/package.json: Invalid package.json: "), + "stdout=\n{stdout}" + ); + assert!( + !stdout.contains("\n\n\n"), + "no double blank lines: {stdout:?}" + ); + + let (_, _, stderr) = run(tmp.path(), &["setup", "--dry-run", "--silent"]); + let norm = stderr.replace('\\', "/"); + assert!( + norm.contains("Error: packages/bad/package.json: Invalid package.json: "), + "stderr=\n{stderr}" + ); +} + +#[test] +fn unsupported_ecosystem_filter_says_so() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("package.json"), WIRED_PACKAGE_JSON); + for args in [ + &["setup", "-e", "cargo"][..], + &["setup", "--check", "-e", "cargo"], + ] { + let (code, stdout, _) = run(tmp.path(), args); + assert_eq!(code, 0, "{args:?}: stdout=\n{stdout}"); + assert_eq!( + stdout.trim_end(), + "Setup has no install hook for: cargo (supported: npm, pypi, gem, composer)", + "{args:?}" + ); + } + let empty = tempfile::tempdir().unwrap(); + let (_, stdout, _) = run(empty.path(), &["setup", "-e", "npm"]); + assert_eq!(stdout.trim_end(), "No package.json project found"); +} + +#[test] +fn unmatched_exclude_warns() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("package.json"), + r#"{ "name": "root", "version": "1.0.0", "workspaces": ["packages/*"] }"#, + ); + write( + &tmp.path().join("packages/b/package.json"), + r#"{ "name": "b", "version": "1.0.0" }"#, + ); + let (code, _, stderr) = run( + tmp.path(), + &["setup", "--dry-run", "--exclude", "packages/b, nope"], + ); + assert_eq!(code, 0, "stderr=\n{stderr}"); + assert!( + stderr.contains("Warning: --exclude \"nope\" matched no workspace member"), + "stderr=\n{stderr}" + ); + assert!( + !stderr.contains("\"packages/b\" matched"), + "stderr=\n{stderr}" + ); + + let (_, _, stderr) = run( + tmp.path(), + &["setup", "--dry-run", "--silent", "--exclude", "nope"], + ); + assert!(stderr.is_empty(), "--silent mutes warnings: {stderr}"); +} + +#[test] +fn already_configured_run_prints_one_verdict() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("package.json"), WIRED_PACKAGE_JSON); + let (code, stdout, stderr) = run(tmp.path(), &["setup", "--yes"]); + assert_eq!(code, 0); + assert_eq!( + stdout.trim(), + "All install hooks are already configured with socket-patch!" + ); + assert_eq!(stderr.trim(), "Configuring socket-patch install hooks..."); +} + +#[test] +fn remove_dry_run_layout() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("package.json"), WIRED_PACKAGE_JSON); + let (code, stdout, _) = run(tmp.path(), &["setup", "--remove", "--dry-run"]); + assert_eq!(code, 0, "stdout=\n{stdout}"); + assert!( + !stdout.contains("\n\n\n"), + "no double blank lines: {stdout:?}" + ); + assert!( + stdout.ends_with( + " -> dependencies: (removed)\n\nSummary (dry run):\n 1 item would have \ + socket-patch removed\n" + ), + "{stdout:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/vex_terminal_output.rs b/crates/socket-patch-cli/tests/vex_terminal_output.rs new file mode 100644 index 00000000..9602d7c9 --- /dev/null +++ b/crates/socket-patch-cli/tests/vex_terminal_output.rs @@ -0,0 +1,412 @@ +//! Terminal-output contract for the standalone `vex` command: what a human +//! (and a `--json` consumer) sees for dry runs, `-O -`, stale-document +//! cleanup, omission reporting and flag typos. Fixtures are self-contained +//! tempdir projects driven through the built binary with a scrubbed +//! `SOCKET_*` environment (the parent env is never mutated). + +use std::collections::HashMap; +use std::path::Path; +use std::process::{Command, Output}; + +use serde_json::Value; +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, SetupConfig, VulnerabilityInfo, +}; + +const STALE_OPENVEX_DOC: &str = r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#; + +fn cli() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + for (key, _) in std::env::vars() { + if key.starts_with("SOCKET_") { + cmd.env_remove(key); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + cmd +} + +fn record(uuid: &str, ghsa: &str) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + "package/index.js".to_string(), + PatchFileInfo { + before_hash: "a".repeat(64), + after_hash: "b".repeat(64), + }, + ); + let mut vulns = HashMap::new(); + vulns.insert( + ghsa.to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2026-0001".to_string()], + summary: "s".to_string(), + severity: "high".to_string(), + description: "d".to_string(), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: vulns, + description: "p".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + } +} + +/// A manifest with `purls` (each with a distinct GHSA). `manual` declares +/// npm so the property-7 setup filter keeps the patches; `None` leaves the +/// ecosystem un-set-up. +fn write_manifest(cwd: &Path, purls: &[&str], manual: bool) { + let mut m = PatchManifest::new(); + for (i, purl) in purls.iter().enumerate() { + m.patches.insert( + purl.to_string(), + record( + &format!("{:08}-1111-4111-8111-111111111111", i + 1), + &format!("GHSA-test-{i}"), + ), + ); + } + if manual { + m.setup = Some(SetupConfig { + exclude: Vec::new(), + manual: vec!["npm".to_string()], + }); + } + let dir = cwd.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("manifest.json"), + serde_json::to_string_pretty(&m).unwrap(), + ) + .unwrap(); +} + +fn vex(cwd: &Path, extra: &[&str]) -> Output { + cli() + .current_dir(cwd) + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .args(extra) + .output() + .expect("invoke vex") +} + +fn stderr(o: &Output) -> String { + String::from_utf8_lossy(&o.stderr).into_owned() +} + +fn stdout(o: &Output) -> String { + String::from_utf8_lossy(&o.stdout).into_owned() +} + +#[test] +fn dry_run_with_output_writes_nothing_and_says_so() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + let out_path = cwd.join("d.json"); + + let o = vex( + cwd, + &["--no-verify", "--dry-run", "-O", out_path.to_str().unwrap()], + ); + assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); + assert!(!out_path.exists(), "--dry-run must not write the document"); + assert_eq!( + stdout(&o).trim_end(), + format!( + "[dry-run] Would write OpenVEX document with 1 statement to {}", + out_path.display() + ) + ); + + let o = vex( + cwd, + &[ + "--no-verify", + "--dry-run", + "--json", + "-O", + out_path.to_str().unwrap(), + ], + ); + assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); + let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); + assert_eq!(env["dryRun"], true, "{env}"); + assert!(!out_path.exists()); +} + +#[test] +fn dry_run_failure_keeps_previous_document() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + // Verify mode with nothing installed: nothing attests. + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + let out_path = cwd.join("keep.json"); + std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); + + let o = vex(cwd, &["--dry-run", "-O", out_path.to_str().unwrap()]); + assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); + assert!(out_path.exists(), "a dry run deletes nothing"); + assert!(!stderr(&o).contains("Removed the previous VEX document")); +} + +#[test] +fn output_dash_means_stdout() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + + let o = vex(cwd, &["--no-verify", "-O", "-"]); + assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); + let doc: Value = serde_json::from_slice(&o.stdout).expect("document on stdout"); + assert_eq!(doc["statements"].as_array().unwrap().len(), 1); + assert!(!cwd.join("-").exists(), "no file literally named `-`"); + assert_eq!(stderr(&o).trim_end(), "Emitted 1 VEX statement"); + + // --json still needs a real file: the envelope owns stdout. + let o = vex(cwd, &["--no-verify", "--json", "-O", "-"]); + assert_eq!(o.status.code(), Some(2)); + let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); + assert_eq!(env["error"]["code"], "json_requires_output", "{env}"); +} + +#[test] +fn written_document_ends_with_newline_and_summary_is_singular() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + let out_path = cwd.join("out.json"); + + let o = vex(cwd, &["--no-verify", "-O", out_path.to_str().unwrap()]); + assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); + let body = std::fs::read_to_string(&out_path).unwrap(); + assert!( + body.ends_with("}\n"), + "file must end with a newline: {body:?}" + ); + assert!(!body.ends_with("\n\n")); + assert_eq!( + stdout(&o).trim_end(), + format!( + "Wrote OpenVEX document with 1 statement to {}", + out_path.display() + ) + ); +} + +#[test] +fn failed_run_reports_stale_document_removal() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + let out_path = cwd.join("keep.json"); + std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); + + let o = vex(cwd, &["-O", out_path.to_str().unwrap()]); + assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); + assert!(!out_path.exists(), "failure contract: stale doc removed"); + let err = stderr(&o); + assert!( + err.contains(&format!( + "Warning: Removed the previous VEX document at {} (this run could not attest it).", + out_path.display() + )), + "{err}" + ); + // The error line is last. + assert!( + err.trim_end() + .ends_with("Error: No applied patches with vulnerability metadata to attest."), + "{err}" + ); + + std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); + let o = vex(cwd, &["--json", "-O", out_path.to_str().unwrap()]); + let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); + assert!( + env["warnings"] + .as_array() + .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_stale_doc_removed")), + "{env}" + ); + assert!( + !stderr(&o).contains("Removed the previous"), + "--json keeps stderr clean" + ); +} + +#[test] +fn omissions_are_sorted_and_listed_once() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purls = [ + "pkg:npm/zeta@1.0.0", + "pkg:npm/alpha@1.0.0", + "pkg:npm/mid@1.0.0", + ]; + write_manifest(cwd, &purls, true); + + let o = vex(cwd, &[]); + assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); + let lines: Vec = stderr(&o).lines().map(str::to_string).collect(); + assert_eq!( + lines, + vec![ + "Warning: omitting pkg:npm/alpha@1.0.0 from VEX: the package is not installed (package_not_found)", + "Warning: omitting pkg:npm/mid@1.0.0 from VEX: the package is not installed (package_not_found)", + "Warning: omitting pkg:npm/zeta@1.0.0 from VEX: the package is not installed (package_not_found)", + "Error: No applied patches with vulnerability metadata to attest.", + ] + ); + + // --silent mutes the warnings, so the error lists the omissions itself. + let o = vex(cwd, &["--silent"]); + assert_eq!(o.status.code(), Some(1)); + let lines: Vec = stderr(&o).lines().map(str::to_string).collect(); + assert_eq!( + lines, + vec![ + "Error: No applied patches with vulnerability metadata to attest.", + " omitted: pkg:npm/alpha@1.0.0 (package_not_found)", + " omitted: pkg:npm/mid@1.0.0 (package_not_found)", + " omitted: pkg:npm/zeta@1.0.0 (package_not_found)", + ] + ); + + // The JSON skipped events come out in the same order. + let out_path = cwd.join("o.json"); + let o = vex(cwd, &["--json", "-O", out_path.to_str().unwrap()]); + let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); + let order: Vec<&str> = env["events"] + .as_array() + .unwrap() + .iter() + .map(|e| e["purl"].as_str().unwrap()) + .collect(); + assert_eq!( + order, + vec![ + "pkg:npm/alpha@1.0.0", + "pkg:npm/mid@1.0.0", + "pkg:npm/zeta@1.0.0" + ] + ); + assert_eq!( + env["events"][0]["reason"], + "patch omitted from VEX: the package is not installed" + ); +} + +#[test] +fn all_setup_drops_skip_the_generic_note() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + // No `manual`, no hook: property 7 drops the (trusted) patch. + write_manifest(cwd, &["pkg:npm/a@1.0.0"], false); + + let o = vex(cwd, &["--no-verify"]); + assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); + let err = stderr(&o); + assert!(!err.contains("Note:"), "{err}"); + let lines: Vec<&str> = err.lines().collect(); + assert_eq!(lines.len(), 2, "{err}"); + assert!(lines[0].starts_with("Warning: omitting pkg:npm/a@1.0.0 from VEX: applied, but")); + assert!(lines[0].ends_with("(ecosystem_not_setup)"), "{err}"); + assert!( + lines[1].starts_with( + "Error: 1 applied patch with vulnerability metadata was omitted from VEX because \ + its ecosystem is not set up" + ), + "{err}" + ); +} + +#[test] +fn org_that_looks_like_a_file_warns() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + + let o = vex(cwd, &["--no-verify", "-o", "out.json"]); + assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); + assert!( + stderr(&o).contains( + "Warning: --org \"out.json\" looks like a file path; did you mean -O/--output?" + ), + "{}", + stderr(&o) + ); + let o = vex(cwd, &["--no-verify", "-o", "out.json", "--silent"]); + assert!(stderr(&o).is_empty(), "{}", stderr(&o)); +} + +#[test] +fn missing_manifest_suggests_next_step() { + let tmp = tempfile::tempdir().unwrap(); + let o = vex(tmp.path(), &[]); + assert_eq!(o.status.code(), Some(2)); + let expected = format!( + "Error: Manifest not found at {}. Run `socket-patch scan` or `socket-patch get` \ + first, or pass --manifest-path.\n", + tmp.path().join(".socket/manifest.json").display() + ); + assert_eq!(stderr(&o), expected); +} + +/// A user who already passed `--manifest-path` is not told to pass it. +#[test] +fn missing_custom_manifest_does_not_suggest_manifest_path() { + let tmp = tempfile::tempdir().unwrap(); + let o = vex(tmp.path(), &["--manifest-path", "nope.json"]); + assert_eq!(o.status.code(), Some(2)); + let expected = format!( + "Error: Manifest not found at {}. Run `socket-patch scan` or `socket-patch get` \ + first.\n", + tmp.path().join("nope.json").display() + ); + assert_eq!(stderr(&o), expected); +} + +#[test] +fn corrupt_manifest_error_names_the_file() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::create_dir_all(cwd.join(".socket")).unwrap(); + std::fs::write(cwd.join(".socket/manifest.json"), "{not json").unwrap(); + let o = vex(cwd, &[]); + assert_eq!(o.status.code(), Some(2)); + let err = stderr(&o); + assert!( + err.starts_with("Error: Failed to parse manifest JSON: "), + "{err}" + ); + assert!(err.contains(".socket/manifest.json)"), "{err}"); +} + +#[test] +fn product_undetected_names_the_unusable_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_manifest(cwd, &["pkg:npm/a@1.0.0"], true); + std::fs::write(cwd.join("package.json"), r#"{"name":"app"}"#).unwrap(); + let o = cli() + .args(["vex", "--no-verify", "--cwd", cwd.to_str().unwrap()]) + .output() + .unwrap(); + assert_eq!(o.status.code(), Some(2), "{}", stderr(&o)); + assert!( + stderr(&o).contains("(package.json was found but has no usable name and version)"), + "{}", + stderr(&o) + ); +} diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index f984eb74..03eea3e2 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -301,52 +301,148 @@ async fn fetch_missing_diff_archives( } } -/// Format a [`FetchMissingBlobsResult`] as a human-readable string. -pub fn format_fetch_result(result: &FetchMissingBlobsResult) -> String { - if result.total == 0 { - return "All blobs are present locally.".to_string(); - } +/// What kind of artifact a fetch or cleanup result counts, for human +/// output: the singular/plural noun and whether ids are long enough to be +/// worth abbreviating (64-hex blob hashes are; patch UUIDs are the lookup +/// key a user greps for, so they print in full). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArtifactNoun { + pub one: &'static str, + pub many: &'static str, + pub abbreviate_ids: bool, +} - let mut lines: Vec = Vec::new(); +impl ArtifactNoun { + /// `"1 blob"` / `"2 blobs"` / `"0 blobs"`. + pub fn count(&self, n: usize) -> String { + format!("{n} {}", if n == 1 { self.one } else { self.many }) + } - if result.downloaded > 0 { - lines.push(format!("Downloaded {} blob(s)", result.downloaded)); + /// An id as listed under a result: abbreviated to 12 characters plus + /// `...` only when that actually shortens it. Counts characters, not + /// bytes: ids are unvalidated manifest strings, and a byte slice + /// panics when index 12 lands inside a multibyte char. + pub fn display_id(&self, id: &str) -> String { + const SHORT: usize = 12; + if self.abbreviate_ids && id.chars().count() > SHORT { + format!("{}...", id.chars().take(SHORT).collect::()) + } else { + id.to_string() + } } +} - if result.skipped > 0 { - lines.push(format!( - "{} blob(s) already present locally", - result.skipped - )); +/// Per-file content blobs (`.socket/blobs/`). +pub const BLOB: ArtifactNoun = ArtifactNoun { + one: "blob", + many: "blobs", + abbreviate_ids: true, +}; + +/// Per-patch diff archives (`.socket/diffs/.tar.gz`). +pub const DIFF_ARCHIVE: ArtifactNoun = ArtifactNoun { + one: "diff archive", + many: "diff archives", + abbreviate_ids: false, +}; + +/// Per-patch package archives (`.socket/packages/.tar.gz`). +pub const PACKAGE_ARCHIVE: ArtifactNoun = ArtifactNoun { + one: "package archive", + many: "package archives", + abbreviate_ids: false, +}; + +impl DownloadMode { + /// The artifact noun a download in this mode fetches. + pub fn noun(&self) -> ArtifactNoun { + match self { + DownloadMode::Diff => DIFF_ARCHIVE, + DownloadMode::File => BLOB, + } } +} - if result.failed > 0 { - lines.push(format!("Failed to download {} blob(s)", result.failed)); +/// How many failures a fetch result lists before "... and N more". +const MAX_LISTED_FAILURES: usize = 5; - let failed_results: Vec<&BlobFetchResult> = - result.results.iter().filter(|r| !r.success).collect(); +/// Format a [`FetchMissingBlobsResult`] of per-file blobs as a +/// human-readable string (see [`format_fetch_result_for`]). +pub fn format_fetch_result(result: &FetchMissingBlobsResult) -> String { + format_fetch_result_for(result, BLOB) +} - for r in failed_results.iter().take(5) { - // Truncate by characters, not bytes: the hash field carries - // arbitrary manifest strings, and a byte slice panics when index - // 12 lands inside a multibyte char. - let short_hash: String = r.hash.chars().take(12).collect(); - let err = r.error.as_deref().unwrap_or("unknown error"); - lines.push(format!(" - {}...: {}", short_hash, err)); - } +/// Format a [`FetchMissingBlobsResult`] counting `noun`s: the success +/// counts first, then the failures sorted by id (the result's order comes +/// from a `HashSet`), at most five listed. +pub fn format_fetch_result_for(result: &FetchMissingBlobsResult, noun: ArtifactNoun) -> String { + let mut lines = format_fetch_successes(result, noun); + lines.extend(format_fetch_failures(result, noun)); + if lines.is_empty() { + // `total > 0` with nothing downloaded, skipped, or failed should + // not be reachable; never emit a misleading blank string. + return format!("All {} are present locally.", noun.many); + } + lines.join("\n") +} - if failed_results.len() > 5 { - lines.push(format!(" ... and {} more", failed_results.len() - 5)); - } +/// The success half of [`format_fetch_result_for`] ("Downloaded 2 blobs", +/// "1 blob already present locally"), for callers that route failures to +/// a different stream. Empty when nothing succeeded. +pub fn format_fetch_successes(result: &FetchMissingBlobsResult, noun: ArtifactNoun) -> Vec { + let mut lines = Vec::new(); + if result.downloaded > 0 { + lines.push(format!("Downloaded {}", noun.count(result.downloaded))); + } + if result.skipped > 0 { + lines.push(format!( + "{} already present locally", + noun.count(result.skipped) + )); } + lines +} - // `total > 0` but nothing downloaded, skipped, or failed should not be - // reachable, but guard against emitting a misleading blank string. - if lines.is_empty() { - return "All blobs are present locally.".to_string(); +/// The failure half of [`format_fetch_result_for`]: a "Failed to download +/// N s" header and up to five ` - : ` lines, sorted by +/// id. Empty when nothing failed. +pub fn format_fetch_failures(result: &FetchMissingBlobsResult, noun: ArtifactNoun) -> Vec { + if result.failed == 0 { + return Vec::new(); + } + let mut lines = vec![format!("Failed to download {}", noun.count(result.failed))]; + let mut failed: Vec<&BlobFetchResult> = result.results.iter().filter(|r| !r.success).collect(); + failed.sort_by(|a, b| a.hash.cmp(&b.hash)); + for r in failed.iter().take(MAX_LISTED_FAILURES) { + let err = r.error.as_deref().unwrap_or("unknown error"); + lines.push(format!( + " - {}: {}", + noun.display_id(&r.hash), + concise_fetch_error(err, &r.hash) + )); } + if failed.len() > MAX_LISTED_FAILURES { + lines.push(format!( + " ... and {} more", + failed.len() - MAX_LISTED_FAILURES + )); + } + lines +} - lines.join("\n") +/// Drop the id the client's error repeats: the line already starts with +/// it, so `Network error fetching diff : ` reads as +/// `network error: `. Anything else is returned unchanged. +fn concise_fetch_error<'a>(err: &'a str, id: &str) -> std::borrow::Cow<'a, str> { + if let Some(rest) = err.strip_prefix("Network error fetching ") { + // ` : ` + if let Some((_kind, tail)) = rest.split_once(' ') { + if let Some(cause) = tail.strip_prefix(id).and_then(|t| t.strip_prefix(": ")) { + return format!("network error: {cause}").into(); + } + } + } + err.into() } // ── Internal helpers ────────────────────────────────────────────────── @@ -630,10 +726,10 @@ mod tests { ], }; let output = format_fetch_result(&result); - assert!(output.contains("Downloaded 2 blob(s)")); - assert!(output.contains("Failed to download 1 blob(s)")); - assert!(output.contains("cccccccccccc...")); - assert!(output.contains("Blob not found on server")); + assert_eq!( + output, + "Downloaded 2 blobs\nFailed to download 1 blob\n - cccccccccccc...: Blob not found on server" + ); } #[test] @@ -685,7 +781,7 @@ mod tests { ], }; let output = format_fetch_result(&result); - assert!(output.contains("Downloaded 3 blob(s)")); + assert_eq!(output, "Downloaded 3 blobs"); assert!(!output.contains("Failed")); } @@ -703,8 +799,8 @@ mod tests { }], }; let output = format_fetch_result(&result); - // Hash is < 12 chars, should show full hash - assert!(output.contains("abc...")); + // Hash is < 12 chars: shown in full, with no false ellipsis. + assert_eq!(output, "Failed to download 1 blob\n - abc: not found"); } #[test] @@ -728,7 +824,7 @@ mod tests { }], }; let output = format_fetch_result(&result); - assert!(output.contains("Failed to download 1 blob(s)")); + assert!(output.contains("Failed to download 1 blob\n")); assert!( output.contains("aaaaaaaaaaa→..."), "12-char prefix expected: {output:?}" @@ -879,7 +975,7 @@ mod tests { }; let output = format_fetch_result(&result); assert!(!output.trim().is_empty(), "must not be blank: {:?}", output); - assert!(output.contains("2 blob(s) already present")); + assert_eq!(output, "2 blobs already present locally"); assert!(!output.contains("Downloaded")); assert!(!output.contains("Failed")); } @@ -910,8 +1006,7 @@ mod tests { ], }; let output = format_fetch_result(&result); - assert!(output.contains("Downloaded 1 blob(s)")); - assert!(output.contains("2 blob(s) already present")); + assert_eq!(output, "Downloaded 1 blob\n2 blobs already present locally"); } // ── Regression: hash comparison is case-insensitive ────────────── @@ -1066,6 +1161,139 @@ mod tests { }; let output = format_fetch_result(&result); assert!(!output.contains("Downloaded")); - assert!(output.contains("Failed to download 2 blob(s)")); + assert!( + output.starts_with("Failed to download 2 blobs\n"), + "{output}" + ); + } + + fn failure(id: &str, error: &str) -> BlobFetchResult { + BlobFetchResult { + hash: id.to_string(), + success: false, + error: Some(error.to_string()), + } + } + + fn failed_result(results: Vec) -> FetchMissingBlobsResult { + FetchMissingBlobsResult { + total: results.len(), + failed: results.len(), + results, + ..Default::default() + } + } + + #[test] + fn artifact_noun_counts_singular_and_plural() { + assert_eq!(BLOB.count(0), "0 blobs"); + assert_eq!(BLOB.count(1), "1 blob"); + assert_eq!(BLOB.count(2), "2 blobs"); + assert_eq!(DIFF_ARCHIVE.count(1), "1 diff archive"); + assert_eq!(DIFF_ARCHIVE.count(3), "3 diff archives"); + assert_eq!(PACKAGE_ARCHIVE.count(1), "1 package archive"); + assert_eq!(DownloadMode::Diff.noun(), DIFF_ARCHIVE); + assert_eq!(DownloadMode::File.noun(), BLOB); + } + + #[test] + fn display_id_abbreviates_only_long_blob_hashes() { + assert_eq!(BLOB.display_id(&"a".repeat(64)), "aaaaaaaaaaaa..."); + // Exactly 12 characters: nothing cut, so no ellipsis. + assert_eq!(BLOB.display_id("abcdefabcdef"), "abcdefabcdef"); + assert_eq!(BLOB.display_id("22"), "22"); + assert_eq!(BLOB.display_id(""), ""); + // Multibyte: counted in chars, never sliced mid-char. + assert_eq!( + BLOB.display_id(&"é".repeat(13)), + format!("{}...", "é".repeat(12)) + ); + // UUIDs are the lookup key: always in full. + let uuid = "11111111-1111-4111-8111-111111111111"; + assert_eq!(DIFF_ARCHIVE.display_id(uuid), uuid); + } + + #[test] + fn diff_mode_result_names_diff_archives_and_full_uuids_sorted() { + let result = failed_result(vec![ + failure( + "22222222-2222-4222-8222-222222222222", + "Diff archive not found on server", + ), + failure( + "11111111-1111-4111-8111-111111111111", + "Network error fetching diff 11111111-1111-4111-8111-111111111111: \ + error sending request for url (http://127.0.0.1:9/patch/diff/x)", + ), + ]); + assert_eq!( + format_fetch_result_for(&result, DIFF_ARCHIVE), + "Failed to download 2 diff archives\n\ + \x20 - 11111111-1111-4111-8111-111111111111: network error: \ + error sending request for url (http://127.0.0.1:9/patch/diff/x)\n\ + \x20 - 22222222-2222-4222-8222-222222222222: Diff archive not found on server" + ); + let empty = FetchMissingBlobsResult::default(); + assert_eq!( + format_fetch_result_for(&empty, DIFF_ARCHIVE), + "All diff archives are present locally." + ); + } + + #[test] + fn failures_are_listed_in_sorted_order_regardless_of_input_order() { + let ids = ["e", "b", "a", "d", "c", "g", "f"]; + let result = failed_result(ids.iter().map(|id| failure(id, "x")).collect()); + assert_eq!( + format_fetch_failures(&result, BLOB), + vec![ + "Failed to download 7 blobs", + " - a: x", + " - b: x", + " - c: x", + " - d: x", + " - e: x", + " ... and 2 more", + ] + ); + } + + #[test] + fn successes_and_failures_split_cleanly() { + let mut result = failed_result(vec![failure("abc", "boom")]); + result.total = 3; + result.downloaded = 1; + result.skipped = 1; + assert_eq!( + format_fetch_successes(&result, BLOB), + vec!["Downloaded 1 blob", "1 blob already present locally"] + ); + assert_eq!( + format_fetch_failures(&result, BLOB), + vec!["Failed to download 1 blob", " - abc: boom"] + ); + assert!(format_fetch_failures(&FetchMissingBlobsResult::default(), BLOB).is_empty()); + assert!(format_fetch_successes(&FetchMissingBlobsResult::default(), BLOB).is_empty()); + } + + #[test] + fn concise_fetch_error_drops_only_the_repeated_id() { + assert_eq!( + concise_fetch_error("Network error fetching blob abc: timed out", "abc"), + "network error: timed out" + ); + // A different id (or any other shape) is left alone. + assert_eq!( + concise_fetch_error("Network error fetching blob zzz: timed out", "abc"), + "Network error fetching blob zzz: timed out" + ); + assert_eq!( + concise_fetch_error("Blob not found on server", "abc"), + "Blob not found on server" + ); + assert_eq!( + concise_fetch_error("Network error fetching ", "abc"), + "Network error fetching " + ); } } diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 1faaaf4a..a7d173a5 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::sync::atomic::AtomicBool; use reqwest::header::{self, HeaderMap, HeaderValue}; use reqwest::StatusCode; @@ -12,8 +13,34 @@ use crate::api::ranking::{cmp_batch_infos, cmp_search_results}; use crate::api::types::*; use crate::constants::USER_AGENT as USER_AGENT_VALUE; use crate::utils::env_compat::{is_debug_enabled, is_offline_env, proxy_url_from_env}; +use crate::utils::notice::{notice_once, Notice}; use crate::utils::socket_cli_config; +// Each client advisory prints at most once per process: commands build +// several clients (telemetry, discovery, download) in one run. +static PROXY_NOTICE_SHOWN: AtomicBool = AtomicBool::new(false); +static TOKEN_SHAPE_SHOWN: AtomicBool = AtomicBool::new(false); +static ORG_DETECT_SHOWN: AtomicBool = AtomicBool::new(false); +static MULTI_ORG_SHOWN: AtomicBool = AtomicBool::new(false); + +/// A transport error with its whole cause chain. reqwest's `Display` stops +/// at "error sending request for url (...)", dropping the part a user can +/// act on ("Connection refused", a DNS or TLS failure). Causes already +/// spelled out by an outer message are skipped. +fn network_error_detail(e: &reqwest::Error) -> String { + let mut msg = e.to_string(); + let mut source = std::error::Error::source(e); + while let Some(cause) = source { + let part = cause.to_string(); + if !part.is_empty() && !msg.contains(&part) { + msg.push_str(": "); + msg.push_str(&part); + } + source = cause.source(); + } + msg +} + /// Log debug messages when debug mode is enabled. fn debug_log(message: &str) { if is_debug_enabled() { @@ -132,12 +159,9 @@ impl ApiClient { let url = format!("{}{}", self.api_url, path); debug_log(&format!("GET {}", url)); - let resp = self - .client - .get(&url) - .send() - .await - .map_err(|e| ApiError::Network(format!("Network error: {}", e)))?; + let resp = self.client.get(&url).send().await.map_err(|e| { + ApiError::Network(format!("Network error: {}", network_error_detail(&e))) + })?; Self::handle_json_response(resp, self.use_public_proxy).await } @@ -158,7 +182,9 @@ impl ApiClient { .json(body) .send() .await - .map_err(|e| ApiError::Network(format!("Network error: {}", e)))?; + .map_err(|e| { + ApiError::Network(format!("Network error: {}", network_error_detail(&e))) + })?; Self::handle_json_response(resp, self.use_public_proxy).await } @@ -390,7 +416,9 @@ impl ApiClient { .json(&body) .send() .await - .map_err(|e| ApiError::Network(format!("Network error: {}", e)))?; + .map_err(|e| { + ApiError::Network(format!("Network error: {}", network_error_detail(&e))) + })?; let status = resp.status(); @@ -594,7 +622,9 @@ impl ApiClient { .map_err(|e| { ApiError::Network(format!( "Network error fetching {} {}: {}", - kind, identifier, e + kind, + identifier, + network_error_detail(&e) )) })?; @@ -822,7 +852,9 @@ impl ApiClient { .await }; - let resp = resp.map_err(|e| ApiError::Network(format!("Network error: {e}")))?; + let resp = resp.map_err(|e| { + ApiError::Network(format!("Network error: {}", network_error_detail(&e))) + })?; let status = resp.status(); if status == StatusCode::OK { let parsed = resp @@ -863,7 +895,8 @@ impl ApiClient { Ok(r) => r, Err(e) => { return ServeDownload::Failed(ApiError::Network(format!( - "Network error fetching vendor package: {e}" + "Network error fetching vendor package: {}", + network_error_detail(&e) ))) } }; @@ -1083,22 +1116,46 @@ pub fn resolve_ambient_credentials( api_token: Option, org_slug: Option, ) -> (Option, Option) { - let api_token = api_token.filter(|t| !t.is_empty()).or_else(|| { - if socket_cli_config::no_api_token_veto() { + let (api_token, _, org_slug) = resolve_credentials_with_origin(api_token, org_slug); + (api_token, org_slug) +} + +/// [`resolve_ambient_credentials`], also reporting which layer the token +/// came from, for messages that must say which token is wrong. (The CLI's +/// `--api-token` flag is also filled from SOCKET_API_TOKEN by clap, so an +/// override equal to that env var is reported as the env var.) +fn resolve_credentials_with_origin( + api_token: Option, + org_slug: Option, +) -> (Option, TokenSource, Option) { + let env_value = std::env::var("SOCKET_API_TOKEN") + .ok() + .filter(|t| !t.is_empty()); + let (api_token, origin) = match api_token.filter(|t| !t.is_empty()) { + Some(t) => { + let origin = if env_value.as_deref() == Some(t.as_str()) { + TokenSource::Env + } else { + TokenSource::Flag + }; + (Some(t), origin) + } + None if socket_cli_config::no_api_token_veto() => { debug_log("api token: suppressed by SOCKET_NO_API_TOKEN"); - return None; + (None, TokenSource::Env) } - std::env::var("SOCKET_API_TOKEN") - .ok() - .filter(|t| !t.is_empty()) - .or_else(|| { - socket_cli_config::load() + None => match env_value { + Some(t) => (Some(t), TokenSource::Env), + None => { + let t = socket_cli_config::load() .and_then(|c| c.api_token.clone()) .inspect(|_| { debug_log("api token: from socket-cli config (`socket login`)"); - }) - }) - }); + }); + (t, TokenSource::Config) + } + }, + }; let org_slug = org_slug .filter(|s| !s.is_empty()) // Treat an empty slug as "not provided" (mirroring the api_token @@ -1117,7 +1174,7 @@ pub fn resolve_ambient_credentials( debug_log(&format!("org slug: `{slug}` from socket-cli config")); }) }); - (api_token, org_slug) + (api_token, origin, org_slug) } /// Like [`get_api_client_from_env`] but with explicit overrides for every @@ -1126,8 +1183,8 @@ pub fn resolve_ambient_credentials( /// `--api-token`, `--org`, `--proxy-url` flags via [`crate::utils`] in the /// CLI crate. pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> (ApiClient, bool) { - let (api_token, resolved_org_slug) = - resolve_ambient_credentials(overrides.api_token, overrides.org_slug); + let (api_token, origin, resolved_org_slug) = + resolve_credentials_with_origin(overrides.api_token, overrides.org_slug); if api_token.is_none() { let proxy_url = overrides @@ -1142,11 +1199,12 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // mirrored into SOCKET_OFFLINE (normalized to "1") before any // client is built. if !is_offline_env() { - eprintln!( + notice_once(Notice::Info, &PROXY_NOTICE_SHOWN, || { "No SOCKET_API_TOKEN set (and no socket-cli login found) — using the \ public patch API proxy (free patches only). Run `socket login` or set \ SOCKET_API_TOKEN to access org patches." - ); + .to_string() + }); } let client = ApiClient::new(ApiClientOptions { api_url: proxy_url, @@ -1160,8 +1218,8 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // Shape check the configured token before the network round-trip so // a "you set the hash, not the token" mistake is loud and immediate. if let Some(ref t) = api_token { - if let Some(msg) = validate_token_shape(t) { - eprintln!("{msg}"); + if let Some(msg) = validate_token_shape(t, origin) { + notice_once(Notice::Warning, &TOKEN_SHAPE_SHOWN, || msg); } } @@ -1190,19 +1248,23 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> match client.resolve_org_slug().await { Ok(slug) => client.org_slug = Some(slug), Err(e) => { - eprintln!("Warning: Could not auto-detect organization: {e}"); - if matches!(e, ApiError::Unauthorized(_)) { - if let Some(t) = client.api_token.as_deref() { - if looks_like_token_hash(t) { - eprintln!( - " Hint: SOCKET_API_TOKEN starts with `{}-` \ - which is the stored hash format. Set it to \ - the raw `sktsec_..._api` value instead.", - t.split('-').next().unwrap_or("sha512") - ); + notice_once(Notice::Warning, &ORG_DETECT_SHOWN, || { + let mut msg = format!("Warning: Could not auto-detect organization: {e}"); + if matches!(e, ApiError::Unauthorized(_)) { + if let Some(t) = client.api_token.as_deref() { + if looks_like_token_hash(t) { + msg.push_str(&format!( + "\n Hint: {} starts with `{}-`, which is the \ + stored hash format. Set it to the raw \ + `sktsec_..._api` value instead.", + origin.label(), + t.split('-').next().unwrap_or("sha512") + )); + } } } - } + msg + }); } } } @@ -1257,7 +1319,7 @@ fn looks_like_token_hash(token: &str) -> bool { /// The returned message redacts the middle of the token (first 8 + /// last 4 chars) so a real token doesn't leak into stderr if a user /// pastes one with a wrong suffix. -fn validate_token_shape(token: &str) -> Option { +fn validate_token_shape(token: &str, source: TokenSource) -> Option { let has_prefix = token.starts_with("sktsec_"); let has_suffix = token.ends_with("_api") || token.ends_with("_agent"); // Measure in characters, not bytes: the preview/length reporting below @@ -1286,13 +1348,35 @@ fn validate_token_shape(token: &str) -> Option { "" }; Some(format!( - "Warning: SOCKET_API_TOKEN does not look like a Socket API token \ + "Warning: {} does not look like a Socket API token \ (expected `sktsec_<44 chars>_api`).{hash_hint}\n \ Got: {preview} ({len} chars). Continuing anyway; the server may \ - reject this with 401." + reject this with 401.", + source.label() )) } +/// Where the resolved API token came from (named in token warnings). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TokenSource { + /// The CLI's `--api-token` flag (an explicit override). + Flag, + /// The `SOCKET_API_TOKEN` environment variable. + Env, + /// The socket-cli config file written by `socket login`. + Config, +} + +impl TokenSource { + fn label(self) -> &'static str { + match self { + TokenSource::Flag => "--api-token", + TokenSource::Env => "SOCKET_API_TOKEN", + TokenSource::Config => "The socket-cli login token (`socket login`)", + } + } +} + /// Classify an [`ApiError`] as a candidate for the auth → proxy /// fallback. We only re-route on 401/403 (the stale-credentials /// signals). Network errors, rate limits, 404s, and 5xx surface as-is @@ -1384,12 +1468,14 @@ fn select_org_slug(mut orgs: Vec) -> Result _ => { let slugs: Vec<_> = orgs.iter().map(|o| o.slug.as_str()).collect(); let first = orgs[0].slug.clone(); - eprintln!( - "Multiple organizations found: {}. Using \"{}\". \ - Pass --org to select a different one.", - slugs.join(", "), - first - ); + notice_once(Notice::Info, &MULTI_ORG_SHOWN, || { + format!( + "Multiple organizations found: {}. Using \"{}\". \ + Pass --org to select a different one.", + slugs.join(", "), + first + ) + }); Ok(first) } } @@ -1724,7 +1810,7 @@ mod tests { #[test] fn test_severity_order_moderate_is_medium_tier() { // Regression: GHSA emits `moderate` for the medium tier (the same - // convention output.rs `format_severity` and get.rs `severity_rank` + // convention the CLI's `ui::severity` and get.rs `severity_rank` // already follow). The moderate-blind ordering lumped it in with // "unknown" (rank 4), ranking it *below* low. assert_eq!( @@ -2295,20 +2381,20 @@ mod tests { // matching the server's SOCKET_TOKEN_REGEXP. let raw = format!("sktsec_{}_api", "x".repeat(44)); assert_eq!(raw.len(), 55); - assert!(validate_token_shape(&raw).is_none()); + assert!(validate_token_shape(&raw, TokenSource::Env).is_none()); } #[test] fn validate_token_shape_accepts_agent_token() { let raw = format!("sktsec_{}_agent", "x".repeat(44)); - assert!(validate_token_shape(&raw).is_none()); + assert!(validate_token_shape(&raw, TokenSource::Env).is_none()); } #[test] fn validate_token_shape_flags_sha512_hash() { let hash = "sha512-7aegAloeNsCqF1mpNL2J9MJ2dpIxQEwgKvXPml8XY2rrV2Za+\ bfj0yhG7RcqvqqLZ4iAH/drJjHjOqFkTGhddg=="; - let msg = validate_token_shape(hash).expect("hash must be flagged"); + let msg = validate_token_shape(hash, TokenSource::Env).expect("hash must be flagged"); assert!( msg.contains("does not look like a Socket API token"), "missing core warning; got: {msg}" @@ -2330,7 +2416,8 @@ mod tests { #[test] fn validate_token_shape_flags_too_short() { - let msg = validate_token_shape("sktsec_abc_api").expect("short token must be flagged"); + let msg = validate_token_shape("sktsec_abc_api", TokenSource::Env) + .expect("short token must be flagged"); assert!(msg.contains("does not look like a Socket API token")); assert!(!msg.contains("SRI-format hash")); } @@ -2338,7 +2425,55 @@ mod tests { #[test] fn validate_token_shape_flags_missing_suffix() { let raw = format!("sktsec_{}", "x".repeat(50)); - assert!(validate_token_shape(&raw).is_some()); + assert!(validate_token_shape(&raw, TokenSource::Env).is_some()); + } + + #[test] + fn validate_token_shape_names_the_token_source() { + for (source, head) in [ + (TokenSource::Flag, "Warning: --api-token does not look like"), + ( + TokenSource::Env, + "Warning: SOCKET_API_TOKEN does not look like", + ), + ( + TokenSource::Config, + "Warning: The socket-cli login token (`socket login`) does not look like", + ), + ] { + let msg = validate_token_shape("sktsec_abc_api", source).expect("flagged"); + assert!(msg.starts_with(head), "{msg}"); + } + } + + #[tokio::test] + async fn network_error_detail_keeps_the_root_cause() { + // A port that was just free: connecting is refused, deterministically. + let port = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let err = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .expect_err("nothing listens there"); + let top = err.to_string(); + let detail = network_error_detail(&err); + assert!(detail.starts_with(&top), "{detail}"); + assert!( + detail.len() > top.len(), + "the cause chain must be appended: {detail}" + ); + assert!( + detail.to_lowercase().contains("refused"), + "the actionable cause must survive: {detail}" + ); + // No cause is repeated. + let parts: Vec<&str> = detail.split(": ").collect(); + for (i, p) in parts.iter().enumerate() { + assert!(!parts[i + 1..].contains(p), "{detail}"); + } } #[test] @@ -2355,7 +2490,8 @@ mod tests { assert_eq!(token.chars().count(), 21); assert_ne!(token.len(), token.chars().count(), "must be multi-byte"); - let msg = validate_token_shape(&token).expect("non-canonical token must be flagged"); + let msg = validate_token_shape(&token, TokenSource::Env) + .expect("non-canonical token must be flagged"); assert!( msg.contains("(21 chars)"), "length must be reported in characters; got: {msg}" diff --git a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs index 8b364be9..bd572ab8 100644 --- a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::path::Path; +use crate::api::blob_fetcher::{ArtifactNoun, BLOB}; use crate::manifest::operations::get_after_hash_blobs; use crate::manifest::schema::PatchManifest; @@ -148,35 +149,65 @@ pub async fn cleanup_unused_archives( .await } -/// Formats the cleanup result for human-readable output. +/// Formats a blob cleanup result for human-readable output (see +/// [`format_cleanup_result_for`]). pub fn format_cleanup_result(result: &CleanupResult, dry_run: bool) -> String { + format_cleanup_result_for(result, dry_run, BLOB) +} + +/// Formats a cleanup result counting `noun`s: "Removed 2 unused diff +/// archives (3 B freed)", and under a dry run the sorted list of what +/// would go ("Unused diff archives:" then ` - ` lines; the +/// directory walk's order is not stable). +pub fn format_cleanup_result_for( + result: &CleanupResult, + dry_run: bool, + noun: ArtifactNoun, +) -> String { if result.blobs_checked == 0 { // Absent directory, or one holding no regular non-hidden files. - return "No blobs to clean up.".to_string(); + return format!("No {} to clean up.", noun.many); } if result.blobs_removed == 0 { - return format!("Checked {} blob(s), all are in use.", result.blobs_checked); + return format_all_in_use(&[noun.count(result.blobs_checked)], result.blobs_checked); } let action = if dry_run { "Would remove" } else { "Removed" }; let bytes_formatted = format_bytes(result.bytes_freed); + let unused = noun + .count(result.blobs_removed) + .replacen(' ', " unused ", 1); - let mut output = format!( - "{} {} unused blob(s) ({} freed)", - action, result.blobs_removed, bytes_formatted - ); + let mut output = format!("{action} {unused} ({bytes_formatted} freed)"); if dry_run && !result.removed_blobs.is_empty() { - output.push_str("\nUnused blobs:"); - for blob in &result.removed_blobs { - output.push_str(&format!("\n - {}", blob)); + let mut names: Vec<&String> = result.removed_blobs.iter().collect(); + names.sort(); + output.push_str(&format!("\nUnused {}:", noun.many)); + for name in names { + output.push_str(&format!("\n - {name}")); } } output } +/// The "nothing unused" line, shared by every cleanup caller so the wording +/// matches across commands: `parts` are the counted kinds checked ("2 +/// blobs", "1 diff archive"), joined as "a, b and c"; `total` is the item +/// count across all of them, which picks "in use" (one item) or "all in +/// use". "Checked 1 blob: in use." / "Checked 5 blobs: all in use." +pub fn format_all_in_use(parts: &[String], total: usize) -> String { + let list = match parts { + [] => String::new(), + [one] => one.clone(), + [init @ .., last] => format!("{} and {last}", init.join(", ")), + }; + let state = if total == 1 { "in use" } else { "all in use" }; + format!("Checked {list}: {state}.") +} + /// Formats bytes into a human-readable string. pub fn format_bytes(bytes: u64) -> String { if bytes == 0 { @@ -521,7 +552,7 @@ mod tests { }; assert_eq!( format_cleanup_result(&result, false), - "Checked 5 blob(s), all are in use." + "Checked 5 blobs: all in use." ); } @@ -536,7 +567,7 @@ mod tests { }; assert_eq!( format_cleanup_result(&result, false), - "Removed 2 unused blob(s) (2.00 KB freed)" + "Removed 2 unused blobs (2.00 KB freed)" ); } @@ -871,9 +902,85 @@ mod tests { ..Default::default() }; let formatted = format_cleanup_result(&result, true); - assert!(formatted.starts_with("Would remove 2 unused blob(s)")); - assert!(formatted.contains("Unused blobs:")); - assert!(formatted.contains(" - aaa")); - assert!(formatted.contains(" - bbb")); + assert_eq!( + formatted, + "Would remove 2 unused blobs (2.00 KB freed)\nUnused blobs:\n - aaa\n - bbb" + ); + } + + #[test] + fn format_cleanup_result_for_archives_uses_the_noun_everywhere() { + use crate::api::blob_fetcher::{DIFF_ARCHIVE, PACKAGE_ARCHIVE}; + let result = CleanupResult { + blobs_checked: 2, + blobs_removed: 1, + bytes_freed: 2, + removed_blobs: vec!["3333.tar.gz".to_string()], + ..Default::default() + }; + assert_eq!( + format_cleanup_result_for(&result, true, DIFF_ARCHIVE), + "Would remove 1 unused diff archive (2 B freed)\nUnused diff archives:\n - 3333.tar.gz" + ); + assert_eq!( + format_cleanup_result_for(&result, false, PACKAGE_ARCHIVE), + "Removed 1 unused package archive (2 B freed)" + ); + let none = CleanupResult::default(); + assert_eq!( + format_cleanup_result_for(&none, false, DIFF_ARCHIVE), + "No diff archives to clean up." + ); + } + + #[test] + fn format_cleanup_result_singular_and_sorted() { + let one_in_use = CleanupResult { + blobs_checked: 1, + ..Default::default() + }; + assert_eq!( + format_cleanup_result(&one_in_use, false), + "Checked 1 blob: in use." + ); + // Unsorted input (directory-walk order) prints sorted. + let result = CleanupResult { + blobs_checked: 3, + blobs_removed: 3, + bytes_freed: 3, + removed_blobs: vec!["c".into(), "a".into(), "b".into()], + ..Default::default() + }; + assert_eq!( + format_cleanup_result(&result, true), + "Would remove 3 unused blobs (3 B freed)\nUnused blobs:\n - a\n - b\n - c" + ); + } + + #[test] + fn all_in_use_wording_counts_items_not_kinds() { + assert_eq!( + format_all_in_use(&["1 blob".into()], 1), + "Checked 1 blob: in use." + ); + assert_eq!( + format_all_in_use(&["2 blobs".into()], 2), + "Checked 2 blobs: all in use." + ); + assert_eq!( + format_all_in_use(&["1 blob".into(), "1 diff archive".into()], 2), + "Checked 1 blob and 1 diff archive: all in use." + ); + assert_eq!( + format_all_in_use( + &[ + "2 blobs".into(), + "1 diff archive".into(), + "3 package archives".into() + ], + 6 + ), + "Checked 2 blobs, 1 diff archive and 3 package archives: all in use." + ); } } diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 846ecdaa..641a2609 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -61,7 +61,11 @@ pub async fn read_manifest( Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e), }; - parse_manifest(&content).map(Some) + // Tolerate a UTF-8 byte-order mark (Windows editors add one on save): + // the file looks fine in an editor, yet serde_json rejects it with an + // opaque "expected value at line 1 column 1". + let json = content.strip_prefix('\u{feff}').unwrap_or(&content); + parse_manifest(json).map(Some) } /// Write a manifest to the filesystem with pretty-printed JSON. @@ -323,6 +327,25 @@ mod tests { assert!(result.unwrap().is_none()); } + // A manifest saved with a UTF-8 BOM (as some Windows editors do) reads + // like any other; a BOM anywhere but the start stays invalid. + #[tokio::test] + async fn test_read_manifest_tolerates_leading_bom() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("manifest.json"); + tokio::fs::write(&path, "\u{feff}{\"patches\":{}}") + .await + .unwrap(); + let manifest = read_manifest(&path).await.unwrap().unwrap(); + assert!(manifest.patches.is_empty()); + + tokio::fs::write(&path, "{\"patches\":{}}\u{feff}\u{feff}") + .await + .unwrap(); + let err = read_manifest(&path).await.unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + // Regression: a missing file maps to Ok(None), but malformed JSON must // surface as an InvalidData error -- NOT be silently swallowed as Ok(None). // The original implementation returned Ok(None) for every failure mode, diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 60247009..7e823da8 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -112,6 +112,60 @@ pub fn upgrade_hint(channel: InstallChannel) -> &'static str { } } +/// [`upgrade_hint`] for the binary at `canonical_exe`. An npm install is +/// either global (`/lib/node_modules`, `%APPDATA%\npm\node_modules`, +/// a yarn/pnpm `global` store, a Windows version-manager dir such as +/// nvm-windows' `%APPDATA%\nvm\v20.11.0\node_modules`), where +/// `npm update -g` is right, or a project dependency +/// (`/node_modules`), where `-g` would update some other copy and +/// leave this one alone. +pub fn upgrade_hint_for(channel: InstallChannel, canonical_exe: &Path) -> &'static str { + if channel == InstallChannel::Npm && !is_global_npm_install(canonical_exe) { + return "npm install @socketsecurity/socket-patch@latest"; + } + upgrade_hint(channel) +} + +/// Whether the outermost `node_modules` of `path` belongs to a global +/// install. The well-known global layouts (directly under `lib` on a Unix +/// prefix or `npm` on Windows, or anywhere below a yarn/pnpm `global` +/// store) decide without touching the disk. Otherwise the directory that +/// holds the outermost `node_modules` decides: a project has a +/// `package.json` there, while a global prefix without a `lib/` level +/// (nvm-windows `…\nvm\v20.11.0`, fnm `…\installation`, Volta's image +/// dirs) does not. Defaulting to global when unsure is the safer miss: +/// `npm install …` run from an arbitrary cwd would scaffold a stray +/// `node_modules`/`package.json` there and leave the real copy stale. +fn is_global_npm_install(path: &Path) -> bool { + let names: Vec<&std::ffi::OsStr> = path + .components() + .filter_map(|c| match c { + Component::Normal(os) => Some(os), + _ => None, + }) + .collect(); + let Some(first_nm) = names.iter().position(|n| *n == "node_modules") else { + return false; + }; + let parent = first_nm.checked_sub(1).map(|i| names[i]); + if matches!(parent, Some(p) if p == "lib" || p == "npm") + || names[..first_nm].iter().any(|n| *n == "global") + { + return true; + } + // `ancestors` walks innermost-first, so the LAST `node_modules` is the + // outermost one; its parent keeps the path's own prefix (drive, root). + let holder = path + .ancestors() + .filter(|a| a.file_name().is_some_and(|n| n == "node_modules")) + .last() + .and_then(Path::parent); + match holder { + Some(dir) => !dir.join("package.json").is_file(), + None => true, + } +} + /// Short human label for refusal messages ("managed by npm"). pub fn channel_label(channel: InstallChannel) -> &'static str { match channel { @@ -434,6 +488,76 @@ mod tests { assert_eq!(channel_label(InstallChannel::Homebrew), "Homebrew"); } + #[test] + fn npm_hint_tells_global_from_project_installs() { + let global = [ + "/usr/local/lib/node_modules/@socketsecurity/socket-patch/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", + "/home/u/.nvm/versions/node/v20.1.0/lib/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", + "/home/u/.config/yarn/global/node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", + "/Users/u/Library/pnpm/global/5/node_modules/@socketsecurity/socket-patch-darwin-arm64/bin/socket-patch", + ]; + for p in global { + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, Path::new(p)), + "npm update -g @socketsecurity/socket-patch", + "{p}" + ); + } + // A project install: the dir holding the outermost node_modules has + // a package.json. + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("package.json"), "{}").unwrap(); + let local = [ + "node_modules/@socketsecurity/socket-patch-linux-x64/bin/socket-patch", + // `lib` deeper than the outermost node_modules is not a prefix. + "node_modules/x/lib/node_modules/y/bin/socket-patch", + ]; + for rel in local { + let p = project.path().join(rel); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "npm install @socketsecurity/socket-patch@latest", + "{}", + p.display() + ); + } + // A node_modules with no package.json beside it and no lib/ level: + // a version-manager global prefix (nvm-windows, fnm, Volta), so + // `npm update -g` (running `npm install` from an arbitrary cwd + // would scaffold a stray project there). + let prefixes = ["nvm/v20.11.0", "fnm/node-versions/v20.11.0/installation"]; + for prefix in prefixes { + let root = tempfile::tempdir().unwrap(); + let p = root + .path() + .join(prefix) + .join("node_modules/@socketsecurity/socket-patch-win32-x64/bin/socket-patch.exe"); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, &p), + "npm update -g @socketsecurity/socket-patch", + "{}", + p.display() + ); + } + // Other channels are path-independent. + assert_eq!( + upgrade_hint_for(InstallChannel::Pypi, Path::new("/work/app/node_modules/x")), + upgrade_hint(InstallChannel::Pypi) + ); + } + + #[cfg(windows)] + #[test] + fn npm_hint_windows_global_prefix() { + let p = Path::new( + r"C:\Users\u\AppData\Roaming\npm\node_modules\@socketsecurity\socket-patch-win32-x64\bin\socket-patch.exe", + ); + assert_eq!( + upgrade_hint_for(InstallChannel::Npm, p), + "npm update -g @socketsecurity/socket-patch" + ); + } + #[test] fn hints_route_to_the_owning_manager() { assert!(upgrade_hint(InstallChannel::Npm).contains("npm update -g")); diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs index 0f99da17..f176426c 100644 --- a/crates/socket-patch-core/src/update/download.rs +++ b/crates/socket-patch-core/src/update/download.rs @@ -84,7 +84,7 @@ async fn fetch_archive( .get(&url) .send() .await - .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + .map_err(|e| super::release::request_error(&url, &e))?; let status = resp.status(); if status == reqwest::StatusCode::NOT_FOUND { return Err(UpdateError::AssetNotFound { diff --git a/crates/socket-patch-core/src/update/mod.rs b/crates/socket-patch-core/src/update/mod.rs index e6606ee9..3c1ce623 100644 --- a/crates/socket-patch-core/src/update/mod.rs +++ b/crates/socket-patch-core/src/update/mod.rs @@ -18,7 +18,9 @@ pub mod swap; use std::path::{Path, PathBuf}; -pub use channel::{channel_label, detect_channel, upgrade_hint, ChannelEnv, InstallChannel}; +pub use channel::{ + channel_label, detect_channel, upgrade_hint, upgrade_hint_for, ChannelEnv, InstallChannel, +}; pub use release::{ asset_name_for_target, current_version, fetch_latest_version, is_newer, parse_release_tag, UpdateEndpoints, UpdateTimeouts, @@ -38,6 +40,12 @@ pub enum UpdateError { #[error("network error: {0}")] Network(String), + /// The release has no `SHA256SUMS`, so no download can be verified. + /// Raised while installing (after the check succeeded), hence its own + /// wording; it keeps the `check_failed` envelope code it always had. + #[error("could not verify release v{version}: it publishes no SHA256SUMS ({url} is 404)")] + SumsMissing { version: String, url: String }, + #[error("release v{version} has no prebuilt binary {asset} for this platform")] AssetNotFound { asset: String, version: String }, @@ -64,7 +72,7 @@ impl UpdateError { /// Stable machine-routing tag for the JSON envelope. pub fn error_code(&self) -> &'static str { match self { - UpdateError::CheckFailed(_) => "check_failed", + UpdateError::CheckFailed(_) | UpdateError::SumsMissing { .. } => "check_failed", UpdateError::Network(_) => "download_failed", UpdateError::AssetNotFound { .. } => "asset_not_found", UpdateError::DownloadFailed(_) => "download_failed", @@ -164,8 +172,15 @@ mod tests { /// callers already handle for download failures. #[test] fn error_code_table_matches_cli_contract() { - let table: [(UpdateError, &str); 9] = [ + let table: [(UpdateError, &str); 10] = [ (UpdateError::CheckFailed("x".into()), "check_failed"), + ( + UpdateError::SumsMissing { + version: "1.0.0".into(), + url: "u".into(), + }, + "check_failed", + ), (UpdateError::Network("x".into()), "download_failed"), ( UpdateError::AssetNotFound { diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index b6ae9f6b..5b286901 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -272,6 +272,73 @@ fn metadata_client( .map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}"))) } +/// The failure of `GET url` as one readable line: `GET : `, +/// where `cause` is the innermost error of reqwest's source chain +/// ("Connection refused (os error 61)") rather than its outer +/// "error sending request for url ()", which repeats the URL and +/// hides why. +pub(crate) fn request_error(url: &str, e: &reqwest::Error) -> UpdateError { + UpdateError::Network(format!("GET {url}: {}", request_cause(e))) +} + +fn request_cause(e: &reqwest::Error) -> String { + if e.is_timeout() { + return "timed out".to_string(); + } + let mut inner: &dyn std::error::Error = e; + while let Some(next) = inner.source() { + inner = next; + } + inner.to_string() +} + +/// `host[:port]` of `url`, for "cannot reach ..." messages. +fn url_host(url: &str) -> Option { + let parsed = reqwest::Url::parse(url).ok()?; + let host = parsed.host_str()?; + Some(match parsed.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) +} + +/// The message for a failed latest-release lookup. When both routes died +/// the same network death (the usual offline or firewalled case) it is one +/// short line naming the host(s) and the cause; otherwise both legs are +/// kept so a support log tells the whole story. +fn combine_check_errors( + probe_url: &str, + probe_err: &UpdateError, + api_url: &str, + api_err: &UpdateError, +) -> String { + let cause_of = |url: &str, err: &UpdateError| match err { + UpdateError::Network(msg) => msg + .strip_prefix(&format!("GET {url}: ")) + .map(str::to_string), + _ => None, + }; + if let (Some(a), Some(b)) = (cause_of(probe_url, probe_err), cause_of(api_url, api_err)) { + if a == b { + if let (Some(h1), Some(h2)) = (url_host(probe_url), url_host(api_url)) { + let hosts = if h1 == h2 { + h1 + } else { + format!("{h1} or {h2}") + }; + return format!("cannot reach {hosts}: {a}"); + } + } + } + // Each leg without the "could not check for updates:" prefix the + // combined error is about to add again. + let leg = |err: &UpdateError| match err { + UpdateError::CheckFailed(msg) => msg.clone(), + other => other.to_string(), + }; + format!("{}; API fallback: {}", leg(probe_err), leg(api_err)) +} + /// Resolve the latest released version: redirect probe first, API fallback /// second (see module docs). pub async fn fetch_latest_version( @@ -284,8 +351,11 @@ pub async fn fetch_latest_version( }; match fetch_latest_via_api(endpoints, timeouts).await { Ok(version) => Ok(version), - Err(api_err) => Err(UpdateError::CheckFailed(format!( - "could not determine the latest release: {probe_err}; API fallback: {api_err}" + Err(api_err) => Err(UpdateError::CheckFailed(combine_check_errors( + &endpoints.latest_probe_url(), + &probe_err, + &endpoints.latest_api_url(), + &api_err, ))), } } @@ -300,7 +370,7 @@ async fn probe_latest_redirect( .get(&url) .send() .await - .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + .map_err(|e| request_error(&url, &e))?; if !resp.status().is_redirection() { return Err(UpdateError::CheckFailed(format!( "GET {url} returned {} (expected a redirect to the latest tag)", @@ -328,7 +398,7 @@ async fn fetch_latest_via_api( .header(reqwest::header::ACCEPT, "application/vnd.github+json") .send() .await - .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + .map_err(|e| request_error(&url, &e))?; let status = resp.status(); if !status.is_success() { return Err(UpdateError::CheckFailed(format!( @@ -361,12 +431,13 @@ pub async fn fetch_sha256sums_entry( .get(&url) .send() .await - .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + .map_err(|e| request_error(&url, &e))?; let status = resp.status(); if status == reqwest::StatusCode::NOT_FOUND { - return Err(UpdateError::CheckFailed(format!( - "release v{version} publishes no SHA256SUMS ({url} is 404) — cannot verify a download" - ))); + return Err(UpdateError::SumsMissing { + version: version.to_string(), + url, + }); } if !status.is_success() { return Err(UpdateError::Network(format!("GET {url} returned {status}"))); @@ -825,8 +896,93 @@ mod tests { assert!(msg.contains("returned 404"), "{msg}"); } + #[tokio::test] + async fn fetch_latest_version_unreachable_host_is_one_short_line() { + // Nothing listens on the reserved port: both legs die the same + // connect death, reported once with the root cause instead of the + // doubled "error sending request for url (...)" chain. + let endpoints = overridden_endpoints("http://127.0.0.1:9"); + let err = fetch_latest_version(&endpoints, &UpdateTimeouts::default()) + .await + .unwrap_err(); + assert_eq!(err.error_code(), "check_failed"); + let msg = err.to_string(); + assert!( + msg.starts_with("could not check for updates: cannot reach 127.0.0.1:9: "), + "{msg}" + ); + assert!(!msg.contains("error sending request"), "{msg}"); + assert!(!msg.contains("API fallback"), "{msg}"); + assert!(!msg.contains("/releases/latest"), "{msg}"); + } + + #[test] + fn combine_check_errors_collapses_identical_network_causes() { + let probe = "https://github.com/SocketDev/socket-patch/releases/latest"; + let api = "https://api.github.com/repos/SocketDev/socket-patch/releases/latest"; + let net = |url: &str, cause: &str| UpdateError::Network(format!("GET {url}: {cause}")); + assert_eq!( + combine_check_errors(probe, &net(probe, "timed out"), api, &net(api, "timed out")), + "cannot reach github.com or api.github.com: timed out" + ); + // Different causes, or a non-network leg: both legs kept. + let mixed = combine_check_errors( + probe, + &net(probe, "timed out"), + api, + &UpdateError::CheckFailed(format!("GET {api} returned 500 Internal Server Error")), + ); + assert_eq!( + mixed, + format!( + "network error: GET {probe}: timed out; API fallback: \ + GET {api} returned 500 Internal Server Error" + ) + ); + let differ = combine_check_errors(probe, &net(probe, "a"), api, &net(api, "b")); + assert!(differ.contains("API fallback:"), "{differ}"); + } + + #[test] + fn url_host_keeps_explicit_ports() { + assert_eq!(url_host("http://127.0.0.1:9/x").as_deref(), Some("127.0.0.1:9")); + assert_eq!(url_host("https://github.com/a").as_deref(), Some("github.com")); + assert_eq!(url_host("not a url"), None); + } + // ---------- SHA256SUMS fetch ---------- + #[tokio::test] + async fn sha256sums_404_says_verify_not_check() { + // Reached while installing, after the check already succeeded: the + // message must not claim the update *check* failed. The envelope + // code stays `check_failed` (stable contract). + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let endpoints = overridden_endpoints(&server.uri()); + let err = fetch_sha256sums_entry( + &endpoints, + &UpdateTimeouts::default(), + &semver::Version::new(1, 2, 3), + "socket-patch-x.tar.gz", + ) + .await + .unwrap_err(); + assert_eq!(err.error_code(), "check_failed"); + assert_eq!( + err.to_string(), + format!( + "could not verify release v1.2.3: it publishes no SHA256SUMS \ + ({}/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS is 404)", + server.uri() + ) + ); + } + #[tokio::test] async fn sha256sums_server_error_status_is_reported() { // A non-404 error status on the integrity-root fetch surfaces as diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index 3e328210..aa4fb09f 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -67,7 +67,7 @@ fn warn_legacy_once(legacy_name: &'static str, new_name: &'static str) { /// Check if debug mode is enabled via `SOCKET_DEBUG` (with the legacy /// `SOCKET_PATCH_DEBUG` shim). -pub(crate) fn is_debug_enabled() -> bool { +pub fn is_debug_enabled() -> bool { matches!( read_env_with_legacy("SOCKET_DEBUG", "SOCKET_PATCH_DEBUG").as_deref(), Some("1" | "true") diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 910dcd0a..f37fa126 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod env_compat; pub mod fs; +pub mod notice; pub(crate) mod http; pub mod pdm_lock; pub mod pipenv; diff --git a/crates/socket-patch-core/src/utils/notice.rs b/crates/socket-patch-core/src/utils/notice.rs new file mode 100644 index 00000000..40f7efac --- /dev/null +++ b/crates/socket-patch-core/src/utils/notice.rs @@ -0,0 +1,99 @@ +//! Process-wide switch for core's own stderr advisories. +//! +//! Core has no view of the CLI's `--silent`/`--json` flags, so the CLI +//! sets them once after parsing via [`set_output_mode`]. Each advisory +//! prints at most once per process even when a command builds several API +//! clients, and is muted according to its [`Notice`] level: +//! +//! - [`Notice::Info`] (the public-proxy notice, the multiple-orgs note): +//! muted by `--silent` or `--json`. +//! - [`Notice::Warning`] (token-shape and org auto-detect warnings): muted +//! only by `--silent`. `--json` keeps stdout machine-readable but still +//! shows warnings on stderr (CLI_CONTRACT: `--silent` is errors only), +//! so a CI run with a misconfigured token still says why. + +use std::sync::atomic::{AtomicBool, Ordering}; + +static SILENT: AtomicBool = AtomicBool::new(false); +static JSON: AtomicBool = AtomicBool::new(false); + +/// How loud an advisory is; decides which output modes mute it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Notice { + /// Informational; muted by `--silent` or `--json`. + Info, + /// Something is likely misconfigured; muted only by `--silent`. + Warning, +} + +/// Record the process's `--silent`/`--json` mode for the rest of the run. +pub fn set_output_mode(silent: bool, json: bool) { + SILENT.store(silent, Ordering::Relaxed); + JSON.store(json, Ordering::Relaxed); +} + +/// Whether informational output is suppressed (`--silent` or `--json`). +pub fn is_quiet() -> bool { + SILENT.load(Ordering::Relaxed) || JSON.load(Ordering::Relaxed) +} + +/// Whether an advisory of `level` is suppressed in the current mode. +pub(crate) fn is_muted(level: Notice) -> bool { + match level { + Notice::Info => is_quiet(), + Notice::Warning => SILENT.load(Ordering::Relaxed), + } +} + +/// Print `msg()` to stderr unless `level` is muted or `shown` already +/// fired. The message is built lazily so a suppressed advisory costs +/// nothing, and a muted call does not consume the once. +pub(crate) fn notice_once(level: Notice, shown: &AtomicBool, msg: impl FnOnce() -> String) { + if is_muted(level) { + return; + } + if !shown.swap(true, Ordering::Relaxed) { + eprintln!("{}", msg()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Whether `notice_once` built (and so printed) its message. + fn fires(level: Notice, shown: &AtomicBool) -> bool { + let mut fired = false; + notice_once(level, shown, || { + fired = true; + String::new() + }); + fired + } + + #[test] + fn notice_once_fires_once_and_respects_mode() { + // Only this test touches the mode switches in core's unit tests. + let info = AtomicBool::new(false); + let warn = AtomicBool::new(false); + + // --silent mutes both levels without consuming the once. + set_output_mode(true, false); + assert!(!fires(Notice::Info, &info), "silent mutes info"); + assert!(!fires(Notice::Warning, &warn), "silent mutes warnings"); + assert!(!info.load(Ordering::Relaxed) && !warn.load(Ordering::Relaxed)); + + // --json mutes info but still shows warnings, once. + set_output_mode(false, true); + assert!(is_quiet()); + assert!(!fires(Notice::Info, &info), "json mutes info"); + assert!(fires(Notice::Warning, &warn), "json keeps warnings"); + assert!(!fires(Notice::Warning, &warn), "warning fires once"); + + // Neither: info fires, once. + set_output_mode(false, false); + assert!(!is_quiet()); + assert!(fires(Notice::Info, &info)); + assert!(!fires(Notice::Info, &info)); + } +} diff --git a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 4ff8c08c..8d495238 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -571,9 +571,6 @@ async fn fetch_missing_blobs_mixed_outcomes_aggregate_and_format() { // End-to-end formatter exercise with a genuinely mixed result. let rendered = format_fetch_result(&result); - assert!(rendered.contains("Downloaded 1 blob(s)"), "{rendered}"); - assert!( - rendered.contains("Failed to download 2 blob(s)"), - "{rendered}" - ); + assert!(rendered.contains("Downloaded 1 blob\n"), "{rendered}"); + assert!(rendered.contains("Failed to download 2 blobs"), "{rendered}"); } diff --git a/docs/design/configuration.md b/docs/design/configuration.md index 80fd4b63..a4d6486a 100644 --- a/docs/design/configuration.md +++ b/docs/design/configuration.md @@ -102,8 +102,9 @@ UX policy and are ignored. bool-parsing dialects (`parse_bool_flag` vs stock `BoolishValueParser` on `--all-releases`, bare clap bool on `get --one-off`, `env_truthy`'s `1|true`-only match on the experimental gates and core's `SOCKET_OFFLINE` - reader); honor `NO_COLOR` (and `FORCE_COLOR`/`CLICOLOR_FORCE`) in - `output.rs`, which today keys only off `is_terminal()`; document + reader); consider `FORCE_COLOR` as an alias for `CLICOLOR_FORCE` in + `ui::color_enabled` (which already honors `NO_COLOR`, `CLICOLOR`, + `CLICOLOR_FORCE` and `TERM=dumb`); document `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` support in the README. - **`SOCKET_API_TOKEN_FILE` / keychain sourcing** for the token — the conventional next step for secret hygiene; not urgent now that the From 8027492a8dd34ccf0afa4dbcbf3f95590e5701a6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 12:52:57 -0400 Subject: [PATCH 02/18] chore: clear Rust 1.93 clippy lints in untouched files Mechanical fixes so clippy --all-targets -D warnings passes on both crates: assert! for a literal-bool assert_eq!, slice::from_ref over a one-element clone, a Tamper alias for a complex test type, non-test items moved above bun_binary's test modules, an eta-reduced closure, an elided lifetime, and an initialized-in-place let. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_gem_fallback_home.rs | 2 +- .../tests/mode_migration_npm.rs | 13 +- .../src/crawlers/python_crawler.rs | 3 +- .../src/patch/redirect/pdm.rs | 2 +- .../src/patch/redirect/pipenv.rs | 2 +- .../src/vendor/bun_binary.rs | 142 +++++++++--------- crates/socket-patch-core/src/vendor/gem.rs | 3 +- .../tests/covgap_update_state.rs | 2 +- 8 files changed, 84 insertions(+), 85 deletions(-) diff --git a/crates/socket-patch-cli/tests/in_process_gem_fallback_home.rs b/crates/socket-patch-cli/tests/in_process_gem_fallback_home.rs index 60c18ae8..fbce6b5a 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_fallback_home.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_fallback_home.rs @@ -169,7 +169,7 @@ fn parse_env(stdout: &str) -> serde_json::Value { .unwrap_or_else(|e| panic!("apply must emit JSON: {e}; stdout={stdout}")) } -fn find_skip_event<'a>(env: &'a serde_json::Value) -> Option<&'a serde_json::Value> { +fn find_skip_event(env: &serde_json::Value) -> Option<&serde_json::Value> { env["events"].as_array().and_then(|events| { events.iter().find(|e| { e["action"] == "skipped" diff --git a/crates/socket-patch-cli/tests/mode_migration_npm.rs b/crates/socket-patch-cli/tests/mode_migration_npm.rs index 3bae4c80..eecea0ae 100644 --- a/crates/socket-patch-cli/tests/mode_migration_npm.rs +++ b/crates/socket-patch-cli/tests/mode_migration_npm.rs @@ -422,25 +422,24 @@ fn stage_yarn_fixture(tag: &str, pm: &str, berry: bool) -> Option { ), ) .unwrap(); - let extra_env: Vec<(String, String)>; - if berry { + let extra_env: Vec<(String, String)> = if berry { std::fs::write( proj.join(".yarnrc.yml"), "nodeLinker: node-modules\nenableGlobalCache: false\n", ) .unwrap(); let global = tmp.path().join("yarn-global"); - extra_env = vec![( + vec![( "YARN_GLOBAL_FOLDER".into(), global.to_str().unwrap().to_string(), - )]; + )] } else { let cache = tmp.path().join("yarn-cache"); - extra_env = vec![( + vec![( "YARN_CACHE_FOLDER".into(), cache.to_str().unwrap().to_string(), - )]; - } + )] + }; let env_refs: Vec<(&str, &str)> = extra_env .iter() .map(|(k, v)| (k.as_str(), v.as_str())) diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 14a90682..d63c311a 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1534,9 +1534,8 @@ mod tests { Some(PathBuf::from("/data/virtualenvs")) ); } - assert_eq!( + assert!( with(Some(" "), None).is_some(), - true, "blank WORKON_HOME falls through" ); let no_home = |_: &str| None::; diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index f8870bdc..1c820b57 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -206,7 +206,7 @@ mod tests { let mut result = RewriteResult::default(); rewrite( &BTreeMap::from([("pdm.lock".into(), original.to_string())]), - &[other.clone()], + std::slice::from_ref(&other), &mut result, ); assert!( diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs index 960de90d..3dc5b57b 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs @@ -869,7 +869,7 @@ mod compatibility_tests { ), ("requirements.txt".into(), "urllib3==1.26.18\n".into()), ]); - let result = super::super::rewrite_registry_redirect(&files, &[dep.clone()]); + let result = super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); assert!(result.files.is_empty()); assert!(result.edits.is_empty()); assert!(result.refused_pipenv_uuids.contains("patch-one")); diff --git a/crates/socket-patch-core/src/vendor/bun_binary.rs b/crates/socket-patch-core/src/vendor/bun_binary.rs index 42caaa73..3133e33f 100644 --- a/crates/socket-patch-core/src/vendor/bun_binary.rs +++ b/crates/socket-patch-core/src/vendor/bun_binary.rs @@ -448,6 +448,77 @@ pub(crate) async fn revert(entry: &VendorEntry, root: &Path, opts: RevertOpts) - outcome } +/// Mirrors are confined to a workspace's own Socket artifact directory. +/// Check every existing component so a workspace symlink cannot redirect a +/// write or deletion outside the project. +pub(super) fn validate_mirror_path(root: &Path, rel: &str) -> Result { + let (workspace, artifact) = rel + .rsplit_once("/.socket/vendor/npm/") + .ok_or("invalid workspace tarball path")?; + if workspace.is_empty() + || artifact.is_empty() + || rel.contains('\\') + || Path::new(rel) + .components() + .any(|c| !matches!(c, std::path::Component::Normal(_))) + { + return Err("unsafe workspace tarball path".into()); + } + let parsed = parse_vendor_path(&format!(".socket/vendor/npm/{artifact}")) + .ok_or("invalid workspace vendor artifact")?; + let valid_leaf = match parsed.leaf.split_once('/') { + None => true, + Some((scope, bare)) => { + scope.starts_with('@') && scope.len() > 1 && !bare.is_empty() && !bare.contains('/') + } + }; + if parsed.eco != "npm" || !valid_leaf || !parsed.leaf.ends_with(".tgz") { + return Err("invalid workspace tarball leaf".into()); + } + let mut path = root.to_path_buf(); + for component in Path::new(rel).components() { + path.push(component); + match std::fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(format!( + "workspace tarball path {} contains a symbolic link", + path.display() + )) + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("cannot inspect workspace tarball path: {e}")), + } + } + Ok(path) +} + +pub(super) async fn prune_mirror_parents(path: &Path) { + // Only empty directories through the workspace's .socket, never the member. + let mut parent = path.parent(); + for _ in 0..5 { + let Some(dir) = parent else { break }; + if tokio::fs::remove_dir(dir).await.is_err() { + break; + } + if dir.file_name().is_some_and(|name| name == ".socket") { + break; + } + parent = dir.parent(); + } +} + +pub(super) async fn undo_mirrors(backups: &[(PathBuf, Option>)]) { + for (path, before) in backups.iter().rev() { + if let Some(bytes) = before { + let _ = atomic_write_bytes_preserving_mode(path, bytes).await; + } else { + let _ = tokio::fs::remove_file(path).await; + prune_mirror_parents(path).await; + } + } +} + #[cfg(all(test, unix))] mod symlink_tests { use super::*; @@ -675,74 +746,3 @@ mod rebuild_tests { } } } - -/// Mirrors are confined to a workspace's own Socket artifact directory. -/// Check every existing component so a workspace symlink cannot redirect a -/// write or deletion outside the project. -pub(super) fn validate_mirror_path(root: &Path, rel: &str) -> Result { - let (workspace, artifact) = rel - .rsplit_once("/.socket/vendor/npm/") - .ok_or("invalid workspace tarball path")?; - if workspace.is_empty() - || artifact.is_empty() - || rel.contains('\\') - || Path::new(rel) - .components() - .any(|c| !matches!(c, std::path::Component::Normal(_))) - { - return Err("unsafe workspace tarball path".into()); - } - let parsed = parse_vendor_path(&format!(".socket/vendor/npm/{artifact}")) - .ok_or("invalid workspace vendor artifact")?; - let valid_leaf = match parsed.leaf.split_once('/') { - None => true, - Some((scope, bare)) => { - scope.starts_with('@') && scope.len() > 1 && !bare.is_empty() && !bare.contains('/') - } - }; - if parsed.eco != "npm" || !valid_leaf || !parsed.leaf.ends_with(".tgz") { - return Err("invalid workspace tarball leaf".into()); - } - let mut path = root.to_path_buf(); - for component in Path::new(rel).components() { - path.push(component); - match std::fs::symlink_metadata(&path) { - Ok(meta) if meta.file_type().is_symlink() => { - return Err(format!( - "workspace tarball path {} contains a symbolic link", - path.display() - )) - } - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("cannot inspect workspace tarball path: {e}")), - } - } - Ok(path) -} - -pub(super) async fn prune_mirror_parents(path: &Path) { - // Only empty directories through the workspace's .socket, never the member. - let mut parent = path.parent(); - for _ in 0..5 { - let Some(dir) = parent else { break }; - if tokio::fs::remove_dir(dir).await.is_err() { - break; - } - if dir.file_name().is_some_and(|name| name == ".socket") { - break; - } - parent = dir.parent(); - } -} - -pub(super) async fn undo_mirrors(backups: &[(PathBuf, Option>)]) { - for (path, before) in backups.iter().rev() { - if let Some(bytes) = before { - let _ = atomic_write_bytes_preserving_mode(path, bytes).await; - } else { - let _ = tokio::fs::remove_file(path).await; - prune_mirror_parents(path).await; - } - } -} diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 5a6a401a..7b4ec7ae 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -7036,7 +7036,8 @@ mod tests { fn t_checksum_new_none(e: &mut VendorEntry) { e.wiring[2].new = None; } - let cases: [(&str, fn(&mut VendorEntry)); 5] = [ + type Tamper = fn(&mut VendorEntry); + let cases: [(&str, Tamper); 5] = [ ("gemfile record without `new`", t_gemfile_new_none), ( "rewritten gemfile record without `original`", diff --git a/crates/socket-patch-core/tests/covgap_update_state.rs b/crates/socket-patch-core/tests/covgap_update_state.rs index d5265ab2..57d39b22 100644 --- a/crates/socket-patch-core/tests/covgap_update_state.rs +++ b/crates/socket-patch-core/tests/covgap_update_state.rs @@ -31,7 +31,7 @@ const STATE_DIR_VARS: [&str; 5] = [ async fn no_resolvable_state_dir_degrades_load_and_save() { // Blank (empty means unset, per the env_dir convention — avoids // remove_var churn) every var the resolution chain consults. - let prev: Vec<_> = STATE_DIR_VARS.iter().map(|v| std::env::var_os(v)).collect(); + let prev: Vec<_> = STATE_DIR_VARS.iter().map(std::env::var_os).collect(); for v in STATE_DIR_VARS { std::env::set_var(v, ""); } From 032b01a6ffbfd8c83d27f8a5f63e7eae1acd3f10 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 12:54:42 -0400 Subject: [PATCH 03/18] docs(cli): one-line vendor/repair summaries, every help page leak-checked The vendor summary lost its closing period to clap's first-paragraph trim; it is now a one-line summary with the rest in the long help. The repair about now names what it restores (blobs, diff/package archives, vendored artifacts). help_text_hygiene scans every subcommand's page, self-update included, instead of three owned pages. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/mod.rs | 2 +- crates/socket-patch-cli/src/lib.rs | 19 ++++---- .../tests/help_text_hygiene.rs | 45 ++++++++++++++++--- 3 files changed, 50 insertions(+), 16 deletions(-) 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/lib.rs b/crates/socket-patch-cli/src/lib.rs index 6fa6a911..bbcf5475 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -59,9 +59,11 @@ 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), /// Wire install hooks (npm, Python, Bundler, Composer) that re-apply @@ -81,13 +83,12 @@ 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), diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 09f363a2..7c06912b 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -51,14 +51,21 @@ fn long_help(path: &[&str]) -> String { } #[test] -fn owned_help_pages_have_no_developer_notes() { - // `self-update` is covered by `self_update_help_shows_the_public_spelling` - // (its [VERSION] arg help lives in commands/update.rs). - for path in [&[][..], &["vex"], &["setup"]] { - let text = long_help(path); +fn every_help_page_has_no_developer_notes() { + let mut cmd = Cli::command(); + cmd.build(); + let mut names: Vec = vec![String::new()]; + names.extend(cmd.get_subcommands().map(|s| s.get_name().to_string())); + let mut failures = Vec::new(); + for name in &names { + let path: Vec<&str> = if name.is_empty() { vec![] } else { vec![name.as_str()] }; + let text = long_help(&path); let found = leaks(&text); - assert!(found.is_empty(), "{path:?} --help leaks {found:?}:\n{text}"); + if !found.is_empty() { + failures.push(format!("{path:?} --help leaks {found:?}:\n{text}")); + } } + assert!(failures.is_empty(), "{}", failures.join("\n\n")); } #[test] @@ -146,3 +153,29 @@ fn root_command_list_uses_the_verb_form() { "{text}" ); } + +#[test] +fn vendor_and_repair_summaries_read_as_one_line() { + let text = long_help(&[]); + assert!( + text.lines().any(|l| l + == " vendor Eject patched dependencies into committable `.socket/vendor/` and rewire lockfiles to use them (`--revert` undoes it)"), + "{text}" + ); + assert!( + text.lines().any(|l| l + == " repair Download missing patch artifacts and clean up unused ones [aliases: gc]"), + "{text}" + ); + let repair = long_help(&["repair"]); + assert!( + repair.starts_with( + "Download missing patch artifacts and clean up unused ones\n\n\ + 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`.\n" + ), + "{repair}" + ); +} From 183d1b7a3fa45cfec2efb377a32af51c90910402 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 12:56:42 -0400 Subject: [PATCH 04/18] fix(core/api): show the API's error message, not the raw JSON body A non-2xx response now reports the body's error.message / message / error string when it is JSON, the trimmed text otherwise, and no dangling colon when the body is empty. A failed binary body read keeps its cause chain like the send error does. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-core/src/api/client.rs | 100 +++++++++++++++++---- 1 file changed, 82 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index a7d173a5..9a58ec2d 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -41,6 +41,37 @@ fn network_error_detail(e: &reqwest::Error) -> String { msg } +/// The readable part of a non-2xx response body, for an error message: the +/// `error.message` / `message` / `error` string of a JSON body (the API's +/// error shape), otherwise the trimmed body text. Empty when there is +/// nothing worth showing. +fn error_body_detail(text: &str) -> String { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + let msg = v + .pointer("/error/message") + .and_then(|m| m.as_str()) + .or_else(|| v.get("message").and_then(|m| m.as_str())) + .or_else(|| v.get("error").and_then(|m| m.as_str())) + .map(str::trim) + .filter(|m| !m.is_empty()); + if let Some(m) = msg { + return m.to_string(); + } + } + trimmed.to_string() +} + +/// `" "` plus `": "` when the body says something. +fn status_error(head: &str, status: StatusCode, text: &str) -> String { + let detail = error_body_detail(text); + if detail.is_empty() { + format!("{head} {}", status.as_u16()) + } else { + format!("{head} {}: {detail}", status.as_u16()) + } +} + /// Log debug messages when debug mode is enabled. fn debug_log(message: &str) { if is_debug_enabled() { @@ -210,10 +241,10 @@ impl ApiClient { return Err(err); } let text = resp.text().await.unwrap_or_default(); - Err(ApiError::Other(format!( - "API request failed with status {}: {}", - status.as_u16(), - text + Err(ApiError::Other(status_error( + "API request failed with status", + status, + &text, ))) } @@ -453,10 +484,10 @@ impl ApiClient { )); return Ok(None); } - Err(ApiError::Other(format!( - "API request failed with status {}: {}", - status.as_u16(), - text + Err(ApiError::Other(status_error( + "API request failed with status", + status, + &text, ))) } @@ -634,7 +665,9 @@ impl ApiClient { let bytes = resp.bytes().await.map_err(|e| { ApiError::Network(format!( "Error reading {} body for {}: {}", - kind, identifier, e + kind, + identifier, + network_error_detail(&e) )) })?; return Ok(Some(bytes.to_vec())); @@ -653,12 +686,10 @@ impl ApiClient { return Err(err); } let text = resp.text().await.unwrap_or_default(); - Err(ApiError::Other(format!( - "Failed to fetch {} {}: status {} - {}", - kind, - identifier, - status.as_u16(), - text, + Err(ApiError::Other(status_error( + &format!("Failed to fetch {kind} {identifier}: status"), + status, + &text, ))) } @@ -869,9 +900,10 @@ impl ApiClient { return Err(err); } let text = resp.text().await.unwrap_or_default(); - Err(ApiError::Other(format!( - "package request failed with status {}: {text}", - status.as_u16(), + Err(ApiError::Other(status_error( + "package request failed with status", + status, + &text, ))) } @@ -2476,6 +2508,38 @@ mod tests { } } + #[test] + fn status_error_surfaces_the_json_message_not_the_raw_body() { + let s500 = StatusCode::INTERNAL_SERVER_ERROR; + assert_eq!( + status_error( + "API request failed with status", + s500, + r#"{"error":{"message":"Patch store unavailable"}}"# + ), + "API request failed with status 500: Patch store unavailable" + ); + assert_eq!( + status_error("x", StatusCode::BAD_REQUEST, r#"{"message":" bad purl "}"#), + "x 400: bad purl" + ); + assert_eq!( + status_error("x", StatusCode::BAD_REQUEST, r#"{"error":"nope"}"#), + "x 400: nope" + ); + // Not our shape: the body itself, trimmed. + assert_eq!( + status_error("x", s500, " upstream timeout\n"), + "x 500: upstream timeout" + ); + assert_eq!( + status_error("x", s500, r#"{"error":{"code":7}}"#), + r#"x 500: {"error":{"code":7}}"# + ); + // Nothing to say: no dangling colon. + assert_eq!(status_error("x", StatusCode::BAD_GATEWAY, " \n"), "x 502"); + } + #[test] fn validate_token_shape_redacts_by_chars_not_bytes() { // Regression: the preview tail and the "(N chars)" count must be From 307c26a549b5d1b116a53bfcaa691d3a5b69a8a6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 12:59:01 -0400 Subject: [PATCH 05/18] fix(cli/scan): hosted --json keeps VEX advisories in vex.warnings The hosted arm dropped the VEX summary's warnings, and under --json they are muted on stderr, so e.g. product_not_iri or an unreadable vendor ledger reached no channel. They now ride vex.warnings (skip-if-empty), as in the agent arm. apply --vex and scan --vex print their summary via the shared vex::format_vex_written. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/apply.rs | 15 +++--- .../src/commands/scan/hosted.rs | 13 ++++- .../socket-patch-cli/src/commands/scan/mod.rs | 15 +++--- .../tests/covgap_commands_scan_hosted.rs | 53 +++++++++++++++++++ 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index eda662ee..7c0be2bd 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1076,13 +1076,14 @@ pub(crate) async fn run_locked( Some(Ok(summary)) => { if !args.common.silent { println!( - "Wrote OpenVEX document with {} to {}", - plural(summary.statements, "statement", "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"), + ) ); } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index ed507568..4cd2162c 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -2357,6 +2357,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 { @@ -2381,7 +2384,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)); @@ -2433,6 +2439,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 }); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 9374604e..b477a139 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -382,13 +382,14 @@ async fn embed_vex_human( Ok(summary) => { if !common.silent { println!( - "Wrote OpenVEX document with {} to {}", - plural(summary.statements, "statement", "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 diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs index 1a6c8088..8aba0b1c 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs @@ -1983,6 +1983,59 @@ async fn human_vex_success_summary_names_statements_path_and_ledger_caveat() { assert_eq!(doc["statements"][0]["vulnerability"]["name"], GHSA); } +/// `--json` `--vex` run: VEX advisories are muted on stderr under --json, +/// so the hosted envelope's `vex.warnings` must carry them (same +/// skip-if-empty key as the agent arm), instead of dropping them. +#[tokio::test] +async fn json_vex_block_carries_the_vex_run_warnings() { + let server = MockServer::start().await; + mock_discovery(&server, PURL, UUID).await; + mock_granted_reference(&server, UUID, PURL, HOSTED_URL).await; + mock_view(&server, UUID, PURL).await; + + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + + let (code, v) = scan_hosted_json( + tmp.path(), + &server.uri(), + &["--vex", "out.vex.json", "--vex-product", "consumer"], + &[], + ); + assert_eq!(code, 0, "{v}"); + assert_eq!(v["vex"]["statements"], 1, "{v}"); + let warnings = v["vex"]["warnings"] + .as_array() + .unwrap_or_else(|| panic!("{v}")); + assert_eq!(warnings.len(), 1, "{v}"); + assert_eq!(warnings[0]["code"], "product_not_iri", "{v}"); + assert_eq!( + warnings[0]["detail"], + "Product override \"consumer\" (--vex-product) 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:/@.", + "{v}" + ); + + // A clean product: no `warnings` key at all. + let tmp = tempfile::tempdir().unwrap(); + write_npm_project(tmp.path(), NAME); + let (code, v) = scan_hosted_json( + tmp.path(), + &server.uri(), + &[ + "--vex", + "out.vex.json", + "--vex-product", + "pkg:npm/consumer@0.0.0", + ], + &[], + ); + assert_eq!(code, 0, "{v}"); + assert!(v["vex"].get("warnings").is_none(), "{v}"); +} + /// Human Rush run: the `redirect_rush_repo_state_stale` detail reaches /// stderr through the rush warning loop (the JSON twin is pinned in /// in_process_redirect.rs). From 5b08c53c806c0471e5e77653700dbe1547034ee4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:00:18 -0400 Subject: [PATCH 06/18] fix(cli/repair): a failed vendored rebuild no longer ends on "Repair complete." repair exits 1 when a vendored artifact cannot be rebuilt, but the human run still closed with "Repair complete." on stdout. It now closes with "Repair finished with errors." on stderr, like a failed download. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/repair.rs | 40 +++++++++++++++---- .../tests/covgap_commands_repair_vendor.rs | 29 ++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index ed72e8da..1104e0f2 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -291,14 +291,23 @@ fn format_cleanup_summary(results: &[(ArtifactNoun, CleanupResult)], dry_run: bo format_all_in_use(&checked, total) } -/// The closing line of a human repair run. -fn format_final_line(download_failed: usize, noun: ArtifactNoun, dry_run: bool) -> String { +/// 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 { @@ -581,8 +590,14 @@ async fn repair_inner( // 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 line = format_final_line(download_failed_count, noun, args.common.dry_run); - if download_failed_count > 0 { + 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!(); } @@ -1133,19 +1148,28 @@ mod tests { #[test] fn final_line_reflects_failures_and_dry_run() { - assert_eq!(format_final_line(0, BLOB, false), "Repair complete."); + assert_eq!(format_final_line(0, false, BLOB, false), "Repair complete."); assert_eq!( - format_final_line(0, BLOB, true), + format_final_line(0, false, BLOB, true), "Dry run: no changes made." ); assert_eq!( - format_final_line(1, DIFF_ARCHIVE, false), + format_final_line(1, false, DIFF_ARCHIVE, false), "Repair finished with errors: 1 diff archive was not downloaded." ); assert_eq!( - format_final_line(2, BLOB, false), + 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] diff --git a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs index 002845bb..45aa911f 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_repair_vendor.rs @@ -1266,6 +1266,35 @@ async fn repair_rebuild_fails_when_installed_patch_file_missing() { assert!(!tgz.exists(), "a failed dispatch replaced nothing"); } +/// Human twin: the failed rebuild exits 1, so the run must close on +/// "Repair finished with errors." (stderr), never "Repair complete.". +#[tokio::test] +async fn human_repair_rebuild_failure_does_not_claim_complete() { + let mock = MockServer::start().await; + mount_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_fixture( + tmp.path(), + "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "sha512-orig==", + ); + let tgz = vendor_project(tmp.path(), &mock.uri()); + std::fs::remove_file(&tgz).unwrap(); + std::fs::remove_file(tmp.path().join("node_modules/left-pad/index.js")).unwrap(); + + let (code, stdout, stderr) = run_cli_human(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 1, "stdout={stdout} stderr={stderr}"); + assert!( + !stdout.contains("Repair complete.") && !stderr.contains("Repair complete."), + "stdout={stdout} stderr={stderr}" + ); + assert_eq!( + stderr.lines().last(), + Some("Repair finished with errors."), + "stderr={stderr}" + ); +} + // ─────────────────── soft-restore fallbacks ─────────────────── /// Staging itself is Unavailable (offline, one candidate's patch content From 1ad4dcbd61b9d61066223af73e1e619413e3ff98 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:01:13 -0400 Subject: [PATCH 07/18] fix(cli/get): vendored-mode step errors use the shared vendor-step line get --mode vendored printed a raw "Error (): " for a failed vendor step or bun refusal, without the capital, the period or the --lock-timeout hint that scan's vendored arm prints. Both now go through scan::vendor_flow::format_vendor_step_error. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 10 ++++++++-- .../socket-patch-cli/src/commands/scan/mod.rs | 2 +- .../tests/covgap_commands_get.rs | 19 ++++++++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 871c2b20..a2e38d01 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -3562,7 +3562,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; } @@ -3661,7 +3664,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 } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index b477a139..df8c3e55 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -33,7 +33,7 @@ mod discovery; mod gc; mod hosted; mod render; -mod vendor_flow; +pub(crate) mod vendor_flow; use self::discovery::{ collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, diff --git a/crates/socket-patch-cli/tests/covgap_commands_get.rs b/crates/socket-patch-cli/tests/covgap_commands_get.rs index 3f6c7759..0e397bf5 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_get.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_get.rs @@ -2371,9 +2371,22 @@ async fn vendored_lock_held_vendor_step_errors_without_vendor_envelope() { ], ); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); - assert!( - stderr.contains("Error (lock_held):"), - "human mode must print the coded vendor-step error; stderr={stderr}" + // The shared vendor-step formatter: capitalized, period-terminated, + // plus the same wait hint every other lock error carries. + let line = stderr + .lines() + .position(|l| l.starts_with("Error (lock_held): ")) + .unwrap_or_else(|| panic!("no coded vendor-step error; stderr={stderr}")); + let lines: Vec<&str> = stderr.lines().collect(); + assert_eq!( + lines[line], + "Error (lock_held): Another socket-patch process is operating in this directory.", + "stderr={stderr}" + ); + assert_eq!( + lines.get(line + 1).copied(), + Some(" Wait for it to finish, or retry with --lock-timeout to wait for the lock."), + "stderr={stderr}" ); } } From 717a3bdf7cf3f377ff41c6ca0aedda4ef56d6257 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:02:34 -0400 Subject: [PATCH 08/18] refactor(cli/get): take the empty-crawl hint from scan's renderer get kept its own copy of "No packages found. Run your package manager's install first."; it now calls scan::render's no_packages_message, so the two cannot drift. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 11 ++++------- crates/socket-patch-cli/src/commands/scan/mod.rs | 2 +- crates/socket-patch-cli/src/commands/scan/render.rs | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index a2e38d01..c072a722 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -935,13 +935,10 @@ fn format_dry_run(action: &str, n: usize) -> String { ) } -/// What a package-name search says when the crawl found nothing. -fn no_packages_message(global: bool) -> &'static str { - if global { - "No global packages found." - } else { - "No packages found. Run your package manager's install first." - } +/// 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. diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index df8c3e55..c65eb6d7 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -32,7 +32,7 @@ use super::get::{download_and_apply_patches_with, select_patches, DownloadParams mod discovery; mod gc; mod hosted; -mod render; +pub(crate) mod render; pub(crate) mod vendor_flow; use self::discovery::{ diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 1b9b733f..82d91d02 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -176,7 +176,7 @@ pub(super) fn lockfile_only_note(n: usize) -> String { /// 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(super) fn no_packages_message( +pub(crate) fn no_packages_message( global: bool, ecosystems: Option<&[String]>, paths: &[String], From f8d52f17e094bb7fd644a30bc39f926cf784a031 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:02:34 -0400 Subject: [PATCH 09/18] fix(cli/scan): vendored --prune prints the same GC line as agent mode The vendored arm hand-rolled "GC: pruned N manifest entries.", which dropped the swept orphan files and said nothing when only files were removed. It now prints gc::format_gc_line, like the agent arm. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/scan/vendor_flow.rs | 10 ++++------ crates/socket-patch-cli/tests/cli_scan_silent.rs | 6 ++++-- 2 files changed, 8 insertions(+), 8 deletions(-) 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 0a4e2e55..ee282aad 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -694,13 +694,11 @@ async fn run_vendor_interactive_path( vendored_purls, ) .await; - if !args.common.silent && !gc.pruned.is_empty() { - println!( - "GC: pruned {}.", - plural(gc.pruned.len(), "manifest entry", "manifest entries") - ); - } 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); } } diff --git a/crates/socket-patch-cli/tests/cli_scan_silent.rs b/crates/socket-patch-cli/tests/cli_scan_silent.rs index d04c8c8b..e3826097 100644 --- a/crates/socket-patch-cli/tests/cli_scan_silent.rs +++ b/crates/socket-patch-cli/tests/cli_scan_silent.rs @@ -362,7 +362,7 @@ fn seed_manifest_with_gone_entry(root: &Path) { /// The vendored-mode GC line must honor `--silent` like the apply-mode one /// does: `scan --vendor --prune --silent --yes` prints nothing when it /// succeeds. Regression guard: `run_vendor_interactive_path` printed -/// "GC: pruned N manifest entries." (and the vendored-revert GC line) +/// "GC: pruned N manifest entries and removed …" (and the vendored-revert GC line) /// unconditionally. #[tokio::test] async fn scan_vendor_silent_gc_prints_nothing() { @@ -467,7 +467,9 @@ async fn scan_vendor_silent_gc_prints_nothing() { "control run must succeed; stderr={loud_stderr:?}" ); assert!( - loud_stdout.contains("GC: pruned 1 manifest entry."), + loud_stdout + .lines() + .any(|l| l == "GC: pruned 1 manifest entry and removed 0 orphan files (0 B)."), "non-silent vendor scan must print the GC line; got {loud_stdout:?}" ); } From 717e6a1a2b06eb588b9432e1b748dc6a202a7ad4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:03:06 -0400 Subject: [PATCH 10/18] fix(cli/scan): --json never stops at the interactive patch menu scan hands select_patches its args with json off (scan has no selection_required path), which also meant a free user's scan --json on a TTY with several patches per package got the interactive menu. --json now also counts as --yes there, taking the menu default (the top-ranked patch), as a non-TTY run already did. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-cli/src/commands/scan/mod.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index c65eb6d7..2b5f763a 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -467,11 +467,15 @@ async fn discover_selected( /// `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 still keeps the non-interactive note off stderr: the -/// process-wide quiet switch mutes it.) +/// 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() } } @@ -3157,6 +3161,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()]; From 573cf251482fa1603465dc911f826f3c7d974d55 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:03:50 -0400 Subject: [PATCH 11/18] fix(cli/get): decode the purl in the "already vendored" skip line The detached-ledger reuse printed the raw purl (pkg:npm/%40scope/...), unlike the "already in manifest" line beside it. Both now share format_record_skip, which displays the decoded purl. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/get.rs | 25 ++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index c072a722..2905e83a 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1009,6 +1009,12 @@ fn format_single_save( } } +/// ` [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 error printed when the nested apply failed. Under `--silent` /// apply's own per-patch failure lines are muted, so this one line is all /// the user gets: point at how to see the details. @@ -1827,7 +1833,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, @@ -1888,10 +1894,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, @@ -5453,6 +5456,18 @@ mod tests { ); } + #[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!( From c2f92a673c34ae5aac0afacfd618528c5c667b94 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:04:30 -0400 Subject: [PATCH 12/18] refactor(cli): one lock_held retry hint for the vendor step and hosted scan vendor_flow and hosted each spelled out the same --lock-timeout hint; both now use lock_cli::HELD_RETRY_HINT. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/lock_cli.rs | 6 ++++++ crates/socket-patch-cli/src/commands/scan/hosted.rs | 4 +--- crates/socket-patch-cli/src/commands/scan/vendor_flow.rs | 5 ++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index e1591929..11aee02d 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -92,6 +92,12 @@ fn waiting_message(lock_path: &Path, timeout: Duration) -> String { ) } +/// 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 diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 4cd2162c..f9789ee9 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -509,9 +509,7 @@ fn acquire_hosted_lock( // "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." - ); + eprintln!(" {}", crate::commands::lock_cli::HELD_RETRY_HINT); } if common.json { emit_json_error_with_code(scan_result.take(), Some(code), &message); 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 ee282aad..4d5e63ca 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -735,9 +735,8 @@ pub(crate) fn format_vendor_step_error(code: &str, message: &str) -> String { }; if code == "lock_held" { // Same advice as the other commands' lock error (lock_cli). - out.push_str( - "\n Wait for it to finish, or retry with --lock-timeout to wait for the lock.", - ); + out.push_str("\n "); + out.push_str(crate::commands::lock_cli::HELD_RETRY_HINT); } out } From 3f35f573e848bc86b1b03e2d2fc078b5a18ec67c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:05:58 -0400 Subject: [PATCH 13/18] fix(core/rollback): "roll back" is the verb in rollback errors "Cannot rollback: - " and "Cannot safely rollback." now read "Cannot roll back" / "Cannot safely roll back.", matching the CLI's own wording. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/rollback.rs | 6 +++--- crates/socket-patch-cli/tests/rollback_invariants.rs | 4 ++-- crates/socket-patch-core/src/patch/rollback.rs | 6 +++--- crates/socket-patch-core/tests/rollback_new_file_e2e.rs | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 3af3f974..b0b543b1 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -2428,7 +2428,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. @@ -3180,7 +3180,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); @@ -3887,7 +3887,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 diff --git a/crates/socket-patch-cli/tests/rollback_invariants.rs b/crates/socket-patch-cli/tests/rollback_invariants.rs index 520c7e39..8304a101 100644 --- a/crates/socket-patch-cli/tests/rollback_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_invariants.rs @@ -574,7 +574,7 @@ fn rollback_with_no_installed_packages_succeeds_quietly() { /// the before-blob MISSING must produce the identical envelope — the entry /// has no installed package, so its blob is never planned, probed, or /// fetched. Before the gate reorder this run hard-failed with exit 1, -/// `failed: 1`, and a synthesized `Cannot rollback: ... Before blob not +/// `failed: 1`, and a synthesized `Cannot roll back: ... Before blob not /// found` result carrying `path: ""` — a blob error for a package with /// nothing on disk to roll back. #[test] @@ -703,7 +703,7 @@ fn rollback_mixed_installed_gated_and_not_installed_entries() { // package: engine vocabulary + repair remedy. let err = entry["error"].as_str().expect("error message string"); assert!( - err.contains("Cannot rollback") && err.contains("socket-patch repair"), + err.contains("Cannot roll back: ") && err.contains("socket-patch repair"), "pinned abort error shape; got: {err}" ); let verified = entry["filesVerified"] diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs index ac0118c4..b797709f 100644 --- a/crates/socket-patch-core/src/patch/rollback.rs +++ b/crates/socket-patch-core/src/patch/rollback.rs @@ -157,7 +157,7 @@ pub async fn verify_file_rollback( file: file_name.to_string(), status: VerifyRollbackStatus::HashMismatch, message: Some( - "File has been modified after patching. Cannot safely rollback.".to_string(), + "File has been modified after patching. Cannot safely roll back.".to_string(), ), current_hash: Some(current_hash), expected_hash: Some(file_info.after_hash.clone()), @@ -296,7 +296,7 @@ pub async fn verify_file_rollback( file: file_name.to_string(), status: VerifyRollbackStatus::HashMismatch, message: Some( - "File has been modified after patching. Cannot safely rollback.".to_string(), + "File has been modified after patching. Cannot safely roll back.".to_string(), ), current_hash: Some(current_hash), expected_hash: Some(file_info.after_hash.clone()), @@ -320,7 +320,7 @@ pub async fn verify_file_rollback( /// before this engine runs) emits byte-identical errors — the string /// reaches users through both stderr and the `--json` envelope. pub fn cannot_rollback_error(file: &str, why: &str) -> String { - format!("Cannot rollback: {file} - {why}") + format!("Cannot roll back: {file} - {why}") } /// Verify and rollback patches for a single package. diff --git a/crates/socket-patch-core/tests/rollback_new_file_e2e.rs b/crates/socket-patch-core/tests/rollback_new_file_e2e.rs index 04009e30..5cb2985e 100644 --- a/crates/socket-patch-core/tests/rollback_new_file_e2e.rs +++ b/crates/socket-patch-core/tests/rollback_new_file_e2e.rs @@ -110,7 +110,7 @@ async fn verify_new_file_rollback_hash_mismatch_when_user_modified() { // containing "modified". assert_eq!( result.message.as_deref(), - Some("File has been modified after patching. Cannot safely rollback.") + Some("File has been modified after patching. Cannot safely roll back.") ); // The reported current hash must be the production hash of the *mutated* // on-disk bytes (proving it re-hashed disk, not echoed the manifest), and From 0de0d86b3980b831a8043ee596bb71012fe2756c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:08:12 -0400 Subject: [PATCH 14/18] fix(cli/setup): the setup preview shows the dependencies hook it writes setup writes both scripts.postinstall and scripts.dependencies, but the preview listed only the postinstall line (the remove preview already showed both). Core UpdateResult now carries the old/new dependencies script and the preview prints "-> dependencies" when it changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/setup.rs | 23 +++++++++++++++++++ .../src/package_json/update.rs | 14 ++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 468bee95..3d322054 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -2238,6 +2238,14 @@ fn format_setup_preview( for r in &npm_changes { 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 @@ -2368,6 +2376,8 @@ mod tests { 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), } } @@ -2556,6 +2566,19 @@ mod tests { 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( diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs index 492f467b..0c419d9e 100644 --- a/crates/socket-patch-core/src/package_json/update.rs +++ b/crates/socket-patch-core/src/package_json/update.rs @@ -12,6 +12,10 @@ pub struct UpdateResult { pub old_script: String, /// New `postinstall` script. pub new_script: String, + /// Previous `dependencies` script (empty if absent). + pub old_dependencies_script: String, + /// New `dependencies` script (equal to the old one when unchanged). + pub new_dependencies_script: String, pub error: Option, } @@ -41,13 +45,15 @@ pub async fn update_package_json( status: UpdateStatus::Error, old_script: String::new(), new_script: String::new(), + old_dependencies_script: String::new(), + new_dependencies_script: String::new(), error: Some(e.to_string()), }; } }; match update_package_json_content(&content, pm) { - Ok((modified, new_content, old_pi, new_pi, _, _)) => { + Ok((modified, new_content, old_pi, new_pi, old_deps, new_deps)) => { if modified && !dry_run { if let Err(e) = atomic_write_bytes_preserving_mode(package_json_path, new_content.as_bytes()) @@ -58,6 +64,8 @@ pub async fn update_package_json( status: UpdateStatus::Error, old_script: old_pi, new_script: new_pi, + old_dependencies_script: old_deps, + new_dependencies_script: new_deps, error: Some(e.to_string()), }; } @@ -72,6 +80,8 @@ pub async fn update_package_json( }, old_script: old_pi, new_script: new_pi, + old_dependencies_script: old_deps, + new_dependencies_script: new_deps, error: None, } } @@ -80,6 +90,8 @@ pub async fn update_package_json( status: UpdateStatus::Error, old_script: String::new(), new_script: String::new(), + old_dependencies_script: String::new(), + new_dependencies_script: String::new(), error: Some(e), }, } From 156260c24c9a0f8bdf6db5d586be3fddccf499b6 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 13:09:47 -0400 Subject: [PATCH 15/18] refactor(cli): one "Skipping VEX generation" line for every --dry-run --vex scan's agent arm said "[dry-run] VEX generation skipped. No attestation written." while apply, vendor and hosted scan said "Skipping VEX generation (--dry-run: nothing was ).". All four now print vex::format_vex_dry_run_skip. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/apply.rs | 5 +++- .../src/commands/scan/hosted.rs | 5 +++- .../socket-patch-cli/src/commands/scan/mod.rs | 2 +- .../socket-patch-cli/src/commands/vendor.rs | 5 +++- crates/socket-patch-cli/src/commands/vex.rs | 11 ++++++++ .../tests/covgap_commands_scan_mod.rs | 28 +++++++++++++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 7c0be2bd..b2a636e2 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1102,7 +1102,10 @@ pub(crate) async fn run_locked( && args.common.dry_run && args.vex.vex.is_some() { - println!("Skipping VEX generation (--dry-run: nothing was applied)."); + println!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("applied") + ); } } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index f9789ee9..7c5e5b6c 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -2550,7 +2550,10 @@ pub(crate) async fn run_redirect_selected( .display(), ); } else if vex.vex.is_some() && common.dry_run { - eprintln!("Skipping VEX generation (--dry-run: nothing was redirected)."); + eprintln!( + "{}", + crate::commands::vex::format_vex_dry_run_skip("redirected") + ); } if !common.dry_run { for line in diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 2b5f763a..a7645bc1 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -373,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; } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index c9d9ba3f..b148723f 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -691,7 +691,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(); diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 30c1ceee..21826762 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -355,6 +355,13 @@ pub(crate) fn format_vex_written(statements: usize, path: &Path) -> String { ) } +/// 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!( @@ -1403,6 +1410,10 @@ mod tests { 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" diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 37054b07..1a10a20d 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -881,6 +881,34 @@ async fn scan_human_preview_renders_vulnerability_details() { ); } +/// `scan --dry-run --vex`: the embedded VEX is skipped with the same note +/// apply, vendor and hosted scan print, and no document is written. +#[tokio::test] +async fn scan_human_dry_run_vex_prints_the_shared_skip_line() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + mount_batch_one(&mock, purl, UUID, "free", &[], false).await; + mount_by_package(&mock, purl, UUID, serde_json::json!({})).await; + + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2", b"x\n"); + + let (code, stdout, stderr) = run_scan_human( + tmp.path(), + &mock.uri(), + &["--dry-run", "--yes", "--vex", "out.vex.json"], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout + .lines() + .any(|l| l == "Skipping VEX generation (--dry-run: nothing was applied)."), + "got {stdout:?}" + ); + assert!(!tmp.path().join("out.vex.json").exists()); +} + // --------------------------------------------------------------------------- // Human post-apply GC line (`--sync`) — both pluralization arms — and the // hosted_wiring_retained warning after an in-place apply From c31bfa93c4c5c36bafb9944c74e70087ed56d2c1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 23 Sep 2026 14:40:11 -0400 Subject: [PATCH 16/18] fix(cli): terminal-UX polish from the fresh-eyes e2e review Output fixes found by driving the binary (pty + pipes): - get/scan --json: the nested apply no longer prints per-package "Error: Failed to patch" lines on stderr (regression from eb131ca); ApplyArgs gains a non-CLI `nested` marker carrying the caller's JSON-ness. The closing "Some patches could not be applied" line drops its stale "re-run without --silent" hint (the per-package lines print under --silent now). - Deterministic order: apply walks packages in PURL order; core apply and rollback walk a patch's files in name order, so the file named in "Cannot apply patch: " is stable run to run. - The nested apply's "Patched packages:" separator goes to stderr: piped stdout of `get`/`scan --yes` no longer carries a double blank line. - select_one fits the prompt and options to the terminal width, so dialoguer's line-count erase leaves no menu rows behind at 40-80 cols. - scan: one blank line opens the post-Summary skip/recorded block and the select menu; a report-only piped scan picks without the non-interactive note; the zero-patch result is said once (stdout); a failed API batch is a "Warning: API batch N of M failed" (none for a one-batch scan) and the fatal line reads "Error: The API query failed: ...". - Progress through StatusLine only: fetch_stage's artifact download and in-memory content fetch (no "(mode: diff)" tag), a per-package vendor progress line, the `--update` version check, setup's search/configure/ apply/remove phases, and the contended-lock wait for every direct apply-lock site (new lock_cli::acquire_with_status). get and hosted scan print the shared "Error: Another socket-patch process ..." line plus the lock-file hint (lock_cli::format_lock_error). - Wording: npm drift warning names the live resolution instead of a Debug `Some("...")`; missing-lockfile refusal lists lockfiles once and says "in the project root" for `.`; "All N packages are already vendored; nothing to do."; repair says "No manifest; no patch artifacts to download." on vendored-only projects, "Would rebuild/download ...:" instead of "Dry run - would ...", and ends the hosted-only sentence with a period; vendor warns when the ledger is unreadable instead of "No manifest found, nothing to vendor."; hosted "No patches could be redirected:" items drop their redundant lead; remove's dry-run footer ("Dry run: no changes made.") closes the whole preview; multi-advisory summaries read "(highest: HIGH)", colored; one non-interactive note wording; "Setup cancelled." / "Hook removal cancelled.". - Errors reach stderr: rollback and setup human runs that exit 1 end with a closing Error: line (the per-item detail stays in the stdout report). - setup --remove preview lists only the scripts the file has, values aligned; no "Proposed changes:" header over an empty preview. - Help: no protocol notes in --vendor-url/--patch-server-url/--vendor-source, --lock-timeout names get/scan, setup --remove names Composer, `--update --version` prints "socket-patch ". Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/args.rs | 17 +- crates/socket-patch-cli/src/commands/apply.rs | 51 ++++- .../src/commands/fetch_stage.rs | 73 ++++-- crates/socket-patch-cli/src/commands/get.rs | 148 ++++++++----- .../socket-patch-cli/src/commands/lock_cli.rs | 109 ++++++--- .../socket-patch-cli/src/commands/remove.rs | 19 +- .../socket-patch-cli/src/commands/repair.rs | 64 +++++- .../src/commands/repair_vendor.rs | 6 +- .../socket-patch-cli/src/commands/rollback.rs | 31 ++- .../socket-patch-cli/src/commands/scan/gc.rs | 3 +- .../src/commands/scan/hosted.rs | 36 ++- .../socket-patch-cli/src/commands/scan/mod.rs | 82 +++++-- .../src/commands/scan/render.rs | 31 +++ .../src/commands/scan/vendor_flow.rs | 10 +- crates/socket-patch-cli/src/commands/setup.rs | 209 +++++++++++++----- .../socket-patch-cli/src/commands/update.rs | 28 ++- .../socket-patch-cli/src/commands/vendor.rs | 91 +++++++- crates/socket-patch-cli/src/lib.rs | 4 +- crates/socket-patch-cli/src/ui/prompt.rs | 61 ++++- .../socket-patch-cli/tests/cli_scan_silent.rs | 2 +- .../tests/cli_setup_silent.rs | 6 +- .../tests/covgap_commands_fetch_stage.rs | 22 +- .../tests/covgap_commands_get.rs | 58 ++++- .../tests/covgap_commands_repair.rs | 2 +- .../tests/covgap_commands_rollback.rs | 5 + .../tests/covgap_commands_scan_hosted.rs | 20 +- .../tests/covgap_commands_scan_mod.rs | 16 +- .../tests/covgap_commands_setup.rs | 23 +- .../socket-patch-cli/tests/covgap_output.rs | 4 +- .../tests/help_text_hygiene.rs | 30 +++ .../tests/in_process_alternate_installers.rs | 1 + .../tests/in_process_edge_cases.rs | 1 + .../tests/in_process_pypi_apply.rs | 1 + .../tests/in_process_redirect.rs | 2 +- .../tests/interactive_prompts_e2e.rs | 6 +- .../tests/setup_terminal_output.rs | 11 +- crates/socket-patch-core/src/patch/apply.rs | 56 ++++- .../socket-patch-core/src/patch/rollback.rs | 36 ++- .../src/vendor/npm_flavor.rs | 40 +++- .../socket-patch-core/src/vendor/npm_lock.rs | 34 ++- 40 files changed, 1125 insertions(+), 324 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 97e7d1cc..df137f10 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -167,8 +167,8 @@ 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` and the vendored - /// modes of `scan`/`get` use 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", @@ -178,8 +178,7 @@ 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`. // A dev/testing knob: listed in `--help`, left out of the `-h` summary. @@ -187,7 +186,7 @@ pub struct GlobalArgs { 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. @@ -299,10 +298,10 @@ pub struct GlobalArgs { /// 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 lock-contending subcommands (`apply`, `rollback`, `repair`, - /// `remove`, `vendor`, `setup --exclude`'s manifest write, and the - /// hosted and vendored modes of `scan`/`get`); other commands accept it - /// silently. Every holder removes the lock file on exit, so a leftover + /// 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, diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index b2a636e2..57bf4130 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -335,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 ──────────────────────────────────────────────── @@ -947,7 +970,16 @@ pub(crate) async fn run_locked( if !args.common.json && !args.common.silent { let cwd = std::fs::canonicalize(&args.common.cwd) .unwrap_or_else(|_| args.common.cwd.clone()); - for line in format_results_block(&results, args.common.dry_run, &cwd) { + 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() { @@ -1687,7 +1719,7 @@ async fn apply_patches_inner( // 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 { + if !unmatched.is_empty() && args.prints_errors() { for line in format_none_installed_error(&unmatched) { eprintln!("{line}"); } @@ -1736,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 @@ -1925,7 +1962,7 @@ async fn apply_patches_inner( // command still reported `success` / exit 0. has_errors = true; // Errors print even under --silent. - if !args.common.json { + if args.prints_errors() { eprintln!( "{}", format_patch_failure( @@ -1966,7 +2003,7 @@ 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.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 @@ -2032,7 +2069,7 @@ async fn apply_patches_inner( if !result.success { has_errors = true; // Errors print even under --silent. - if !args.common.json { + if args.prints_errors() { eprintln!( "{}", format_patch_failure( @@ -2063,7 +2100,7 @@ async fn apply_patches_inner( // Nothing matched: this fails the run, so it is an error — and // errors print even under --silent. has_errors = true; - if !args.common.json { + if args.prints_errors() { for line in format_none_installed_error(&unmatched) { eprintln!("{line}"); } diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index 03221f06..3179bab5 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -22,7 +22,7 @@ use tempfile::TempDir; use super::get::base64_decode; use crate::args::GlobalArgs; use crate::commands::bun_preflight::LedgerLoad; -use crate::ui::plural; +use crate::ui::{plural, StatusLine}; /// Resolved artifact locations for the patch pipeline. Holds the overlay /// `TempDir` alive — sources become invalid when this is dropped. @@ -176,6 +176,14 @@ fn format_fetch_failures(result: &FetchMissingBlobsResult, (one, many): Noun) -> /// 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 { @@ -337,16 +345,14 @@ pub(crate) async fn stage_patch_sources( overlay_dir(&socket_diffs_path, &staged.diffs).await; overlay_dir(&socket_packages_path, &staged.packages).await; - // Progress: stderr, like every other status line (stdout is data). - if !quiet { - eprintln!( - "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 @@ -369,13 +375,12 @@ 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 { - eprintln!( - "{}", - format_blob_fallback(fetch_result.failed, 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 { for line in format_fetch_summary(&blob_result, BLOB, true) { eprintln!("{line}"); @@ -486,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"); @@ -547,12 +551,8 @@ pub(crate) async fn stage_vendor_sources_in_memory( return MemStageOutcome::Unavailable; } - if !quiet { - eprintln!( - "Fetching content for {}...", - plural(to_fetch.len(), "patch", "patches") - ); - } + let mut status = StatusLine::stderr(common.json, common.silent); + status.set(format_fetching_content(to_fetch.len())); let built; let client = match client { @@ -565,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; @@ -575,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; @@ -603,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 @@ -633,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"; diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 2905e83a..2e32ee27 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -15,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, @@ -244,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", @@ -254,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 } @@ -631,16 +639,20 @@ fn format_patch_option(p: &PatchSearchResult) -> String { } /// One-line human summary of a patch: -/// ` [] : fixes ()`. +/// ` [] : 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 purl is shown decoded (`%40scope` → `@scope`). +/// 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 { @@ -652,7 +664,12 @@ fn format_patch_summary( let sep = if patch_id.is_some() { ": " } else { " " }; line.push_str(&format!("{sep}fixes {}", labels.join(", "))); if let Some(sev) = max_vuln_severity(vulns) { - line.push_str(&format!(" ({})", sev.to_uppercase())); + 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 @@ -802,16 +819,16 @@ fn format_verbose_skips(skips: &[serde_json::Value]) -> Vec { .collect() } -/// Whether [`select_patches`] will put a menu in front of the user for -/// these candidates: a free user, several accessible patches for one purl, -/// no `--yes`/`--json`, and an interactive stdin (mirrors `select_one`). -fn selection_prompted( +/// 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 { - use std::io::IsTerminal; - if can_access_paid || common.yes || common.json || !std::io::stdin().is_terminal() { + if can_access_paid || common.yes || common.json { return false; } let mut seen = std::collections::HashSet::new(); @@ -821,16 +838,28 @@ fn selection_prompted( .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]) -> String { +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) + format_patch_summary(&p.purl, &p.tier, Some(&p.uuid), &p.vulnerabilities, color) )); } out.push('\n'); @@ -1015,16 +1044,10 @@ fn format_record_skip(purl: &str, why: &str) -> String { format!(" [skip] {} ({why})", normalize_purl(purl)) } -/// The error printed when the nested apply failed. Under `--silent` -/// apply's own per-patch failure lines are muted, so this one line is all -/// the user gets: point at how to see the details. -fn format_apply_failed(silent: bool) -> &'static str { - if silent { - "Error: Some patches could not be applied (re-run without --silent for details)." - } else { - "Error: Some patches could not be applied." - } -} +/// 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 @@ -2210,15 +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. `json` / `silent` are the -/// caller's flags: they decide the failure line (`common` itself is always -/// quiet and never JSON). The read-only cargo-redirect verifier stays off +/// 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, json: bool, - silent: bool, client: &ApiClient, lock: LockGuard, ) -> bool { @@ -2228,12 +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; // 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!("{}", format_apply_failed(silent)); + eprintln!("{APPLY_FAILED}"); } code == 0 } @@ -2261,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 { @@ -2368,7 +2396,6 @@ pub async fn download_and_apply_patches_with( apply_succeeded = run_nested_apply( nested_apply_args_from_params(params, run, &manifest_path), params.json, - params.silent, run.api_client, lock, ) @@ -2559,7 +2586,8 @@ pub async fn run(args: GetArgs) -> i32 { &patch.purl, &patch.tier, None, - &patch.vulnerabilities + &patch.vulnerabilities, + crate::ui::stderr_color(), ) ); } @@ -2920,7 +2948,7 @@ pub async fn run(args: GetArgs) -> i32 { &args.common, ) { - print!("{}", format_selected_patches(&selected)); + print!("{}", format_selected_patches(&selected, color)); } // Agent-mode dry run: preview against the manifest, write nothing. @@ -3271,10 +3299,10 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR // 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; } }; @@ -3334,7 +3362,6 @@ async fn save_and_apply_patch(args: &GetArgs, client: &ApiClient, patch: &PatchR apply_succeeded = run_nested_apply( nested_apply_args(&args.common, &manifest_path, quiet), args.common.json, - args.common.silent, client, lock, ) @@ -5086,7 +5113,7 @@ mod tests { vuln(&["CVE-2021-44906"], "critical", ""), ); assert_eq!( - format_patch_summary("pkg:npm/minimist@1.2.5", "free", None, &m), + 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!( @@ -5094,22 +5121,48 @@ mod tests { "pkg:npm/%40scope/x@1.0.0", "paid", Some("a8b05a61-1e2f-4c5f-a65b-93e71deba1ae"), - &m + &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()), + 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), + 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] @@ -5193,10 +5246,10 @@ mod tests { &[("GHSA-a", vuln(&["CVE-2026-4800"], "HIGH", ""))], ); assert_eq!( - format_selected_patches(&[a]), + 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(&[]), "Selected:\n\n"); + assert_eq!(format_selected_patches(&[], false), "Selected:\n\n"); } fn skip(purl: &str, code: &str) -> serde_json::Value { @@ -5470,14 +5523,7 @@ mod tests { #[test] fn apply_failed_line_is_an_error_even_when_silent() { - assert_eq!( - format_apply_failed(false), - "Error: Some patches could not be applied." - ); - assert_eq!( - format_apply_failed(true), - "Error: Some patches could not be applied (re-run without --silent for details)." - ); + assert_eq!(APPLY_FAILED, "Error: Some patches could not be applied."); } #[test] diff --git a/crates/socket-patch-cli/src/commands/lock_cli.rs b/crates/socket-patch-cli/src/commands/lock_cli.rs index 11aee02d..6b804c07 100644 --- a/crates/socket-patch-cli/src/commands/lock_cli.rs +++ b/crates/socket-patch-cli/src/commands/lock_cli.rs @@ -47,42 +47,64 @@ pub(crate) fn acquire_or_emit( dry_run: bool, timeout: Duration, ) -> Result { - let lock_path = socket_dir.join("apply.lock"); - let result = match acquire(socket_dir, Duration::ZERO) { - // Contended with a wait budget: say what we are waiting on, or a - // `--lock-timeout 30` run just sits there silently for 30 s. The - // status line is terminal-only and quiet under --json/--silent. + match acquire_with_status(socket_dir, timeout) { + Ok(guard) => Ok(guard), + Err(err) => { + 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); + 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(&lock_path, timeout)); + status.set(waiting_message(&socket_dir.join("apply.lock"), timeout)); let result = acquire(socket_dir, timeout); status.finish(); result } other => other, - }; - match result { - Ok(guard) => Ok(guard), - Err(err) => { - let hint = 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(), - }; - let (code, message) = lock_failure(&err, timeout); - emit(command, json, dry_run, code, &message, &hint); - Err(1) + } +} + +/// 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!( @@ -425,6 +447,43 @@ 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!( diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 54643ab9..2e6559f8 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -298,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); @@ -881,9 +885,7 @@ pub async fn run(args: RemoveArgs) -> i32 { 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()); } } @@ -978,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; @@ -1594,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(), diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 1104e0f2..58d0699b 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -90,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; @@ -102,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; } @@ -257,6 +257,28 @@ 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" }; @@ -437,11 +459,7 @@ async fn repair_inner( if missing_artifacts.is_empty() { if !quiet { - if manifest.as_ref().is_some_and(|m| m.patches.is_empty()) { - println!("No patches in manifest; nothing to download."); - } else { - println!("All {} are present locally.", noun.many); - } + println!("{}", format_nothing_missing(manifest.as_ref(), noun)); } } else if args.common.offline { if !quiet { @@ -456,7 +474,7 @@ async fn repair_inner( if args.common.dry_run { if !quiet { println!(); - println!("Dry run - would download:"); + println!("Would download:"); for line in format_id_list(&missing_artifacts, noun, DRY_RUN_LIST_CAP) { println!("{line}"); } @@ -671,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": { diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index c48ab6cf..5817afec 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -384,7 +384,7 @@ fn format_repair_failure(purl: &str, detail: &str) -> String { /// `(purl, reason code, artifact path)`. fn format_rebuild_preview(items: &[(String, &str, &str)]) -> Vec { let mut lines = vec![format!( - "Dry run - would rebuild {}:", + "Would rebuild {}:", plural(items.len(), "vendored artifact", "vendored artifacts") )]; lines.extend(items.iter().map(|(purl, reason, path)| { @@ -2253,7 +2253,7 @@ mod ui_format_tests { assert_eq!( format_rebuild_preview(&one), vec![ - "Dry run - would rebuild 1 vendored artifact:", + "Would rebuild 1 vendored artifact:", " - pkg:npm/minimist@1.2.5 (missing: .socket/vendor/npm/u/minimist-1.2.5.tgz)", ] ); @@ -2272,7 +2272,7 @@ mod ui_format_tests { assert_eq!( format_rebuild_preview(&two), vec![ - "Dry run - would rebuild 2 vendored artifacts:", + "Would rebuild 2 vendored artifacts:", " - pkg:npm/a@1 (corrupt: p/a.tgz)", " - pkg:gem/b@1 (unverified: p/b)", ] diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index b0b543b1..e339981c 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -243,6 +243,17 @@ 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 @@ -1972,6 +1983,11 @@ pub async fn run(args: RollbackArgs) -> i32 { 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 { println!("\nDetailed verification:"); @@ -2697,7 +2713,8 @@ pub(crate) async fn rollback_patches_inner( // 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 (or by `remove`). + // "Failed to roll back:" section plus a closing stderr error + // (or by `remove`). if common.silent && !common.json { eprintln!( "{}", @@ -4690,6 +4707,18 @@ mod tests { ); } + #[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"); diff --git a/crates/socket-patch-cli/src/commands/scan/gc.rs b/crates/socket-patch-cli/src/commands/scan/gc.rs index 8cd2efe6..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 { diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 7c5e5b6c..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; @@ -501,16 +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!(" {}", crate::commands::lock_cli::HELD_RETRY_HINT); - } + 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); } @@ -2793,20 +2793,26 @@ fn format_unredirected( 1 => " (see the warning below)", _ => " (see the warnings below)", }; - let indent = if nothing_redirected { " " } else { "" }; + // 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!( - "{indent}Skipped {purl}: {}", + "{skip_lead}{purl}: {}", describe_skip_reason(reason) )); } for purl in unconfirmed { lines.push(format!( - "{indent}Not redirected {purl}: no lockfile entry pinning it could be redirected{see}" + "{unpinned_lead}{purl}: no lockfile entry pinning it could be redirected{see}" )); } lines @@ -4194,8 +4200,16 @@ mod tests { format_unredirected(&[], &unconfirmed, true, 0), vec![ "No patches could be redirected:".to_string(), - " Not redirected pkg:npm/minimist@1.2.5: no lockfile entry pinning it could \ - be redirected" + " 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(), ] ); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a7645bc1..19e0f034 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -480,6 +480,14 @@ fn selection_args(common: &GlobalArgs) -> GlobalArgs { } } +/// 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 every @@ -2017,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 { - status.println(format!("Error 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(), + )); } } } @@ -2067,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; } @@ -2084,10 +2098,9 @@ pub async fn run(mut args: ScanArgs) -> i32 { plural(all_packages_with_patches.len(), "package", "packages") )); } else { - status.finish_with(format!( - "No patches found for {}", - plural(package_count, "package", "packages") - )); + // 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 @@ -2724,15 +2737,39 @@ pub async fn run(mut args: ScanArgs) -> i32 { return 1; } - // Smart selection - let selected: Vec = match select_patches( - &all_search_results, - can_access_paid_patches, - &selection_args(&args.common), - ) { - Ok(s) => s, - Err(code) => return code, - }; + // 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, &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 @@ -2745,9 +2782,11 @@ pub async fn run(mut args: ScanArgs) -> i32 { let split = partition_agent_selection(selected, &vendored_purls, &lockfile_only); if !silent { for purl in &split.vendored_purls { + open_paragraph(&mut skip_paragraph); println!("{}", render::vendored_skip_line(&normalize_purl(purl))); } for purl in &split.not_installed_purls { + open_paragraph(&mut skip_paragraph); println!("{}", render::not_installed_skip_line(&normalize_purl(purl))); } } @@ -2770,6 +2809,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { }; if !silent { for p in &already_recorded { + open_paragraph(&mut skip_paragraph); println!( "{}", render::already_recorded_line(&normalize_purl(&p.purl), &p.uuid) @@ -2779,6 +2819,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { if selected.is_empty() { if !silent { + open_paragraph(&mut skip_paragraph); if already_recorded.is_empty() { println!("No patches selected."); } else { @@ -2871,14 +2912,7 @@ pub async fn run(mut args: ScanArgs) -> i32 { 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 report_only = args.mode.is_none() && !args.prune && !args.common.yes && !ui::stdin_is_tty(); + // Report-only (see `report_only` above): stop before the prompt. if report_only { // The "Patches to apply:" listing already ends with a blank line. if !silent { diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 82d91d02..65af51bc 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -204,6 +204,21 @@ fn quoted_list(items: &[String], one: &str, many: &str) -> String { /// 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."; @@ -455,6 +470,22 @@ pub(super) fn patch_block(b: &PatchBlock) -> Vec { 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(), 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 4d5e63ca..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,7 +21,6 @@ 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}; @@ -196,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. diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 3d322054..e46785a9 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -12,7 +12,6 @@ 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::{ @@ -82,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, @@ -407,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); @@ -1194,13 +1194,15 @@ fn format_check_footer(hooks: usize, drifted: usize, errors: usize) -> String { /// 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 { - eprintln!("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. @@ -1268,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( @@ -1335,6 +1338,9 @@ async fn run_check(args: &SetupArgs) -> i32 { } println!(); 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 // read/parse failures must still reach stderr. A plain @@ -1362,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 { @@ -1370,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 { - eprintln!("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. @@ -1400,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 @@ -1474,7 +1494,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { 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, &common.cwd), ); @@ -1496,7 +1516,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { ) ); } - eprint_errors_when_silent( + eprint_errors( common, &remove_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), ); @@ -1513,14 +1533,12 @@ async fn run_remove(args: &SetupArgs) -> i32 { } if !crate::ui::confirm_or_proceed("Remove these install hooks?", common) { if !common.silent { - eprintln!("Aborted."); + eprintln!("{REMOVE_CANCELLED}"); } return 0; } - if !quiet { - eprintln!("\nRemoving install hooks..."); - } + 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); @@ -1542,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() @@ -1599,7 +1618,7 @@ async fn run_remove(args: &SetupArgs) -> i32 { } print_warnings(common, &warnings); - eprint_errors_when_silent( + eprint_errors( common, &remove_error_messages(&npm_results, &py_results, &extra_results, &common.cwd), ); @@ -1665,15 +1684,36 @@ fn format_warning(w: &str) -> String { /// 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. @@ -1728,7 +1768,7 @@ fn format_remove_preview( extra: &SetupOutcome, cwd: &Path, ) -> String { - let mut out = String::from("\nProposed changes:\n"); + let mut out = String::new(); let to_remove: Vec<_> = npm .iter() .filter(|r| r.status == RemoveStatus::Removed) @@ -1737,19 +1777,20 @@ fn format_remove_preview( out.push_str("\nWill remove socket-patch from:\n"); for r in &to_remove { out.push_str(&format!(" - {}\n", pathdiff(&r.path, cwd))); - out.push_str(&format!(" postinstall: \"{}\"\n", r.old_script)); - out.push_str(&format!( - " -> postinstall: {}\n", - render_removed(&r.new_script) - )); - out.push_str(&format!( - " dependencies: \"{}\"\n", - r.old_dependencies_script - )); - out.push_str(&format!( - " -> dependencies: {}\n", - render_removed(&r.new_dependencies_script) - )); + // 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))); + } + } } } let py_remove: Vec<_> = py @@ -1766,7 +1807,12 @@ fn format_remove_preview( // 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)); - out + // 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. @@ -1874,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 { - eprintln!("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 @@ -1895,7 +1948,12 @@ async fn run_setup(args: &SetupArgs) -> i32 { .as_ref() .map(|f| unmatched_excludes(f, &common.cwd, &excludes)) .unwrap_or_default(); - warn_unmatched_excludes(common, &unmatched); + 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 @@ -1922,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() @@ -2039,7 +2098,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { } } print_warnings(common, &warnings); - eprint_errors_when_silent( + eprint_errors( common, &setup_error_messages(&npm_preview, &py_preview, &extra_preview, &common.cwd), ); @@ -2070,7 +2129,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { 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, &common.cwd), ); @@ -2086,7 +2145,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { } if !crate::ui::confirm_or_proceed("Proceed with these changes?", common) { if !common.silent { - eprintln!("Aborted."); + eprintln!("{SETUP_CANCELLED}"); } return 0; } @@ -2095,9 +2154,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { // returned above; an aborted or no-project run never gets here). let persist_warning = persist_setup_excludes(common, &existing, &to_persist).await; - if !quiet { - eprintln!("\nApplying changes..."); - } + status.set("Applying changes..."); let mut npm_results = Vec::new(); for loc in &npm_files { @@ -2124,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() @@ -2185,7 +2243,7 @@ async fn run_setup(args: &SetupArgs) -> i32 { } print_warnings(common, &warnings); - eprint_errors_when_silent( + eprint_errors( common, &setup_error_messages(&npm_results, &py_results, &extra_results, &common.cwd), ); @@ -2608,6 +2666,55 @@ mod tests { ); } + #[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)]; @@ -2627,8 +2734,8 @@ mod tests { 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: \ + 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" diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index bde51d11..fa6c384b 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -194,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; @@ -258,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 @@ -415,6 +427,14 @@ 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() } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index b148723f..3628764a 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -48,7 +48,7 @@ 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; +use crate::ui::{plural, StatusLine}; #[derive(Args)] pub struct VendorArgs { @@ -397,6 +397,16 @@ fn format_vendor_failure(purl: &str, detail: &str) -> String { /// 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)); @@ -471,6 +481,15 @@ impl VendorTally { /// `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} {}", @@ -615,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; } @@ -765,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(), @@ -1366,7 +1394,12 @@ pub(crate) async fn vendor_records( // 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(); - for (purl, pkg_path) in &all_packages { + // 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 { @@ -1626,6 +1659,12 @@ pub(crate) async fn vendor_records( } } + status.set(format_vendor_progress( + common.dry_run, + &normalize_purl(candidate), + index + 1, + total, + )); let outcome = dispatch_vendor_one( candidate, pkg_path, @@ -1639,6 +1678,7 @@ pub(crate) async fn vendor_records( &pipenv_version, ) .await; + status.finish(); match outcome { None => { @@ -3628,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!( @@ -4000,6 +4050,18 @@ mod pristine_fetch_tests { 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, @@ -4046,13 +4108,22 @@ mod ui_format_tests { 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)), - "Vendored 0 packages; 2 already vendored." + "All 2 packages are already vendored; nothing to do." ); assert_eq!( format_vendor_summary(true, &tally(0, 2, 0, 0, 0)), - "Would vendor 0 packages; 2 already vendored." + "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)), @@ -4102,7 +4173,7 @@ mod ui_format_tests { ); assert_eq!( format_vendor_summary(true, &VendorTally::from_envelope(&dry, true, 3)), - "Would vendor 0 packages; 3 already vendored." + "All 3 packages are already vendored; nothing to do." ); } diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index bbcf5475..6406d9ab 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -98,10 +98,12 @@ pub enum Commands { // 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. + // 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]" )] diff --git a/crates/socket-patch-cli/src/ui/prompt.rs b/crates/socket-patch-cli/src/ui/prompt.rs index d9c2a8b2..86b76e06 100644 --- a/crates/socket-patch-cli/src/ui/prompt.rs +++ b/crates/socket-patch-cli/src/ui/prompt.rs @@ -14,7 +14,7 @@ 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: auto-selecting first option."; + "Non-interactive mode detected, selecting the first option."; /// Ask a yes/no question on stderr. Returns the answer. /// @@ -199,10 +199,11 @@ pub fn select_one( } 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) + .items(&options) .default(0) .interact_opt(); match picked { @@ -211,6 +212,22 @@ pub fn select_one( } } +/// 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 `❯