diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 24ab66cd..244ae6bf 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -15,7 +15,7 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth | `setup` | — | Wire automatic-patching install hooks (npm/pypi/gem) | | `rollback` | — | Restore original files; takes optional positional `identifier` | | `get` | `download` | Fetch + apply patch; requires positional `identifier` | -| `list` | — | Print patches in the local manifest | +| `list` | — | Print patches in the local manifest, plus the hosted redirect ledger's records (labeled; see the `manifest_not_found` row and the action matrix) | | `remove` | — | Remove patch from manifest (rolls back first); requires positional `identifier` | | `repair` | `gc` | Download missing blobs, rebuild missing/corrupt vendored artifacts, clean up unused ones, and delete the leftover `<.socket>/apply.lock` as a final housekeeping step (skipped under `--dry-run`; refuses with `lock_held` when a live process holds the lock) | @@ -87,7 +87,9 @@ Beyond the globals above, each subcommand defines a small set of local arguments **pnpm hosted-mode contract (v3.5)**: `scan --mode hosted` rewrites pnpm locks of every major since pnpm 7 — lockfileVersion 5.4, 6.0, and 9.0. Legacy grammars are spliced across **every** instance key of the dep (v5 `/name/ver` + `_peer`-suffixed, v6 `/name@ver(peer)` — each owns its own `resolution:`), one revert-ledger `redirect_pnpm_resolution` edit per instance; a partial rewrite is never possible. When a **9.0 root lock** was rewritten this run, the CLI also ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with the root-only `packages:` scaffold, or the single line appended to an existing file with all user bytes preserved) so pnpm ≥ 11's lockfile verification accepts the repointed tarballs with no flags and no CI changes (pnpm ≤ 10 ignores the key; the sha512 tarball pin still fails closed on tampered bytes). The write is recorded in the redirect ledger as a `redirect_pnpm_workspace_trust` edit (`created`/`added`), respects `--dry-run`, is skipped for legacy 5.4/6.0 locks and Rush repos, never overwrites an explicit user `trustLockfile:` value, and is disabled by `--no-trust-lockfile-config` (which restores the manual `--trust-lockfile` / committable-yaml guidance in the `redirect_pnpm_trust_lockfile` warning). Additive no-lockfile diagnostics: `redirect_pnpm_legacy_lockfile` (a pnpm ≤ 2-era `shrinkwrap.yaml` is present) and `redirect_pnpm_no_lockfile` (pnpm markers but no lock) replace the npm-flavored wording on marker-bearing projects; `redirect_pnpm_entry_vendored` names a dep whose lock entry is vendored (`socket-patch vendor --revert` to switch modes) instead of the misleading entry-not-found. **Takeover reconciliation (npm family)**: vendoring over a hosted-redirected purl drops that purl's records + package edits from `redirect-state.json` (the vendor wiring embeds the hosted-spliced fragments as `original`, so `vendor --revert` byte-restores the hosted lock); the `vendor_supersedes_redirect` warning fires exactly once, on the run that reconciles. -`scan --apply` opts JSON callers into the full discover → select → apply pipeline. Without it, `scan --json` stays read-only (discovery + `updates` array only). No effect outside `--json` mode — the non-JSON path always prompts the user interactively. +`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 always prompts the user interactively. + +**Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--apply`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the hosted redirect ledger (`.socket/vendor/redirect-state.json`) holds ≥ 1 `records` entry: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning — a hosted-wired project's report-only scan used to be byte-identical to a never-touched project's. `mode` is the constant `"hosted"` (the mode's documented name, whatever opaque `mode` string the ledger itself carries — pre-rename ledgers say `"redirect"`) and `ledger` the ledger's repo-relative path. `records` lists every ledger record (sorted by ledger key): each entry's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the records↔proof join is a plain string compare, and `ledgerKey` preserves the ledger's verbatim key (percent-encoded scoped names, `?platform=` qualifiers) for consumers addressing the ledger itself. `wiringLive` is the subset of this run's *counted* purls (post-`--ecosystems`-filter) whose hosted lockfile wiring the LIVE lock still proves — the same proof, computed once per run, that feeds `hosted_wiring_retained`. Consumers must treat the split as exactly that: records are the ledger's word, `wiringLive` the live lock's proof — a record with no proof means the wiring was unwound, the lock is unreadable, or the purl was not crawled/queried this run (an `--ecosystems` filter, a zero discovery), never "still live". The key is omitted when the ledger is absent or its `records` are empty (an edits-only ledger asserts no patches), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A malformed ledger degrades to "nothing to consult" (no block) with a stderr warning, muted by `--silent`. Hosted-mode runs carry the `redirect` sub-object instead (the run's own result; the ledger is re-persisted mid-run), and vendored-mode runs carry the takeover warnings (their reconciliation may retire records mid-run) — neither duplicates a pre-run snapshot that could go stale. **Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--apply` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (the per-patch `skipped`/`vendored` records in `apply.patches[]` are unchanged); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the hosted redirect ledger records scanned package(s) whose hosted lockfile wiring the live lock still proves (the agent run does not unwind hosted wiring; no npm/yarn hosted revert exists); the detail names the purls and the options (stay `--mode hosted`, or migrate via `scan --mode vendored`) and never advises hand-deleting the ledger. The warning keys on ledger *records* still live at scan time — a flow that pre-reverted the redirect (retiring the records) retires the warning with them, even while the append-only `edits` (revert originals) remain. The interactive path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. @@ -895,7 +897,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`. | +| `manifest_not_found` | list, remove, repair, rollback | `.socket/manifest.json` doesn't exist. v3.5: `repair` proceeds anyway (vendored phase only) when a vendor ledger or vendor-path lockfile references exist, and exits 0 with a `redirect_only_project` skip (not this error) when the only `.socket/` trace is a hosted-mode `redirect-state.json`. `list` likewise no longer fires this on a hosted-only project: when the hosted redirect ledger holds ≥ 1 `records` entry, the records are listed (exit 0, labeled `details.mode: "hosted"` + `details.ledger`; when the manifest exists too, both stores are shown, purl-sorted with the manifest entry first on a tie). Both stores always come from the SAME project: the ledger is resolved against the root the RESOLVED manifest path implies (its `.socket` parent's parent in the standard layout, else the manifest file's directory — exactly `--cwd` for the default path), so `--manifest-path` into another project reads that project's ledger, never the local one. The error still fires when NEITHER store has a record — an edits-only ledger asserts no patches — and a present-but-broken manifest still reports `manifest_invalid`/`manifest_unreadable` regardless of ledger records (corruption is never masked). A malformed ledger degrades to "nothing to consult" with a stderr warning, muted by `--silent` (read-only consumer posture; the hosted write path hard-errors instead). | | `manifest_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. | @@ -908,7 +910,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified |--------------|---| | `apply` | `Applied` · `Updated` · `Skipped` (already_patched / package_not_installed / vendored) · `Failed` · `Verified` (dry-run) | | `vendor` | `Applied` (= vendored; `command` routes) · `Skipped` (refusals, warnings, unsupported ecosystems) · `Failed` · `Removed` (reconcile + `--revert`) · `Verified` (dry-run) | -| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`) | +| `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`; hosted redirect-ledger records additionally carry `details.mode: "hosted"` — the constant mode name, whatever opaque mode string the ledger itself carries — and `details.ledger: ".socket/vendor/redirect-state.json"`, both additive and absent on manifest entries) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | | `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 1475d748..151b1ed0 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,6 +1,9 @@ +use std::path::{Path, PathBuf}; + use clap::Args; use socket_patch_core::manifest::operations::read_manifest; -use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; +use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; use socket_patch_core::telemetry::track_patch_listed; use socket_patch_core::utils::socket_cli_config; @@ -15,27 +18,71 @@ pub struct ListArgs { pub common: GlobalArgs, } -/// Build the `list --json` envelope: one `Discovered` event per manifest -/// entry, with the rich metadata (vulnerabilities, tier, license, -/// description, exportedAt) under `details` per the per-command extension -/// convention. +/// One listable patch record with its provenance: a `.socket/manifest.json` +/// entry (agent/vendored modes) or a hosted redirect-ledger record +/// (`scan --mode hosted` records its patches ONLY in +/// `.socket/vendor/redirect-state.json` and never writes the manifest — +/// without the ledger records, a purely hosted-wired project listed as +/// `manifest_not_found` while its patches were demonstrably live). +struct ListEntry<'a> { + purl: &'a str, + record: &'a PatchRecord, + /// `true` when the record comes from the hosted redirect ledger. + hosted: bool, +} + +/// Every listable record from both stores, in a stable order: by PURL, the +/// manifest entry before the hosted-ledger record when one purl appears in +/// BOTH (coexistence is real state — e.g. an agent-applied patch alongside +/// live hosted wiring — so both are shown, labeled apart). The record maps +/// (`HashMap` manifest / `BTreeMap` ledger) never impose an order shared +/// consumers could diff, so the sort here is the contract. +fn combined_entries<'a>( + manifest: Option<&'a PatchManifest>, + redirect: Option<&'a RedirectState>, +) -> Vec> { + let mut entries: Vec> = Vec::new(); + if let Some(manifest) = manifest { + entries.extend(manifest.patches.iter().map(|(purl, record)| ListEntry { + purl, + record, + hosted: false, + })); + } + if let Some(redirect) = redirect { + entries.extend(redirect.records.iter().map(|(purl, record)| ListEntry { + purl, + record, + hosted: true, + })); + } + entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.hosted.cmp(&b.hosted))); + entries +} + +/// Build the `list --json` envelope: one `Discovered` event per entry, with +/// the rich metadata (vulnerabilities, tier, license, description, +/// exportedAt) under `details` per the per-command extension convention. +/// Hosted-ledger records additionally carry `details.mode` (the constant +/// [`crate::commands::HOSTED_MODE_LABEL`]) and `details.ledger` naming the +/// redirect ledger (additive keys, absent on manifest entries), so +/// consumers can tell the stores apart. /// -/// Patches, vulnerabilities, and files are each emitted in a stable sorted -/// order (by PURL / advisory ID / path). `HashMap` iteration is otherwise -/// nondeterministic, so without this the event/vuln/file ordering would -/// change run-to-run — breaking consumers that diff this output in CI logs. -/// Mirrors the stable-ordering guarantee `get` already provides for its -/// vulnerability lists. +/// Events are emitted in the entries' given order — [`combined_entries`] +/// owns the by-PURL event sort; this builder sorts each event's +/// vulnerabilities (by advisory ID) and files (by path). `HashMap` +/// iteration is otherwise nondeterministic, so without these sorts the +/// vuln/file ordering would change run-to-run — breaking consumers that +/// diff this output in CI logs. Mirrors the stable-ordering guarantee +/// `get` already provides for its vulnerability lists. /// /// Shared by `run` and the unit tests so the tests exercise the exact code /// path `list --json` uses, rather than a hand-copied duplicate. -fn build_list_envelope(manifest: &PatchManifest) -> Envelope { +fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { let mut env = Envelope::new(Command::List); - let mut patch_entries: Vec<_> = manifest.patches.iter().collect(); - patch_entries.sort_by(|a, b| a.0.cmp(b.0)); - - for (purl, patch) in patch_entries { + for entry in entries { + let patch = entry.record; let mut file_paths: Vec<_> = patch.files.keys().cloned().collect(); file_paths.sort(); let files = file_paths @@ -62,16 +109,23 @@ fn build_list_envelope(manifest: &PatchManifest) -> Envelope { }) .collect(); - let details = serde_json::json!({ + let mut details = serde_json::json!({ "exportedAt": patch.exported_at, "tier": patch.tier, "license": patch.license, "description": patch.description, "vulnerabilities": vulnerabilities, }); + if entry.hosted { + // The shared constant label, never the ledger's own opaque + // `mode` string — see HOSTED_MODE_LABEL's docs (scan's + // `redirectState` block emits the same label, one owner). + details["mode"] = serde_json::json!(crate::commands::HOSTED_MODE_LABEL); + details["ledger"] = serde_json::json!(REDIRECT_STATE_REL); + } env.record( - PatchEvent::new(PatchAction::Discovered, purl.clone()) + PatchEvent::new(PatchAction::Discovered, entry.purl.to_string()) .with_uuid(patch.uuid.clone()) .with_files(files) .with_details(details), @@ -150,6 +204,26 @@ fn emit_error(args: &ListArgs, code: &str, message: String) { } } +/// The project root whose redirect ledger accompanies the manifest being +/// listed. Both stores must come from the SAME project, so the root is +/// derived from the RESOLVED manifest path rather than hardcoding cwd: +/// the manifest's directory, stepping out of a standard `.socket/` layout +/// when the manifest lives in one. For the default +/// `/.socket/manifest.json` this is exactly `cwd`; for a +/// `--manifest-path` into another project it is that project's root (its +/// `.socket` parent's parent), or — for a bare file like +/// `--manifest-path /tmp/x/abs.json` — the file's own directory. +fn ledger_root(common: &GlobalArgs, manifest_path: &Path) -> PathBuf { + match manifest_path.parent() { + Some(dir) if dir.file_name() == Some(std::ffi::OsStr::new(".socket")) => dir + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| common.cwd.clone()), + Some(dir) => dir.to_path_buf(), + None => common.cwd.clone(), + } +} + pub async fn run(args: ListArgs) -> i32 { apply_env_toggles(&args.common); let manifest_path = args.common.resolved_manifest_path(); @@ -162,83 +236,8 @@ pub async fn run(args: ListArgs) -> i32 { // `manifest_not_found`, masking real I/O errors that owe a // `manifest_unreadable`, and it opens a TOCTOU window where a file removed // between the stat and the read lands in the wrong error arm. - match read_manifest(&manifest_path).await { - Ok(Some(manifest)) => { - // Sort by PURL so both the JSON envelope and the human-readable - // table list packages in a stable order across runs. - let mut patch_entries: Vec<_> = manifest.patches.iter().collect(); - patch_entries.sort_by(|a, b| a.0.cmp(b.0)); - let patches_count = patch_entries.len(); - let (api_token, org_slug) = telemetry_credentials(&args.common); - track_patch_listed(patches_count, api_token.as_deref(), org_slug.as_deref()).await; - - if args.common.json { - println!("{}", build_list_envelope(&manifest).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 patch_entries.is_empty() { - println!("No patches found in manifest."); - } else { - println!("Found {} patch(es):\n", patch_entries.len()); - for (purl, patch) in &patch_entries { - println!("Package: {purl}"); - println!(" UUID: {}", patch.uuid); - 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!(); - } - } - - 0 - } - Ok(None) => { - // `read_manifest` returns `Ok(None)` only when the file does not - // exist (its documented contract), so this is the missing-manifest - // path — `manifest_not_found`, NOT `manifest_invalid` (which means - // the file is present but corrupt). See CLI_CONTRACT.md error-code - // table. - emit_error( - &args, - "manifest_not_found", - format!("Manifest not found at {}", manifest_path.display()), - ); - 1 - } + let manifest = match read_manifest(&manifest_path).await { + Ok(manifest) => manifest, Err(e) => { // A manifest that exists but is unparseable (bad JSON or a // schema violation) surfaces as `ErrorKind::InvalidData` — the @@ -246,15 +245,133 @@ pub async fn run(args: ListArgs) -> i32 { // I/O failure (`manifest_unreadable`). Conflating the two would // tell a consumer to retry on a corrupt file, or to give up on a // transient I/O error. See CLI_CONTRACT.md error-code table. + // Hosted-ledger records never mask either: a present-but-broken + // manifest is an error state, not a hosted-only project. let code = if e.kind() == std::io::ErrorKind::InvalidData { "manifest_invalid" } else { "manifest_unreadable" }; emit_error(&args, code, e.to_string()); - 1 + return 1; } + }; + + // Hosted-mode patches live ONLY in the redirect ledger, so `list` + // consults it alongside the manifest — leniently (a malformed ledger + // degrades to "nothing to consult", surfaced on stderr unless --silent; + // the hosted write path hard-errors on it instead), and always from the + // SAME project as the manifest: with `--manifest-path` pointing at + // another project, reading the LOCAL cwd's ledger would interleave two + // projects' patch state (and a local ledger could suppress the flagged + // project's manifest_not_found). + let redirect_state = crate::commands::load_redirect_state_lenient( + &ledger_root(&args.common, &manifest_path), + args.common.silent, + ) + .await; + + // `combined_entries` folds only ledger RECORDS in (an edits-only ledger + // — post-takeover residue / a degraded record-fetch-failed run — + // asserts no patches), so entry emptiness is the whole exit predicate. + let entries = combined_entries(manifest.as_ref(), redirect_state.as_ref()); + if manifest.is_none() && entries.is_empty() { + // No manifest AND no hosted records: nothing is listable anywhere — + // the classic missing-manifest error. `read_manifest` returns + // `Ok(None)` only when the file does not exist (its documented + // contract), so this is `manifest_not_found`, NOT `manifest_invalid` + // (which means the file is present but corrupt). See CLI_CONTRACT.md + // error-code table. + emit_error( + &args, + "manifest_not_found", + format!("Manifest not found at {}", manifest_path.display()), + ); + return 1; } + + // Records found (either store) ⇒ a successful list, exit 0 — including + // the purely hosted-wired project that used to hard-fail here. + // + // Telemetry: `patch_listed`'s `patches_count` predates the hosted + // folding and its consumers read it as "manifest patches", so it keeps + // counting the manifest ONLY (0 on a hosted-only project) — folding the + // listed entries in would silently redefine the metric and double-count + // purls present in both stores. Hosted visibility, if wanted, belongs + // in a new dedicated field. + let manifest_patch_count = manifest.as_ref().map_or(0, |m| m.patches.len()); + let (api_token, org_slug) = telemetry_credentials(&args.common); + track_patch_listed( + manifest_patch_count, + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; + + if args.common.json { + println!("{}", build_list_envelope(&entries).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 entry.hosted { + // Same labeling rule as the JSON details: the record comes + // from the hosted redirect ledger — installs resolve this + // package to the hosted patch server; no manifest entry + // exists or is needed. + println!( + " Mode: {} (recorded in {REDIRECT_STATE_REL})", + crate::commands::HOSTED_MODE_LABEL + ); + } + 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!(); + } + } + + 0 } #[cfg(test)] @@ -265,6 +382,13 @@ mod tests { use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; use std::collections::HashMap; + /// Envelope for a manifest-only listing (no redirect ledger) — the shape + /// most tests below need; the hosted tests call `combined_entries` + /// directly with a `RedirectState`. + fn manifest_envelope(manifest: &PatchManifest) -> Envelope { + build_list_envelope(&combined_entries(Some(manifest), None)) + } + fn sample_manifest() -> PatchManifest { let mut files = HashMap::new(); files.insert( @@ -370,7 +494,7 @@ mod tests { #[test] fn list_emits_discovered_event_per_patch() { - let env = build_list_envelope(&sample_manifest()); + let env = manifest_envelope(&sample_manifest()); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["command"], "list"); assert_eq!(v["status"], "success"); @@ -384,7 +508,7 @@ mod tests { #[test] fn list_event_carries_vulnerability_details() { - let env = build_list_envelope(&sample_manifest()); + let env = manifest_envelope(&sample_manifest()); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); let event = &v["events"][0]; assert_eq!(event["details"]["tier"], "free"); @@ -398,7 +522,7 @@ mod tests { #[test] fn empty_manifest_emits_empty_events() { - let env = build_list_envelope(&PatchManifest::new()); + let env = manifest_envelope(&PatchManifest::new()); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success"); assert_eq!(v["events"].as_array().unwrap().len(), 0); @@ -413,7 +537,7 @@ mod tests { #[test] fn events_are_sorted_by_purl() { - let env = build_list_envelope(&multi_entry_manifest()); + let env = manifest_envelope(&multi_entry_manifest()); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); let purls: Vec<&str> = v["events"] .as_array() @@ -433,7 +557,7 @@ mod tests { #[test] fn vulnerabilities_are_sorted_by_id() { - let env = build_list_envelope(&multi_entry_manifest()); + let env = manifest_envelope(&multi_entry_manifest()); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); // The zeta entry carries two advisories inserted out of order. let zeta = v["events"] @@ -453,7 +577,7 @@ mod tests { #[test] fn files_are_sorted_by_path() { - let env = build_list_envelope(&multi_entry_manifest()); + let env = manifest_envelope(&multi_entry_manifest()); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); let zeta = v["events"] .as_array() @@ -509,12 +633,80 @@ mod tests { assert_ne!(org_slug.as_deref(), Some("")); } + /// Hosted redirect-ledger records fold into the envelope labeled apart + /// from manifest entries: `details.mode` / `details.ledger` ride the + /// hosted events ONLY (additive keys), and the global purl sort holds + /// with the manifest entry first when one purl appears in both stores. + #[test] + fn hosted_ledger_records_are_labeled_and_interleaved() { + let manifest = sample_manifest(); + let mut redirect = RedirectState::new(); + let mut hosted_record = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); + hosted_record.uuid = "22222222-2222-4222-8222-222222222222".to_string(); + // Same purl as the manifest entry (coexistence) + a distinct one. + redirect + .records + .insert("pkg:npm/minimist@1.2.2".to_string(), hosted_record.clone()); + redirect + .records + .insert("pkg:npm/aaa-hosted@1.0.0".to_string(), hosted_record); + + let env = build_list_envelope(&combined_entries(Some(&manifest), Some(&redirect))); + let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); + assert_eq!(v["summary"]["discovered"], 3); + let events = v["events"].as_array().unwrap(); + let listed: Vec<(&str, bool)> = events + .iter() + .map(|e| { + ( + e["purl"].as_str().unwrap(), + e["details"]["mode"] == "hosted", + ) + }) + .collect(); + assert_eq!( + listed, + vec![ + ("pkg:npm/aaa-hosted@1.0.0", true), + ("pkg:npm/minimist@1.2.2", false), + ("pkg:npm/minimist@1.2.2", true), + ], + "purl-sorted, manifest before hosted on a tie: {v}" + ); + assert!( + events[1]["details"].get("mode").is_none() + && events[1]["details"].get("ledger").is_none(), + "manifest entries must NOT carry the hosted labels: {v}" + ); + assert_eq!( + events[0]["details"]["ledger"], + ".socket/vendor/redirect-state.json" + ); + } + + /// A hosted-only listing (no manifest at all) — the shape a purely + /// hosted-wired project produces. + #[test] + fn hosted_only_entries_build_a_success_envelope() { + let manifest = sample_manifest(); + let mut redirect = RedirectState::new(); + redirect.records.insert( + "pkg:npm/minimist@1.2.2".to_string(), + manifest.patches["pkg:npm/minimist@1.2.2"].clone(), + ); + let env = build_list_envelope(&combined_entries(None, Some(&redirect))); + let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); + assert_eq!(v["status"], "success"); + assert_eq!(v["summary"]["discovered"], 1); + assert_eq!(v["events"][0]["details"]["mode"], "hosted"); + } + #[test] fn ordering_is_deterministic_across_builds() { // Two independent builds of the same manifest must be byte-identical. let manifest = multi_entry_manifest(); - let a = build_list_envelope(&manifest).to_pretty_json(); - let b = build_list_envelope(&manifest).to_pretty_json(); + let a = manifest_envelope(&manifest).to_pretty_json(); + let b = manifest_envelope(&manifest).to_pretty_json(); assert_eq!(a, b); } } diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index b6a68538..d2502b71 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -12,3 +12,37 @@ pub mod setup; pub mod update; pub mod vendor; pub mod vex; + +use std::path::Path; + +/// The documented name of the mode whose ledger is +/// `.socket/vendor/redirect-state.json`. Shared by scan's `redirectState` +/// envelope block and list's hosted event labels so the two surfaces can +/// never drift, and deliberately a CONSTANT rather than an echo of the +/// ledger's own `mode` string: that string is opaque to the loader +/// (pre-rename ledgers carry `"redirect"`), and a consumer dispatching on +/// these keys must not have to know that history. +pub(crate) const HOSTED_MODE_LABEL: &str = "hosted"; + +/// Read-only lenient load of the hosted redirect ledger: missing → `None` +/// (a fresh start); malformed → `None` with the corruption surfaced on +/// stderr unless `silent`. This is the "read-only consumers may degrade a +/// malformed ledger to nothing-to-consult, but must surface it" posture +/// from `load_redirect_state`'s contract — the warning is advisory +/// (muted by `--silent`, "errors only"), because every path that would +/// WRITE or ATTEST from the ledger hard-errors on the same corruption +/// instead. Shared by `list` and both of scan's read-only consults. +pub(crate) async fn load_redirect_state_lenient( + cwd: &Path, + silent: bool, +) -> Option { + match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { + Ok(state) => state, + Err(corrupt) => { + if !silent { + eprintln!("Warning: {corrupt}"); + } + None + } + } +} diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index f5f41a86..84ddb1d6 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -1163,25 +1163,26 @@ pub(super) async fn hosted_wiring_retained_purls( let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); let scanned: std::collections::BTreeSet = scanned_purls.iter().map(|p| canon(p)).collect(); + // Cheap no-I/O gate: only ledger records naming a scanned purl can ever + // prove live wiring, so when none do (a zero/filtered discovery, or a + // ledger about other packages) skip the lockfile inventory below — a + // full multi-file lock parse — entirely. + let candidates: Vec<(String, &str)> = redirect + .records + .iter() + .map(|(key, record)| (canon(key), record.uuid.as_str())) + .filter(|(purl, _)| scanned.contains(purl)) + .collect(); + if candidates.is_empty() { + return Vec::new(); + } let mut redirect_files: Vec<&str> = redirect.edits.iter().map(|e| e.path.as_str()).collect(); redirect_files.sort(); redirect_files.dedup(); let inventory = socket_patch_core::vendor::lock_inventory::inventory_project(cwd).await; let mut out = Vec::new(); - for (key, record) in &redirect.records { - let purl = canon(key); - if !scanned.contains(&purl) { - continue; - } - if hosted_wiring_live( - cwd, - &purl, - Some(record.uuid.as_str()), - &redirect_files, - &inventory, - ) - .await - { + for (purl, uuid) in candidates { + if hosted_wiring_live(cwd, &purl, Some(uuid), &redirect_files, &inventory).await { out.push(purl); } } @@ -1239,6 +1240,65 @@ pub(super) fn vendored_ownership_retained_detail(purls: &[String]) -> String { ) } +/// Additive top-level `redirectState` block for the scan `--json` envelope: +/// the hosted redirect ledger's records — project STATE, so a descriptive +/// block rather than a warning — plus the scanned purls whose hosted +/// lockfile wiring the live lock still proves. +/// +/// Before this block, a hosted-wired project's report-only `scan --json` +/// was byte-identical to a never-touched project's (verified against +/// production on bundler 1.17/2.7/4.0): [`HOSTED_WIRING_RETAINED`] rides +/// only the agent-mode envelope, and the `redirect` sub-object only a +/// hosted-mode run's. `None` (key omitted, additive contract) when the +/// ledger is absent or its `records` are empty — an edits-only ledger +/// (post-takeover / degraded) asserts no patches, mirroring the warning's +/// records gate. This emptiness check is the block's ONE presence +/// decision; the caller precomputes `wiring_live` (see below) whose own +/// probe guards its inputs independently for its other callers. +/// +/// Shape: `{ mode, ledger, records: [{purl, ledgerKey, uuid}], wiringLive: +/// [purl] }`. `mode` is the constant [`crate::commands::HOSTED_MODE_LABEL`] +/// — never the ledger's own opaque `mode` string (pre-rename ledgers carry +/// `"redirect"`; consumers dispatching on this key must not need that +/// history). Each record's `purl` is CANONICALIZED (qualifiers stripped, +/// percent-decoded) to the same spelling `wiringLive` carries, so the +/// records↔proof join is a plain string compare; `ledgerKey` is the +/// ledger's verbatim key (percent-encoded scoped names, `?platform=` +/// qualifiers) for consumers that need to address the ledger itself. +/// `wiring_live` is the caller's [`hosted_wiring_retained_purls`] result — +/// computed ONCE per run (it parses the project's lockfiles) and shared +/// with the agent-flow warning. 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 — never "still live". +pub(super) fn redirect_state_json( + redirect_state: Option<&socket_patch_core::patch::redirect::RedirectState>, + wiring_live: &[String], +) -> Option { + let redirect = redirect_state?; + if redirect.records.is_empty() { + return None; + } + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let records: Vec = redirect + .records + .iter() + .map(|(key, record)| { + serde_json::json!({ + "purl": canon(key), + "ledgerKey": key, + "uuid": record.uuid, + }) + }) + .collect(); + Some(serde_json::json!({ + "mode": crate::commands::HOSTED_MODE_LABEL, + "ledger": socket_patch_core::patch::redirect::REDIRECT_STATE_REL, + "records": records, + "wiringLive": wiring_live, + })) +} + /// Append one `{code, detail}` entry to the scan `--json` result's /// top-level `warnings` array (created on first use — the key is additive /// and absent when no run-level warning fired), mirroring the @@ -1518,6 +1578,28 @@ pub async fn run(mut args: ScanArgs) -> i32 { "warnings": warnings, "dryRun": args.common.dry_run, }); + } else if !vendor { + // The `redirectState` block rides the empty-discovery + // envelope too (same rule as the ≥1-package path below: + // every non-hosted-mode, non-vendored-mode `--json` envelope + // carries it when the ledger holds records) — an + // `--ecosystems` filter or a wiped tree must not blind a + // state-probing consumer. The vendored gate mirrors the + // main path's: vendored runs may reconcile ledger records + // mid-run, so they never carry a pre-run snapshot. The + // ledger is loaded here (leniently, --silent-gated) because + // the main-path load sits after this early return. + // `wiringLive` is empty by construction: this run counted + // zero packages, and the block's contract scopes the proof + // to packages the run actually covered. + let redirect_state = crate::commands::load_redirect_state_lenient( + &args.common.cwd, + args.common.silent, + ) + .await; + if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) { + result["redirectState"] = state; + } } let code = embed_vex_into_json(&args.common, &args.vex, &manifest_path, 0, &mut result).await; @@ -1793,20 +1875,23 @@ pub async fn run(mut args: ScanArgs) -> i32 { // view update detection sees — otherwise a pure hosted project's // `updates[]` (the documented CI signal) stays structurally empty and a // superseding patch is never reported. The envelope schema is unchanged. - // A malformed ledger is only warned about here — this is a read-only - // consult, and the hosted write path hard-errors on it. + // A malformed ledger is only warned about here (and muted by --silent — + // the warning is advisory) — this is a read-only consult, and the hosted + // write path hard-errors on it. let redirect_state = - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { - Ok(state) => state, - Err(corrupt) => { - eprintln!("Warning: {corrupt}"); - None - } - }; + crate::commands::load_redirect_state_lenient(&args.common.cwd, args.common.silent).await; let update_manifest = merge_redirect_records_for_updates(existing_manifest.clone(), redirect_state.as_ref()); let updates = detect_updates(update_manifest.as_ref(), &all_packages_with_patches); + // Post-filter scanned set for the hosted-wiring probes: `wiringLive` and + // the agent-flow `hosted_wiring_retained` warning only ever name + // packages this run actually counted/queried (an `--ecosystems` filter + // narrows both — a filtered-out purl reads as "not covered this run", + // never as "wiring unwound"). Distinct from `scanned_purls` above, which + // deliberately stays PRE-filter for the GC prune (see its comment). + let wiring_scanned: HashSet = all_purls.iter().cloned().collect(); + if args.common.json { let mut result = serde_json::json!({ "status": "success", @@ -1867,6 +1952,31 @@ pub async fn run(mut args: ScanArgs) -> i32 { .await; } + // Cross-mode visibility, read-only half (companion to the run-level + // warnings below): the hosted redirect ledger's records ride every + // report-only and agent `--json` envelope as the additive + // `redirectState` block. Hosted mode is excluded above (its nested + // `redirect` block reports this run's own result, and `run_redirect` + // re-persists the ledger mid-run, so a pre-run snapshot would go + // stale); the vendored path below is excluded for the same staleness + // reason (its takeover reconciliation may retire ledger records + // mid-run — the `vendor_supersedes_redirect` warning covers it). + // + // The live-wiring probe (a full lockfile-inventory parse behind its + // cheap no-I/O gate) runs ONCE here and is shared with the agent-flow + // warning in the apply branch below. + let hosted_retained = if vendor { + Vec::new() + } else { + hosted_wiring_retained_purls(&args.common.cwd, redirect_state.as_ref(), &wiring_scanned) + .await + }; + if !vendor { + if let Some(state) = redirect_state_json(redirect_state.as_ref(), &hosted_retained) { + result["redirectState"] = state; + } + } + // `apply` and `prune` are computed once at the top of run() // (factoring in --sync, which implies both). They're independent // here: a bot can `--apply` without `--prune`, or `--prune` @@ -2010,12 +2120,9 @@ pub async fn run(mut args: ScanArgs) -> i32 { } push_scan_json_warning(&mut result, VENDORED_OWNERSHIP_RETAINED, &detail); } - let hosted_retained = hosted_wiring_retained_purls( - &args.common.cwd, - redirect_state.as_ref(), - &scanned_purls, - ) - .await; + // `hosted_retained` was computed once above (shared with the + // `redirectState` block) — same probe, same post-filter scanned + // set, no second lockfile-inventory parse. if !hosted_retained.is_empty() { let detail = hosted_wiring_retained_detail(&hosted_retained); if !args.common.silent { @@ -2536,9 +2643,12 @@ pub async fn run(mut args: ScanArgs) -> i32 { // (The vendored-ownership counterpart is already printed per package // by the `[skip] … (vendored …)` lines above.) if !vendor && !args.common.silent { - let hosted_retained = - hosted_wiring_retained_purls(&args.common.cwd, redirect_state.as_ref(), &scanned_purls) - .await; + let hosted_retained = hosted_wiring_retained_purls( + &args.common.cwd, + redirect_state.as_ref(), + &wiring_scanned, + ) + .await; if !hosted_retained.is_empty() { eprintln!( "Warning ({HOSTED_WIRING_RETAINED}): {}", @@ -2965,6 +3075,157 @@ mod tests { assert_ne!(VENDORED_OWNERSHIP_RETAINED, VENDOR_SUPERSEDES_REDIRECT); } + // ---- redirectState envelope block (read-only cross-mode visibility) ---- + // The end-to-end envelope placement (report-only + agent runs carry it, + // hosted/vendored runs don't) is pinned by `tests/scan_invariants.rs`; + // these pin the block builder's own gates and shape. + + /// Records present ⇒ the block exists with each record's canonicalized + /// purl + verbatim ledger key, the constant mode label, and the + /// caller-supplied wiringLive. Records absent (edits-only ledger, no + /// ledger) ⇒ `None`, so the envelope key stays additive. + #[tokio::test] + async fn redirect_state_block_gates_on_records_and_splits_live_proof() { + let purl = "pkg:npm/minimist@1.2.2"; + let scanned: HashSet = [purl.to_string()].into_iter().collect(); + + // Records, but no lockfile on disk: listed, with the EMPTY wiringLive + // the probe computes (the ledger's word is never promoted to a + // live-lock proof). + let tmp = tempfile::tempdir().unwrap(); + write_redirect_ledger_with_edit(tmp.path(), &[purl]).await; + let ledger = load_ledger(tmp.path()).await; + let wiring = hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned).await; + assert_eq!(wiring, Vec::::new()); + let block = + redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); + assert_eq!(block["mode"], "hosted"); + assert_eq!(block["ledger"], ".socket/vendor/redirect-state.json"); + assert_eq!( + block["records"], + serde_json::json!([{ "purl": purl, "ledgerKey": purl, "uuid": TAKEOVER_UUID }]) + ); + assert_eq!(block["wiringLive"], serde_json::json!([])); + + // Live lock present too: the same purl graduates into wiringLive. + write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + let wiring = hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned).await; + let block = + redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); + assert_eq!(block["wiringLive"], serde_json::json!([purl])); + + // Edits-only ledger (records retired) ⇒ no block. + let tmp = tempfile::tempdir().unwrap(); + write_redirect_ledger_with_edit(tmp.path(), &[]).await; + let ledger = load_ledger(tmp.path()).await; + assert!( + redirect_state_json(ledger.as_ref(), &[]).is_none(), + "an edits-only ledger asserts no records" + ); + + // No ledger ⇒ no block. + assert!(redirect_state_json(None, &[]).is_none()); + } + + /// The records↔wiringLive join is a plain string compare: each record's + /// `purl` is canonicalized to exactly the spelling the probe emits, with + /// the ledger's raw key preserved as `ledgerKey`. Pinned on the two key + /// shapes real ledgers carry — a percent-encoded scoped npm name (the + /// API spelling, the `drop_superseded_purl` fixture shape) and a + /// `?platform=`-qualified gem purl. Pre-fix, `records[].purl` kept the + /// verbatim key while `wiringLive` was canonical, so a LIVE redirect + /// read as "wiring unwound" to any consumer doing the documented join. + #[tokio::test] + async fn redirect_state_records_canonicalize_to_the_wiring_live_spelling() { + use socket_patch_core::patch::redirect::{FileEdit, RedirectState}; + + let scoped_key = "pkg:npm/%40scope%2Fpkg@1.0.0"; + let scoped_canon = "pkg:npm/@scope/pkg@1.0.0"; + let gem_key = "pkg:gem/nokogiri@1.13.3?platform=ruby"; + let gem_canon = "pkg:gem/nokogiri@1.13.3"; + + let tmp = tempfile::tempdir().unwrap(); + let mut state = RedirectState::new(); + state + .records + .insert(scoped_key.to_string(), takeover_record()); + state.records.insert(gem_key.to_string(), takeover_record()); + // A recorded yarn.lock edit + the uuid in the lock text (outside any + // vendored path) — the text proof of live hosted wiring for the + // scoped purl. + state.edits.push(FileEdit { + path: "yarn.lock".to_string(), + kind: "redirect_yarn_entry".to_string(), + action: "rewritten".to_string(), + key: Some("@scope/pkg@1.0.0".to_string()), + original: Some(serde_json::Value::String("orig".to_string())), + new: None, + }); + let dir = tmp.path().join(".socket/vendor"); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write( + dir.join("redirect-state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .await + .unwrap(); + write_hosted_yarn_lock(tmp.path(), TAKEOVER_UUID).await; + + let scanned: HashSet = [scoped_canon.to_string()].into_iter().collect(); + let ledger = load_ledger(tmp.path()).await; + let wiring = hosted_wiring_retained_purls(tmp.path(), ledger.as_ref(), &scanned).await; + assert_eq!( + wiring, + vec![scoped_canon.to_string()], + "the text proof (uuid in the recorded lock) claims the scoped purl" + ); + + let block = + redirect_state_json(ledger.as_ref(), &wiring).expect("records present ⇒ block present"); + assert_eq!( + block["records"], + serde_json::json!([ + { "purl": gem_canon, "ledgerKey": gem_key, "uuid": TAKEOVER_UUID }, + { "purl": scoped_canon, "ledgerKey": scoped_key, "uuid": TAKEOVER_UUID }, + ]), + "records carry the canonical purl (wiringLive's spelling) plus \ + the verbatim ledger key; block={block}" + ); + let live: Vec<&str> = block["wiringLive"] + .as_array() + .unwrap() + .iter() + .map(|p| p.as_str().unwrap()) + .collect(); + let record_purls: Vec<&str> = block["records"] + .as_array() + .unwrap() + .iter() + .map(|r| r["purl"].as_str().unwrap()) + .collect(); + for purl in live { + assert!( + record_purls.contains(&purl), + "every wiringLive purl must string-match a records[].purl \ + (the documented join); block={block}" + ); + } + } + + /// The block's `mode` is the constant label, not the ledger's opaque + /// `mode` string: a pre-rename ledger carrying `"redirect"` still labels + /// as `"hosted"`, so consumers dispatching on the key need no history. + #[tokio::test] + async fn redirect_state_mode_is_the_constant_label_for_legacy_ledgers() { + let tmp = tempfile::tempdir().unwrap(); + write_redirect_ledger_with_edit(tmp.path(), &["pkg:npm/minimist@1.2.2"]).await; + let mut ledger = load_ledger(tmp.path()).await.unwrap(); + ledger.mode = "redirect".to_string(); + let block = + redirect_state_json(Some(&ledger), &[]).expect("records present ⇒ block present"); + assert_eq!(block["mode"], "hosted"); + } + // ---- cargo takeover direction (lock-shape probe) ------------------------ // The scan inventory records `resolved: None` for every cargo entry, so // the generic patch.socket.dev check can never prove hosted for cargo — diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 98dfa0b1..d039bf42 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -16,6 +16,9 @@ use std::process::Command; use clap::Parser; use socket_patch_cli::commands::list::{run, ListArgs}; + +#[path = "common/mod.rs"] +mod common; use socket_patch_cli::{Cli, Commands}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, @@ -955,3 +958,446 @@ fn silent_keeps_missing_manifest_error_on_stderr_via_binary() { "error output must NOT be muted by --silent" ); } + +// --------------------------------------------------------------------------- +// Hosted redirect-ledger records — `scan --mode hosted` records its patches +// ONLY in `.socket/vendor/redirect-state.json` (it never writes +// `.socket/manifest.json`), so `list` on a purely hosted-wired project used +// to hard-fail `manifest_not_found` while patches were demonstrably live +// (verified against production on bundler 1.17/2.7/4.0 — the gem live-matrix +// D3 defect). `list` now folds the ledger's records in, labeled as hosted; +// when both stores exist, both are shown. +// --------------------------------------------------------------------------- + +const HOSTED_PURL: &str = "pkg:npm/hosted-pkg@2.0.0"; +const HOSTED_UUID: &str = "22222222-2222-4222-8222-222222222222"; + +/// A patch record for the redirect ledger, distinguishable from the +/// manifest fixture's record. +fn hosted_record(uuid: &str) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + "package/hosted.js".to_string(), + PatchFileInfo { + before_hash: "c".repeat(64), + after_hash: "d".repeat(64), + }, + ); + let mut vulnerabilities = HashMap::new(); + vulnerabilities.insert( + "GHSA-host-host-host".to_string(), + VulnerabilityInfo { + cves: vec!["CVE-2024-0002".to_string()], + summary: "hosted vuln".to_string(), + severity: "critical".to_string(), + description: "hosted description".to_string(), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-02-02T00:00:00Z".to_string(), + files, + vulnerabilities, + description: "Hosted patch".to_string(), + license: "MIT".to_string(), + tier: "free".to_string(), + } +} + +// The redirect-ledger writer lives in `tests/common/mod.rs` +// (`common::write_redirect_ledger`) — shared with the other suites that +// seed hosted state, so the fixture can never drift from the on-disk schema. + +#[test] +fn hosted_only_project_list_json_lists_ledger_records_via_binary() { + // No manifest at all — only the hosted redirect ledger. `list --json` + // must exit 0 with the hosted records as labeled discovered events, not + // `manifest_not_found`. + let tmp = tempfile::tempdir().unwrap(); + common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + + let out = run_list_binary(tmp.path(), &["--json"]); + assert_eq!( + out.status.code(), + Some(0), + "hosted-only list --json must exit 0 (records found), stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!(v["command"], "list"); + assert_eq!(v["status"], "success", "envelope={v}"); + assert_eq!(v["summary"]["discovered"], 1, "envelope={v}"); + + let events = v["events"].as_array().expect("events array"); + assert_eq!(events.len(), 1, "envelope={v}"); + let event = &events[0]; + assert_eq!(event["action"], "discovered"); + assert_eq!(event["purl"], HOSTED_PURL); + assert_eq!(event["uuid"], HOSTED_UUID); + // The hosted label: a consumer must be able to tell a redirect-ledger + // record from a manifest entry. + assert_eq!(event["details"]["mode"], "hosted", "envelope={v}"); + assert_eq!( + event["details"]["ledger"], ".socket/vendor/redirect-state.json", + "envelope={v}" + ); + // The rich metadata rides along exactly like a manifest entry's. + assert_eq!(event["details"]["tier"], "free"); + assert_eq!(event["details"]["exportedAt"], "2024-02-02T00:00:00Z"); + let vulns = event["details"]["vulnerabilities"] + .as_array() + .expect("vulnerabilities array"); + assert_eq!(vulns[0]["id"], "GHSA-host-host-host"); + assert_eq!(vulns[0]["severity"], "critical"); +} + +#[test] +fn hosted_only_project_list_plain_labels_hosted_via_binary() { + let tmp = tempfile::tempdir().unwrap(); + common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + + let out = run_list_binary(tmp.path(), &[]); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!( + out.status.code(), + Some(0), + "hosted-only list must exit 0, stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + stdout.contains("Found 1 patch(es):"), + "count header must include the hosted record: {stdout}" + ); + assert!( + stdout.contains(&format!("Package: {HOSTED_PURL}")), + "missing hosted purl: {stdout}" + ); + assert!( + stdout.contains(&format!("UUID: {HOSTED_UUID}")), + "missing hosted uuid: {stdout}" + ); + // The human line must label the record as hosted and name the ledger. + assert!( + stdout.contains("Mode: hosted"), + "hosted record must be labeled: {stdout}" + ); + assert!( + stdout.contains(".socket/vendor/redirect-state.json"), + "the label must name the ledger the record came from: {stdout}" + ); +} + +#[test] +fn manifest_and_hosted_ledger_coexist_via_binary() { + // Manifest entry + hosted records, including one purl present in BOTH + // stores: both are shown (labeled apart), globally purl-sorted with the + // manifest entry first on a tie. + let tmp = tempfile::tempdir().unwrap(); + write_manifest_in(tmp.path(), &populated_manifest()); + common::write_redirect_ledger( + tmp.path(), + &[ + (HOSTED_PURL, hosted_record(HOSTED_UUID)), + // Same purl as the manifest fixture, different uuid. + ( + "pkg:npm/test-pkg@1.0.0", + hosted_record("33333333-3333-4333-8333-333333333333"), + ), + ], + ); + + let out = run_list_binary(tmp.path(), &["--json"]); + assert_eq!( + out.status.code(), + Some(0), + "list over both stores must exit 0, stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!(v["summary"]["discovered"], 3, "envelope={v}"); + let events = v["events"].as_array().expect("events array"); + let listed: Vec<(&str, &str, bool)> = events + .iter() + .map(|e| { + ( + e["purl"].as_str().expect("purl"), + e["uuid"].as_str().expect("uuid"), + e["details"]["mode"] == "hosted", + ) + }) + .collect(); + assert_eq!( + listed, + vec![ + (HOSTED_PURL, HOSTED_UUID, true), + ( + "pkg:npm/test-pkg@1.0.0", + "11111111-1111-4111-8111-111111111111", + false + ), + ( + "pkg:npm/test-pkg@1.0.0", + "33333333-3333-4333-8333-333333333333", + true + ), + ], + "both stores' records must be listed, purl-sorted, manifest entry \ + before the hosted record on a purl tie; envelope={v}" + ); +} + +#[test] +fn edits_only_ledger_without_manifest_still_manifest_not_found_via_binary() { + // A ledger with recorded edits but NO records (the post-takeover / + // degraded shape) asserts no patches, so a manifest-less project stays + // on the manifest_not_found path. + 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"), + serde_json::to_string_pretty(&serde_json::json!({ + "version": 1, + "mode": "hosted", + "edits": [{ + "path": "yarn.lock", + "kind": "redirect_yarn_entry", + "action": "rewritten", + "key": "minimist@1.2.2", + "original": "registry original" + }] + })) + .unwrap(), + ) + .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), + "no records anywhere must exit 1" + ); + assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); +} + +#[test] +fn corrupt_manifest_with_hosted_ledger_still_manifest_invalid_via_binary() { + // A corrupt manifest is an error state; hosted records must never mask + // it as a healthy hosted-only listing. + let tmp = tempfile::tempdir().unwrap(); + let socket_dir = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket_dir).unwrap(); + std::fs::write(socket_dir.join("manifest.json"), "{not json").unwrap(); + common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + + 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), "corrupt manifest must exit 1"); + assert_eq!(v["error"]["code"], "manifest_invalid", "envelope={v}"); +} + +#[test] +fn silent_suppresses_hosted_listing_via_binary() { + // `--silent` is "errors only": the hosted listing is muted like the + // manifest one, while the exit code still says records were found. + let tmp = tempfile::tempdir().unwrap(); + common::write_redirect_ledger(tmp.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + + let out = run_list_binary_scrubbed(tmp.path(), &["--silent"]); + assert_eq!( + out.status.code(), + Some(0), + "hosted-only --silent must exit 0" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.trim().is_empty(), + "--silent must suppress the hosted listing; got {stdout:?}" + ); +} + +#[test] +fn silent_gates_the_malformed_ledger_warning_via_binary() { + // A malformed ledger degrades to "nothing to consult" with a stderr + // warning — and that warning is advisory, so `--silent` ("errors only") + // must mute it like every sibling warning. The listing itself proceeds + // from the manifest either way. + let tmp = tempfile::tempdir().unwrap(); + write_manifest_in(tmp.path(), &populated_manifest()); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("redirect-state.json"), "{ torn ledger").unwrap(); + + // Control: without --silent the corruption is surfaced. + let loud = run_list_binary_scrubbed(tmp.path(), &[]); + assert_eq!(loud.status.code(), Some(0), "manifest still lists"); + assert!( + String::from_utf8_lossy(&loud.stderr).contains("malformed"), + "a malformed ledger must be surfaced on stderr when not silent; \ + stderr={}", + String::from_utf8_lossy(&loud.stderr) + ); + + // --silent: the warning is muted, the exit code unchanged. + let out = run_list_binary_scrubbed(tmp.path(), &["--silent"]); + assert_eq!(out.status.code(), Some(0)); + assert!( + String::from_utf8_lossy(&out.stderr).trim().is_empty(), + "--silent must mute the malformed-ledger warning; stderr={}", + String::from_utf8_lossy(&out.stderr) + ); +} + +// --------------------------------------------------------------------------- +// `--manifest-path` store scoping — both stores must come from the SAME +// project. The redirect ledger used to be resolved against cwd +// unconditionally, so pointing `--manifest-path` at another project's +// manifest interleaved two projects' patch state (and a LOCAL ledger could +// suppress the flagged project's manifest_not_found). +// --------------------------------------------------------------------------- + +#[test] +fn manifest_path_scopes_ledger_to_target_project_via_binary() { + // cwd has its own (decoy) ledger; --manifest-path points at another + // project that has BOTH a manifest and its own ledger. Only the target + // project's stores may be listed. + let cwd = tempfile::tempdir().unwrap(); + common::write_redirect_ledger( + cwd.path(), + &[("pkg:npm/local-decoy@0.0.1", hosted_record(HOSTED_UUID))], + ); + + let target = tempfile::tempdir().unwrap(); + write_manifest_in(target.path(), &populated_manifest()); + common::write_redirect_ledger(target.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + + let manifest_path = target.path().join(".socket/manifest.json"); + let out = run_list_binary( + cwd.path(), + &["--json", "--manifest-path", manifest_path.to_str().unwrap()], + ); + assert_eq!( + out.status.code(), + Some(0), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + let purls: Vec<&str> = v["events"] + .as_array() + .expect("events array") + .iter() + .map(|e| e["purl"].as_str().expect("purl")) + .collect(); + assert_eq!( + purls, + vec![HOSTED_PURL, "pkg:npm/test-pkg@1.0.0"], + "only the target project's manifest + ledger may be listed — never \ + the cwd's local ledger; envelope={v}" + ); +} + +#[test] +fn local_ledger_never_suppresses_flagged_manifest_not_found_via_binary() { + // --manifest-path points at a project with NO manifest and NO ledger; + // the cwd's local ledger records must not turn that into a success. + let cwd = tempfile::tempdir().unwrap(); + common::write_redirect_ledger(cwd.path(), &[(HOSTED_PURL, hosted_record(HOSTED_UUID))]); + let target = tempfile::tempdir().unwrap(); + + let manifest_path = target.path().join(".socket/manifest.json"); + let out = run_list_binary( + cwd.path(), + &["--json", "--manifest-path", manifest_path.to_str().unwrap()], + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!( + out.status.code(), + Some(1), + "the flagged project has no stores at all; envelope={v}" + ); + assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); +} + +// --------------------------------------------------------------------------- +// Telemetry — `patch_listed`'s `patches_count` predates the hosted folding +// and dashboards consume it as "manifest patches". Folding hosted records +// into the SAME field would silently redefine the metric (and double-count +// purls present in both stores), so the count stays manifest-only. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_telemetry_counts_manifest_patches_only_via_binary() { + use wiremock::matchers::{method, path as url_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(url_path("/v0/orgs/test-org/telemetry")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .mount(&server) + .await; + + // 1 manifest patch + 2 hosted records (one sharing the manifest purl): + // the listing shows 3 entries, the metric must still say 1. + let tmp = tempfile::tempdir().unwrap(); + write_manifest_in(tmp.path(), &populated_manifest()); + common::write_redirect_ledger( + tmp.path(), + &[ + (HOSTED_PURL, hosted_record(HOSTED_UUID)), + ( + "pkg:npm/test-pkg@1.0.0", + hosted_record("33333333-3333-4333-8333-333333333333"), + ), + ], + ); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + cmd.arg("list") + .arg("--cwd") + .arg(tmp.path()) + .arg("--json") + .arg("--api-token") + .arg("sktsec_telemetry_test") + .arg("--org") + .arg("test-org"); + for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { + cmd.env_remove(var); + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "0"); + cmd.env("SOCKET_API_URL", server.uri()); + cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); + let out = cmd.output().expect("run socket-patch binary"); + assert_eq!( + out.status.code(), + Some(0), + "stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_str(String::from_utf8_lossy(&out.stdout).trim()) + .expect("stdout must be valid JSON"); + assert_eq!( + v["summary"]["discovered"], 3, + "the listing itself shows all three entries; envelope={v}" + ); + + let reqs = server.received_requests().await.unwrap_or_default(); + let telemetry = reqs + .iter() + .find(|r| r.url.path() == "/v0/orgs/test-org/telemetry") + .expect("list must POST the patch_listed telemetry event"); + let body = String::from_utf8_lossy(&telemetry.body); + assert!( + body.contains("\"patches_count\":1"), + "patches_count must keep its pre-hosted meaning (manifest patches \ + only — here 1, not the 3 listed entries); body={body}" + ); +} diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index fb5a8a3e..ae032d19 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -306,6 +306,29 @@ pub fn write_minimal_manifest( path } +/// Write a hosted redirect ledger (`.socket/vendor/redirect-state.json`) +/// under `root` with the given PURL → record entries — the records-only +/// shape `scan --mode hosted` persists (tests that need recorded `edits` +/// add them to the returned-state shape themselves before writing, or +/// keep their own writer). Serializes through the real `RedirectState` +/// type so the fixture can never drift from the on-disk schema. +pub fn write_redirect_ledger( + root: &Path, + records: &[(&str, socket_patch_core::manifest::schema::PatchRecord)], +) { + let mut state = socket_patch_core::patch::redirect::RedirectState::new(); + for (purl, record) in records { + state.records.insert((*purl).to_string(), record.clone()); + } + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).expect("create .socket/vendor dir"); + std::fs::write( + vendor_dir.join("redirect-state.json"), + serde_json::to_string_pretty(&state).expect("serialize redirect ledger"), + ) + .expect("write redirect-state.json"); +} + /// Drop `content` at `/blobs/`. Used to stage the /// `after_hash` blob a synthetic manifest references so apply can /// run fully offline. diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index 0989389a..e8491f98 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1669,3 +1669,430 @@ async fn scan_agent_hosted_warning_silent_when_lock_is_registry_clean() { "registry-clean lock ⇒ no hosted_wiring_retained warning; envelope={v}" ); } + +// --------------------------------------------------------------------------- +// Read-only cross-mode visibility — the `redirectState` envelope block +// --------------------------------------------------------------------------- +// +// The `hosted_wiring_retained` warning above only rides the AGENT-mode +// envelope, and report-only `scan --json` (the documented read-only state +// probe) said nothing at all about a live hosted redirect: a hosted-wired +// project's `scan --json` was byte-identical to a never-touched project's +// (verified against production on bundler 1.17/2.7/4.0 — the gem live-matrix +// D3 defect). These pin the additive top-level `redirectState` block: the +// redirect ledger's records (project STATE, not an anomaly — so a block, not +// a warning) plus the scanned purls whose hosted lockfile wiring the live +// lock still proves. + +/// Report-only `scan --json` over live hosted wiring must carry the +/// `redirectState` block — records AND the live-wiring proof — with no +/// status/exit change and no agent-flow conversion warning. +#[tokio::test] +async fn report_only_scan_json_surfaces_hosted_redirect_state() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + + // No mode flag: the read-only discovery envelope. + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + assert_eq!( + code, 0, + "report-only scan must stay exit 0; stdout={stdout}; stderr={stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert_eq!(v["status"], "success", "envelope={v}"); + + let state = &v["redirectState"]; + assert!( + state.is_object(), + "report-only scan --json over a hosted-wired project must carry the \ + redirectState block; envelope={v}" + ); + assert_eq!(state["mode"], "hosted", "envelope={v}"); + assert_eq!( + state["ledger"], ".socket/vendor/redirect-state.json", + "the block must name the ledger it reports; envelope={v}" + ); + let records = state["records"].as_array().expect("records array"); + assert_eq!(records.len(), 1, "envelope={v}"); + assert_eq!(records[0]["purl"], purl, "envelope={v}"); + assert_eq!(records[0]["uuid"], AGENT_WARN_UUID, "envelope={v}"); + let live: Vec<&str> = state["wiringLive"] + .as_array() + .expect("wiringLive array") + .iter() + .map(|p| p.as_str().expect("purl string")) + .collect(); + assert_eq!( + live, + vec![purl], + "the live lock still pins the patch server, so the wiring-live proof \ + must name the purl; envelope={v}" + ); + // The conversion warning stays agent-scoped: a read-only scan converts + // nothing, so hosted state is reported as state, never as a warning. + assert!( + find_warning(&v, "hosted_wiring_retained").is_none(), + "report-only scan must not fire the agent-flow conversion warning; \ + envelope={v}" + ); +} + +/// The block keys on ledger RECORDS: an edits-only ledger (the post-takeover +/// / degraded shape) and a ledger-less project both omit it entirely. +#[tokio::test] +async fn report_only_scan_json_omits_redirect_state_without_ledger_records() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + // Edits-only ledger (records retired), live-looking lock text. + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ false, + ); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v.get("redirectState").is_none(), + "an edits-only ledger asserts no records ⇒ no redirectState block; \ + envelope={v}" + ); + + // No ledger at all: the key must stay absent (additive contract). + let clean = tempfile::tempdir().expect("tempdir"); + write_root_package_json(clean.path()); + write_npm_package(clean.path(), "minimist", "1.2.2"); + let (code, stdout, stderr) = run_scan(clean.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v.get("redirectState").is_none(), + "no ledger ⇒ no redirectState block; envelope={v}" + ); +} + +/// Records with a registry-clean lock: the block still lists the records +/// (the ledger is real state) but `wiringLive` is empty — the records/proof +/// split mirrors the agent warning's live-lock gate. +#[tokio::test] +async fn report_only_scan_json_redirect_state_splits_records_from_live_proof() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + // Registry-clean lock: the ledger record outlived the wiring. + std::fs::write( + tmp.path().join("yarn.lock"), + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + minimist@^1.2.2:\n version \"1.2.2\"\n \ + resolved \"https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#bbbb\"\n \ + integrity sha512-orig==\n", + ) + .unwrap(); + + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let state = &v["redirectState"]; + assert!( + state.is_object(), + "records exist ⇒ block exists; envelope={v}" + ); + assert_eq!( + state["records"].as_array().map(Vec::len), + Some(1), + "envelope={v}" + ); + assert_eq!( + state["wiringLive"].as_array().map(Vec::len), + Some(0), + "registry-clean lock ⇒ empty wiringLive (never guess from ledger \ + presence alone); envelope={v}" + ); +} + +/// Agent-mode runs carry the block too, alongside the conversion warning — +/// the state block is descriptive, the warning is the conversion-incomplete +/// diagnostic; neither replaces the other. +#[tokio::test] +async fn scan_agent_json_carries_redirect_state_alongside_warning() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--yes"], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + find_warning(&v, "hosted_wiring_retained").is_some(), + "the agent-flow warning is unchanged; envelope={v}" + ); + assert_eq!(v["redirectState"]["mode"], "hosted", "envelope={v}"); + assert_eq!( + v["redirectState"]["records"][0]["purl"], purl, + "envelope={v}" + ); +} + +/// Mount the hosted reference endpoint with NO grants: every uuid comes back +/// missing, so each selected patch lands as a per-patch `not_found` skip and +/// the hosted run still emits its full envelope — all the omission tests +/// below need. +async fn mount_empty_reference(mock: &MockServer) { + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/package"))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "results": {} })), + ) + .mount(mock) + .await; +} + +/// Hosted-mode envelopes never carry `redirectState`: the nested `redirect` +/// block reports this run's own result and `run_redirect` re-persists the +/// ledger mid-run, so a pre-run snapshot would go stale. Pinned on BOTH the +/// zero-discovery and the ≥1-package paths. +#[tokio::test] +async fn hosted_mode_envelopes_omit_redirect_state() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + mount_empty_reference(&mock).await; + + // Zero-discovery: ledger records present, nothing installed. + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + std::fs::remove_file(tmp.path().join("yarn.lock")).unwrap(); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "hosted", "--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v["redirect"].is_object(), + "hosted zero-discovery envelope keeps its no-op redirect block; \ + envelope={v}" + ); + assert!( + v.get("redirectState").is_none(), + "hosted-mode envelopes must not carry redirectState; envelope={v}" + ); + + // ≥1 package: the full hosted pipeline (empty grants ⇒ not_found skips). + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "hosted", "--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v["redirect"].is_object(), + "hosted run reports its own result under redirect; envelope={v}" + ); + assert!( + v.get("redirectState").is_none(), + "hosted-mode envelopes must not carry redirectState; envelope={v}" + ); +} + +/// Vendored-mode envelopes never carry `redirectState` either: the vendored +/// takeover reconciliation may retire ledger records mid-run (the +/// `vendor_supersedes_redirect` warning covers that state), so a pre-run +/// snapshot would go stale. The zero-discovery leg is the regression pin — +/// the early-return used to gate the block on `!hosted` alone, leaking it +/// into `scan --mode vendored --json` over an empty crawl. +#[tokio::test] +async fn vendored_mode_envelopes_omit_redirect_state() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + // Zero-discovery (the leak): ledger records present, nothing installed. + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + std::fs::remove_file(tmp.path().join("yarn.lock")).unwrap(); + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), &["--mode", "vendored", "--yes"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v.get("redirectState").is_none(), + "vendored-mode zero-discovery envelope must not carry redirectState; \ + envelope={v}" + ); + + // ≥1 package (dry-run keeps it network-light past discovery). + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "vendored", "--dry-run", "--yes"], + ); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v["vendor"].is_object(), + "vendored dry-run envelope carries its vendor block; envelope={v}" + ); + assert!( + v.get("redirectState").is_none(), + "vendored-mode envelopes must not carry redirectState; envelope={v}" + ); +} + +/// The malformed-ledger degradation warning is advisory, so `--silent` +/// ("errors only") must mute it — on the report-only path like everywhere +/// else. The envelope itself is unchanged either way (no redirectState from +/// a ledger that cannot be read). +#[tokio::test] +async fn silent_gates_scan_malformed_ledger_warning() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + let vendor_dir = tmp.path().join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write(vendor_dir.join("redirect-state.json"), "{ torn ledger").unwrap(); + + // Control: without --silent the corruption is surfaced on stderr. + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stderr.contains("malformed"), + "a malformed ledger must be surfaced when not silent: {stderr}" + ); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + assert!( + v.get("redirectState").is_none(), + "an unreadable ledger asserts nothing; envelope={v}" + ); + + // --silent mutes the advisory warning; the run is otherwise identical. + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--silent"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + !stderr.contains("malformed"), + "--silent must mute the malformed-ledger warning: {stderr}" + ); +} + +/// `wiringLive` (like the agent warning) only ever names packages this run +/// actually counted: an `--ecosystems` filter that excludes the hosted +/// ecosystem leaves the records listed — the ledger is still real state — +/// with an EMPTY wiringLive ("purl not crawled/queried this run" is a +/// documented silent cause, distinct from "wiring unwound"). This also pins +/// the zero-discovery envelope carrying the block at all. +#[tokio::test] +async fn ecosystems_filter_keeps_records_but_not_wiring_live() { + let mock = MockServer::start().await; + let purl = "pkg:npm/minimist@1.2.2"; + let encoded = "pkg%3Anpm%2Fminimist%401.2.2"; + mount_patch_discovery(&mock, purl, encoded, AGENT_WARN_UUID).await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + // Live hosted wiring — provable, but out of this run's scope below. + seed_live_hosted_wiring( + tmp.path(), + purl, + AGENT_WARN_UUID, + /*with_record=*/ true, + ); + + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--ecosystems", "pypi"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let state = &v["redirectState"]; + assert!( + state.is_object(), + "records exist ⇒ the block rides even the filtered/zero-discovery \ + envelope; envelope={v}" + ); + assert_eq!( + state["records"].as_array().map(Vec::len), + Some(1), + "envelope={v}" + ); + assert_eq!( + state["wiringLive"], + serde_json::json!([]), + "a purl this run did not crawl/query must not be claimed live — \ + even though the lock provably pins the patch server; envelope={v}" + ); +}