From bc090e4f1ba9150dd9b3da2efed900e283afa608 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 19:52:31 +0000 Subject: [PATCH 1/3] Start fix for #1089 (apply slice) Assisted-by: Claude Code:claude-opus-5-5 From cc0e50dd3022efca56355b411b66bba5570e91e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:04:37 +0000 Subject: [PATCH 2/3] Apply suite asserts codes, not sentences The apply suite's covgap tests pinned English sentences, so a copy edit to an apply message broke them even when the exit code, error code, events and bytes on disk were unchanged. covgap_commands_apply.rs splits three ways inside the apply suite: apply_failure_paths.rs checks --json codes, events and disk state through common::envelope (no 4+-word sentence asserts), apply_render.rs keeps one human render check per message family, and apply_fixture.rs holds the fixtures both share. The paths that only had human tests (go drift arms, stale-redirect pruning, no matching package, already-patched dry run, strict per-package failures, the PnP refusal) now have a --json test too. coverage_fix_apply_silent_mute_exit.rs joins the apply suite as apply_silent_staging_errors.rs, so CI links one fewer test binary. Refs #1089. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/apply/apply_failure_paths.rs | 559 +++++++++ .../tests/apply/apply_fixture.rs | 208 ++++ .../tests/apply/apply_render.rs | 429 +++++++ .../apply_silent_staging_errors.rs} | 13 +- .../tests/apply/covgap_commands_apply.rs | 1098 ----------------- crates/socket-patch-cli/tests/apply/main.rs | 9 +- .../coverage_fix_vendor_silent_mute_exit.rs | 2 +- .../tests/scan/covgap_commands_fetch_stage.rs | 2 +- scripts/ci-test-durations.json | 3 +- 9 files changed, 1212 insertions(+), 1111 deletions(-) create mode 100644 crates/socket-patch-cli/tests/apply/apply_failure_paths.rs create mode 100644 crates/socket-patch-cli/tests/apply/apply_fixture.rs create mode 100644 crates/socket-patch-cli/tests/apply/apply_render.rs rename crates/socket-patch-cli/tests/{coverage_fix_apply_silent_mute_exit.rs => apply/apply_silent_staging_errors.rs} (96%) delete mode 100644 crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs diff --git a/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs b/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs new file mode 100644 index 000000000..30fcfbcd5 --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs @@ -0,0 +1,559 @@ +//! `apply`'s `--json` contracts for the paths the rest of the suite never +//! drove (#1089): exit code, `status`, `error.code`, `events[]` and the +//! bytes on disk. A message is read only for what it must name (a module, +//! a path, which drift arm fired). The human output of the same paths is +//! pinned in `apply_render.rs`. +//! +//! 1. `apply --check --json`: the in-sync success envelope and the +//! `go_redirect_drift` Failed events, including the `OrphanReplace` +//! and `WrongReplacePath` arms; +//! 2. `reconcile_local_go` pruning (or, dry, keeping) a stale redirect; +//! 3. the empty-manifest no-op, the no-matching-package skip, the +//! dry-run `already_patched` skip, the per-package `apply_failed` +//! events and the yarn-PnP refusal next to a corrupt manifest; +//! 4. apply-loop wiring: a vendored release-variant base with its +//! installed tree PRESENT is skipped (not re-patched), a qualified +//! singleton whose record holds only NEW files is applied, and a +//! ledger-only vendored project is the `noManifest` no-op; +//! 5. `apply --dry-run --vex` writes no attestation. + +use serde_json::json; + +use crate::apply_fixture::*; +use crate::common::envelope::{ + envelope_error_code, event_triples, events, find_event, has_event, json_string, + parse_json_envelope, +}; +use crate::common::git_sha256; + +/// The `reason` of an `OrphanReplace` drift event: it shares +/// `go_redirect_drift` with the other drift arms. +const ORPHAN_REPLACE: &str = "orphan go.mod `replace`"; + +// ═══════════════════ 1. `--check --json` envelopes ═══════════════════ + +/// `apply --check --json` WITH drift: one Failed event per drift with +/// `errorCode: go_redirect_drift`, status `partialFailure`, exit 1. +#[test] +fn check_json_with_drift_emits_failed_events_envelope() { + let tmp = tempfile::tempdir().unwrap(); + write_drifted_go_manifest(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"], &[]); + assert_eq!(code, 1, "drift must exit 1; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "command"), Some("apply"), "{env}"); + assert_eq!(json_string(&env, "status"), Some("partialFailure"), "{env}"); + let triples = event_triples(&env); + assert!( + !triples.is_empty(), + "drift must be reported as events: {env}" + ); + for (purl, action, code) in triples { + assert_eq!( + (purl.as_str(), action.as_str(), code.as_str()), + (GO_PURL, "failed", "go_redirect_drift"), + "{env}" + ); + } +} + +/// `apply --check --json` with NO drift: the in-sync success envelope. +#[test] +fn check_json_in_sync_emits_success_envelope() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"], &[]); + assert_eq!(code, 0, "in-sync check must exit 0; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "command"), Some("apply"), "{env}"); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert!(events(&env).is_empty(), "no drift, no events: {env}"); +} + +/// A socket-owned `replace` for a module the manifest no longer patches: +/// the `OrphanReplace` arm. Its event is keyed by the module, not a purl. +#[test] +fn check_reports_orphan_replace_drift() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/app\n\ngo 1.21\n\n\ + replace example.com/other v1.2.3 => ./.socket/go-patches/example.com/other@v1.2.3\n", + ) + .unwrap(); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"], &[]); + assert_eq!(code, 1, "an orphan replace is drift; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!( + event_triples(&env), + vec![( + "example.com/other".to_string(), + "failed".to_string(), + "go_redirect_drift".to_string() + )], + "{env}" + ); + let reason = events(&env)[0]["reason"].as_str().unwrap_or_default(); + assert!(reason.contains(ORPHAN_REPLACE), "{env}"); +} + +/// A healthy copy but a socket-owned `replace` pinned at the WRONG target +/// path: the `WrongReplacePath` arm. The copy hashes clean, so this drift +/// is the only finding: go keys `replace` by module+version+path and would +/// silently ignore the directive. +#[test] +fn check_reports_wrong_replace_path_drift() { + let patched = b"patched go content\n"; + let tmp = tempfile::tempdir().unwrap(); + write_manifest( + tmp.path(), + json!({ + GO_PURL: patch_record( + "61616161-6161-4161-8161-616161616161", + one_file("file.go", b"original go content\n", patched), + ) + }), + ); + let copy_dir = tmp.path().join(GO_COPY_DIR); + std::fs::create_dir_all(©_dir).unwrap(); + std::fs::write(copy_dir.join("file.go"), patched).unwrap(); + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/app\n\ngo 1.21\n\n\ + replace example.com/mod v1.0.0 => ./.socket/go-patches/example.com/mod@v0.0.9\n", + ) + .unwrap(); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"], &[]); + assert_eq!(code, 1, "a mispointed replace is drift; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + // The copy validated, so the directive is the ONLY drift: a fixture + // regression that broke the copy would add MissingCopy/StaleCopy events. + assert_eq!( + event_triples(&env), + vec![( + GO_PURL.to_string(), + "failed".to_string(), + "go_redirect_drift".to_string() + )], + "{env}" + ); + let reason = events(&env)[0]["reason"].as_str().unwrap_or_default(); + assert!( + reason.contains("./.socket/go-patches/example.com/mod@v0.0.9"), + "the event must name the found target: {env}" + ); + assert!( + reason.contains("./.socket/go-patches/example.com/mod@v1.0.0"), + "the event must name the expected target: {env}" + ); +} + +// ═══════════ 2. reconcile_local_go: stale redirect pruning ═══════════ + +/// A wet apply prunes the orphaned redirect: the directive leaves `go.mod` +/// and the copy dir is deleted, as a clean no-op. +#[test] +fn reconcile_prunes_stale_go_redirect() { + let tmp = tempfile::tempdir().unwrap(); + write_stale_go_redirect(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); + assert_eq!( + code, 0, + "pruning an orphan is a clean no-op; stderr={stderr}" + ); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + let go_mod = std::fs::read_to_string(tmp.path().join("go.mod")).unwrap(); + assert!( + !go_mod.contains("replace"), + "the orphaned directive must be dropped; go.mod={go_mod}" + ); + assert!( + !tmp.path().join(GO_COPY_DIR).exists(), + "the orphaned copy dir must be pruned" + ); +} + +/// `--dry-run` mutates NOTHING: `go.mod` stays byte-identical and the +/// copy dir survives. +#[test] +fn reconcile_dry_run_touches_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let original_go_mod = write_stale_go_redirect(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--dry-run", "--json"], &[]); + assert_eq!( + code, 0, + "dry-run reconcile is a clean no-op; stderr={stderr}" + ); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("go.mod")).unwrap(), + original_go_mod, + "dry-run must leave go.mod byte-identical" + ); + assert!( + tmp.path().join(GO_COPY_DIR).exists(), + "dry-run must keep the copy dir" + ); +} + +// ═══════════ 3. no-op, skip and failure envelopes ═══════════ + +/// An empty manifest is the clean no-op: success, no events, exit 0. +#[test] +fn empty_manifest_is_a_clean_no_op() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); + assert_eq!( + code, 0, + "an empty manifest is a clean no-op; stderr={stderr}" + ); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert!(events(&env).is_empty(), "{env}"); +} + +/// Zero packages match an in-scope patch (nothing installed): the patch +/// is skipped as `package_not_installed` and the run exits 1. +#[test] +fn no_matching_package_is_skipped_and_fails_the_run() { + let tmp = tempfile::tempdir().unwrap(); + write_npm_patch(tmp.path(), "ghost", "63636363-6363-4363-8363-636363636363"); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); + assert_eq!( + code, 1, + "an in-scope patch with no installed package fails the run; stderr={stderr}" + ); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "status"), Some("partialFailure"), "{env}"); + assert!( + has_event( + &env, + "skipped", + "package_not_installed", + "pkg:npm/ghost@1.0.0" + ), + "{env}" + ); +} + +/// Applying then dry-running the same fixture skips the package as +/// `already_patched` and applies nothing. +#[test] +fn dry_run_after_apply_skips_already_patched() { + let tmp = tempfile::tempdir().unwrap(); + write_npm_patch(tmp.path(), "twice", "64646464-6464-4464-8464-646464646464"); + let file = install_npm_pkg(tmp.path(), "twice", "1.0.0", Some(MM_BEFORE)); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); + assert_eq!(code, 0, "the wet apply must succeed; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + find_event(&env, "applied", None); + assert_eq!( + std::fs::read(&file).unwrap(), + MM_AFTER, + "the wet apply must patch the file" + ); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--dry-run", "--json"], &[]); + assert_eq!( + code, 0, + "the dry-run re-check must succeed; stderr={stderr}" + ); + let env = parse_json_envelope(stdout.trim()); + assert_eq!( + event_triples(&env), + vec![( + "pkg:npm/twice@1.0.0".to_string(), + "skipped".to_string(), + "already_patched".to_string() + )], + "{env}" + ); + assert_eq!(env["summary"]["applied"], 0, "{env}"); +} + +/// `--dry-run --strict` over three single-file packages: the +/// already-patched one is skipped, the hash-mismatched and file-missing +/// ones fail with `apply_failed`, and nothing on disk changes. +#[test] +fn strict_dry_run_fails_mismatched_and_missing_packages() { + let tmp = tempfile::tempdir().unwrap(); + write_three_state_npm_project(tmp.path()); + + let (code, stdout, stderr) = run_apply( + tmp.path(), + &["--offline", "--dry-run", "--strict", "--json"], + &[], + ); + assert_eq!(code, 1, "strict must fail the run; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "status"), Some("partialFailure"), "{env}"); + assert!( + has_event(&env, "skipped", "already_patched", "pkg:npm/valready@1.0.0"), + "{env}" + ); + assert!( + has_event(&env, "failed", "apply_failed", "pkg:npm/vmismatch@1.0.0"), + "{env}" + ); + assert!( + has_event(&env, "failed", "apply_failed", "pkg:npm/vmissing@1.0.0"), + "{env}" + ); + let index = |name: &str| tmp.path().join("node_modules").join(name).join("index.js"); + assert_eq!(std::fs::read(index("vmismatch")).unwrap(), MM_LOCAL); + assert!(!index("vmissing").exists()); +} + +/// A corrupt manifest under a yarn-PnP layout falls through to the +/// ordinary `manifest_invalid` error, NOT the misdirected +/// `yarn_pnp_unsupported` refusal (`manifest_targets_npm`'s `_ => false` +/// arm). Anti-vacuity: the same layout WITH a readable npm-targeting +/// manifest does refuse, and so does `--check`. +#[test] +fn corrupt_manifest_under_pnp_layout_reports_manifest_error_not_refusal() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); + std::fs::write(tmp.path().join(".socket/manifest.json"), "{ not json").unwrap(); + std::fs::write(tmp.path().join(".pnp.cjs"), "// pnp loader stub\n").unwrap(); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); + assert_eq!(code, 1, "a corrupt manifest must fail; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(envelope_error_code(&env), Some("manifest_invalid"), "{env}"); + + let tmp = tempfile::tempdir().unwrap(); + write_pnp_npm_project(tmp.path()); + for extra in [None, Some("--check")] { + let mut args = vec!["--offline", "--json"]; + args.extend(extra); + let (code, stdout, stderr) = run_apply(tmp.path(), &args, &[]); + assert_eq!( + code, 1, + "{args:?}: PnP + npm patch must refuse; stderr={stderr}" + ); + let env = parse_json_envelope(stdout.trim()); + assert_eq!( + envelope_error_code(&env), + Some("yarn_pnp_unsupported"), + "{args:?}: {env}" + ); + } +} + +// ═══════════ 4. apply-loop wiring ═══════════ + +/// A vendored release-variant base WITH its installed tree present must be +/// skipped by the apply loop: only the synthesized `skipped`/`vendored` +/// event, no re-patch of the installed bytes, no `package_not_installed` +/// noise. +#[test] +fn vendored_gem_base_with_installed_tree_is_skipped_not_repatched() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let uuid = "70707070-7070-4070-8070-707070707070"; + let file = write_gem_patch(root, uuid, b"# SOCKET-VENDORED-PATCH\n"); + // Vendor ledger claiming the purl: the entries shape from + // `in_process_rollback_vendored.rs`. + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + std::fs::write( + vendor_dir.join("state.json"), + serde_json::to_vec_pretty(&json!({ + "version": 1, + "entries": { + GEM_BASE_PURL: { + "ecosystem": "gem", + "basePurl": GEM_BASE_PURL, + "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/gem/{uuid}/rack-3.1.0.gem") }, + "wiring": [] + } + } + })) + .unwrap(), + ) + .unwrap(); + + let (code, stdout, stderr) = + run_apply(root, &["--offline", "--ecosystems", "gem", "--json"], &[]); + let env = parse_json_envelope(stdout.trim()); + assert_eq!( + code, 0, + "a vendor-owned base is a clean skip; {env}\nstderr={stderr}" + ); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert_eq!( + event_triples(&env), + vec![( + GEM_BASE_PURL.to_string(), + "skipped".to_string(), + "vendored".to_string() + )], + "exactly one synthesized event for the vendored base: {env}" + ); + assert_eq!( + std::fs::read(&file).unwrap(), + GEM_PRISTINE, + "the loop-level skip must prevent a re-patch of the installed tree" + ); +} + +/// Vendored mode is manifest-free: a project vendored by `scan`/`get --mode +/// vendored` has ONLY `.socket/vendor/state.json` (detached entries with +/// embedded records) and no manifest. The hooked `apply` on such a project +/// is the calm `noManifest` no-op: it never reads the ledger, never takes +/// the lock (so never creates `apply.lock`) and leaves `.socket/` exactly as +/// it found it. +#[test] +fn apply_on_ledger_only_vendored_project_is_a_no_manifest_no_op() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_root_package_json(root); + let uuid = "80808080-8080-4080-8080-808080808080"; + let vendor_dir = root.join(".socket/vendor"); + std::fs::create_dir_all(&vendor_dir).unwrap(); + let ledger = serde_json::to_vec_pretty(&json!({ + "version": 1, + "entries": { "pkg:npm/vend-only@1.0.0": { + "ecosystem": "npm", + "basePurl": "pkg:npm/vend-only@1.0.0", + "uuid": uuid, + "artifact": { "path": format!(".socket/vendor/npm/{uuid}/vend-only-1.0.0.tgz") }, + "wiring": [], + "detached": true, + "record": patch_record(uuid, one_file("package/index.js", MM_BEFORE, MM_AFTER)), + }} + })) + .unwrap(); + std::fs::write(vendor_dir.join("state.json"), &ledger).unwrap(); + + let (code, stdout, stderr) = run_apply(root, &["--json"], &[]); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(code, 0, "{env}\nstderr={stderr}"); + assert_eq!(json_string(&env, "status"), Some("noManifest"), "{env}"); + assert!(events(&env).is_empty(), "{env}"); + + let mut names: Vec = std::fs::read_dir(root.join(".socket")) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!( + names, + vec!["vendor"], + "apply must not touch .socket/ on a manifest-less project (no manifest, \ + no blobs/, no apply.lock)" + ); + assert_eq!( + std::fs::read(vendor_dir.join("state.json")).unwrap(), + ledger, + "the ledger survives byte-identical" + ); +} + +/// A QUALIFIED gem singleton whose record holds only NEW files (empty +/// `beforeHash` ⇒ no representative file) has nothing to disqualify it: +/// the gated apply loop must treat it as installed, attempt it, and +/// CREATE the new file from the staged blob. +#[test] +fn qualified_singleton_with_only_new_files_is_attempted_and_applied() { + const SHIM: &[u8] = b"# security shim injected by socket-patch\n"; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Installed gem WITHOUT the new file (lib/rack.rb only anchors the + // crawler's discovery; it is not in the record). + let gem_file = install_gem(root, "rack-3.1.0", "lib/rack.rb", GEM_PRISTINE); + let shim_path = gem_file.parent().unwrap().join("new_shim.rb"); + assert!(!shim_path.exists()); + let after_hash = git_sha256(SHIM); + write_manifest( + root, + json!({ + GEM_QUALIFIED_PURL: patch_record( + "71717171-7171-4171-8171-717171717171", + json!({ "lib/new_shim.rb": { "beforeHash": "", "afterHash": after_hash } }), + ) + }), + ); + stage_blob(root, &after_hash, SHIM); + + let (code, stdout, stderr) = + run_apply(root, &["--offline", "--ecosystems", "gem", "--json"], &[]); + let env = parse_json_envelope(stdout.trim()); + assert_eq!( + code, 0, + "a no-representative variant must apply cleanly; {env}\nstderr={stderr}" + ); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert!( + events(&env) + .iter() + .any(|e| e["action"] == "applied" && e["purl"] == GEM_QUALIFIED_PURL), + "the qualified singleton must be reported applied: {env}" + ); + assert_eq!( + std::fs::read(&shim_path).unwrap(), + SHIM, + "the new file must be created with the staged blob's bytes" + ); + assert_eq!( + std::fs::read(&gem_file).unwrap(), + GEM_PRISTINE, + "the untouched anchor file must stay pristine" + ); +} + +// ═══════════ 5. dry-run --vex ═══════════ + +/// `apply --dry-run --vex `: nothing was applied, so no attestation +/// file is written and the package is left alone. +#[test] +fn dry_run_with_vex_writes_no_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = write_gem_patch( + root, + "72727272-7272-4272-8272-727272727272", + b"# SOCKET-VEX-PATCH\n", + ); + let vex_path = root.join("out.vex.json"); + + let (code, stdout, stderr) = run_apply( + root, + &[ + "--offline", + "--ecosystems", + "gem", + "--dry-run", + "--json", + "--vex", + vex_path.to_str().unwrap(), + ], + &[], + ); + assert_eq!(code, 0, "the dry-run verify must succeed; stderr={stderr}"); + let env = parse_json_envelope(stdout.trim()); + assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert!( + !vex_path.exists(), + "a dry run must never write an attestation file" + ); + assert_eq!( + std::fs::read(&file).unwrap(), + GEM_PRISTINE, + "a dry run must not modify the package" + ); +} diff --git a/crates/socket-patch-cli/tests/apply/apply_fixture.rs b/crates/socket-patch-cli/tests/apply/apply_fixture.rs new file mode 100644 index 000000000..142b296a4 --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/apply_fixture.rs @@ -0,0 +1,208 @@ +//! Fixtures and runners shared by `apply_failure_paths.rs` (the `--json` +//! contracts) and `apply_render.rs` (the human output) (#1089). +//! +//! Binary-driven throughout (`common::run_with_env`, `SOCKET_*`-scrubbed +//! children), hand-written camelCase manifests, git-sha256 oracle, +//! `--offline` wherever the network is not the subject under test. + +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use crate::common::{git_sha256, run_with_env}; + +pub const GO_PURL: &str = "pkg:golang/example.com/mod@v1.0.0"; +pub const GO_COPY_DIR: &str = ".socket/go-patches/example.com/mod@v1.0.0"; +pub const GEM_BASE_PURL: &str = "pkg:gem/rack@3.1.0"; +pub const GEM_QUALIFIED_PURL: &str = "pkg:gem/rack@3.1.0?platform=ruby"; +pub const GEM_PRISTINE: &[u8] = b"module Rack\n VERSION = '3.1.0'\nend\n"; + +pub const MM_BEFORE: &[u8] = b"pristine content\n"; +pub const MM_AFTER: &[u8] = b"patched content\n"; +pub const MM_LOCAL: &[u8] = b"locally modified content\n"; + +/// Run `socket-patch apply ` in `cwd` with the hermetic `SOCKET_*` +/// environment (see `common::run_with_env`) and `extra_env` landing last +/// (so tests can inject API URLs / TMPDIR). +pub fn run_apply(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { + let mut argv: Vec<&str> = vec!["apply"]; + argv.extend_from_slice(args); + run_with_env(cwd, &argv, extra_env) +} + +/// One camelCase manifest record (the TS-compatible on-disk schema). +pub fn patch_record(uuid: &str, files: Value) -> Value { + json!({ + "uuid": uuid, + "exportedAt": "2024-01-01T00:00:00Z", + "files": files, + "vulnerabilities": {}, + "description": "apply suite fixture", + "license": "MIT", + "tier": "free" + }) +} + +/// The `files` map of a one-file patch from `before` to `after` bytes. +pub fn one_file(path: &str, before: &[u8], after: &[u8]) -> Value { + json!({ path: { "beforeHash": git_sha256(before), "afterHash": git_sha256(after) } }) +} + +/// Write `.socket/manifest.json` (creating `.socket/blobs/` alongside so +/// blob-emptiness assertions have a stable target). +pub fn write_manifest(root: &Path, patches: Value) { + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).expect("create .socket/blobs"); + std::fs::write( + socket.join("manifest.json"), + serde_json::to_vec_pretty(&json!({ "patches": patches })).unwrap(), + ) + .expect("write manifest"); +} + +pub fn stage_blob(root: &Path, hash: &str, content: &[u8]) { + let blobs = root.join(".socket").join("blobs"); + std::fs::create_dir_all(&blobs).expect("create blobs dir"); + std::fs::write(blobs.join(hash), content).expect("stage blob"); +} + +/// Install a fake npm package at `/node_modules/` with the +/// given `index.js` bytes (`None`: no `index.js`); returns the index.js +/// path. +pub fn install_npm_pkg(root: &Path, name: &str, version: &str, index_js: Option<&[u8]>) -> PathBuf { + let pkg_dir = root.join("node_modules").join(name); + std::fs::create_dir_all(&pkg_dir).expect("create pkg dir"); + std::fs::write( + pkg_dir.join("package.json"), + format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), + ) + .expect("write package.json"); + let index = pkg_dir.join("index.js"); + if let Some(bytes) = index_js { + std::fs::write(&index, bytes).expect("write index.js"); + } + index +} + +pub fn write_root_package_json(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{ "name": "apply-suite-root", "version": "0.0.0" }"#, + ) + .expect("write root package.json"); +} + +/// Synthesize an installed gem under the cwd's vendor/bundle tree (the +/// layout the ruby crawler scans in local mode — same shape as +/// `cli_gem_variant_mismatch_policy.rs`). Returns the patchable file path. +pub fn install_gem(root: &Path, leaf: &str, file_rel: &str, contents: &[u8]) -> PathBuf { + let gem_dir = root + .join("vendor") + .join("bundle") + .join("ruby") + .join("3.4.0") + .join("gems") + .join(leaf); + let file = gem_dir.join(file_rel); + std::fs::create_dir_all(file.parent().unwrap()).expect("create gem dir"); + std::fs::write(&file, contents).expect("write gem file"); + file +} + +/// Valid manifest with one golang patch and NO committed copy / `go.mod`: +/// `--check` reports MissingCopy + MissingReplace drift (the +/// `cli_apply_silent.rs::write_drifted_go_manifest` shape). +pub fn write_drifted_go_manifest(root: &Path) { + write_manifest( + root, + json!({ + GO_PURL: patch_record( + "60606060-6060-4060-8060-606060606060", + json!({ "file.go": { + "beforeHash": "a".repeat(64), + "afterHash": "b".repeat(64), + }}), + ) + }), + ); +} + +/// Stale-redirect fixture: a socket-owned `replace` + committed copy for a +/// module the (empty) manifest no longer patches. Returns the `go.mod` +/// text. +pub fn write_stale_go_redirect(root: &Path) -> String { + write_manifest(root, json!({})); + let go_mod = "module example.com/app\n\ngo 1.21\n\n\ + replace example.com/mod v1.0.0 => ./.socket/go-patches/example.com/mod@v1.0.0\n"; + std::fs::write(root.join("go.mod"), go_mod).unwrap(); + let copy_dir = root.join(GO_COPY_DIR); + std::fs::create_dir_all(©_dir).unwrap(); + std::fs::write(copy_dir.join("file.go"), b"patched\n").unwrap(); + go_mod.to_string() +} + +/// A root `package.json` plus a manifest patching `pkg:npm/@1.0.0`'s +/// `package/index.js` from [`MM_BEFORE`] to [`MM_AFTER`], with the after +/// blob staged so staging succeeds offline. Nothing is installed. +pub fn write_npm_patch(root: &Path, name: &str, uuid: &str) { + write_root_package_json(root); + write_manifest( + root, + json!({ + format!("pkg:npm/{name}@1.0.0"): + patch_record(uuid, one_file("package/index.js", MM_BEFORE, MM_AFTER)), + }), + ); + stage_blob(root, &git_sha256(MM_AFTER), MM_AFTER); +} + +/// The `--verbose --strict` fixture: three single-file npm packages, one +/// already at afterHash (`valready`), one matching neither hash +/// (`vmismatch`) and one missing the patched file (`vmissing`). +pub fn write_three_state_npm_project(root: &Path) { + write_root_package_json(root); + install_npm_pkg(root, "valready", "1.0.0", Some(MM_AFTER)); + install_npm_pkg(root, "vmismatch", "1.0.0", Some(MM_LOCAL)); + install_npm_pkg(root, "vmissing", "1.0.0", None); + let files = one_file("package/index.js", MM_BEFORE, MM_AFTER); + write_manifest( + root, + json!({ + "pkg:npm/valready@1.0.0": + patch_record("65656565-6565-4565-8565-656565656565", files.clone()), + "pkg:npm/vmismatch@1.0.0": + patch_record("66666666-6666-4666-8666-666666666666", files.clone()), + "pkg:npm/vmissing@1.0.0": + patch_record("67676767-6767-4767-8767-676767676767", files), + }), + ); + stage_blob(root, &git_sha256(MM_AFTER), MM_AFTER); +} + +/// A yarn-PnP project (`.pnp.cjs`) whose manifest targets npm. +pub fn write_pnp_npm_project(root: &Path) { + write_manifest( + root, + json!({ + "pkg:npm/blocked@1.0.0": patch_record( + "68686868-6868-4868-8868-686868686868", + one_file("package/index.js", MM_BEFORE, MM_AFTER), + ) + }), + ); + std::fs::write(root.join(".pnp.cjs"), "// pnp loader stub\n").unwrap(); +} + +/// An installed `rack-3.1.0` gem and a manifest patching +/// `lib/rack.rb` with `marker` appended, blob staged. Returns the +/// installed file. +pub fn write_gem_patch(root: &Path, uuid: &str, marker: &[u8]) -> PathBuf { + let file = install_gem(root, "rack-3.1.0", "lib/rack.rb", GEM_PRISTINE); + let patched = [GEM_PRISTINE, marker].concat(); + write_manifest( + root, + json!({ GEM_BASE_PURL: patch_record(uuid, one_file("lib/rack.rb", GEM_PRISTINE, &patched)) }), + ); + stage_blob(root, &git_sha256(&patched), &patched); + file +} diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs new file mode 100644 index 000000000..48b89111d --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -0,0 +1,429 @@ +//! `apply`'s render suite (#1089): the human-mode (no `--json`, no +//! `--silent`) output of `commands/apply.rs`. Behavior is asserted +//! through `--json` codes in `apply_failure_paths.rs`; the sentence +//! checks here are the render checks, one per message family. +//! +//! Lines pinned: the go drift report, the stale-redirect `Removed` / +//! `Would remove` announcement, "No patches to apply.", the +//! no-matching-package error block, the dry-run summary counts, +//! `--verbose` per-file labels, the pnpm/bun/vlt layout notes, the +//! yarn-PnP refusal, the gem fallback-home skip warning and the +//! `--dry-run --vex` skip line. + +use serde_json::json; + +use crate::apply_fixture::*; + +// ═══════════ go drift and reconcile reports ═══════════ + +/// `--check` drift prints the out-of-sync report with each drift's detail. +#[test] +fn check_drift_prints_out_of_sync_report() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + std::fs::write( + tmp.path().join("go.mod"), + "module example.com/app\n\ngo 1.21\n\n\ + replace example.com/other v1.2.3 => ./.socket/go-patches/example.com/other@v1.2.3\n", + ) + .unwrap(); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--check"], &[]); + assert_eq!(code, 1, "an orphan replace is drift; stderr={stderr}"); + assert!( + stderr.contains("Patches are OUT OF SYNC:"), + "drift must print the out-of-sync report; stderr={stderr}" + ); + assert!( + stderr.contains("orphan go.mod `replace` for `example.com/other`"), + "the report must carry each drift's detail; stderr={stderr}" + ); +} + +/// A wet apply announces the pruned redirect with its purl; `--dry-run` +/// uses the conditional verb. +#[test] +fn reconcile_announces_removed_and_would_remove() { + let tmp = tempfile::tempdir().unwrap(); + write_stale_go_redirect(tmp.path()); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--dry-run"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + 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}"); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + stdout.contains("Removed 1 stale Go patch redirect:"), + "the removal must be announced; stdout={stdout}" + ); + assert!( + stdout.contains(GO_PURL), + "the announcement must name the pruned purl; stdout={stdout}" + ); +} + +// ═══════════ apply output block ═══════════ + +/// The empty-scope clean success prints "No patches to apply.": the +/// postinstall-hook UX for fresh projects. +#[test] +fn empty_manifest_prints_no_patches_to_apply() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + stdout.contains("No patches to apply."), + "the human no-op line must print; stdout={stdout}" + ); +} + +/// Zero packages match an in-scope patch: the error block lists the +/// unmatched purls and carries the check-your-cwd hint. +#[test] +fn no_matching_packages_prints_error_block() { + let tmp = tempfile::tempdir().unwrap(); + write_npm_patch(tmp.path(), "ghost", "63636363-6363-4363-8363-636363636363"); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains("Error: The targeted manifest patch matched no installed package:"), + "a failing run labels its cause an error; stderr={stderr}" + ); + assert!( + stderr.contains(" - pkg:npm/ghost@1.0.0"), + "the error must list the unmatched purls; stderr={stderr}" + ); + assert!( + stderr.contains("--cwd points to the right directory"), + "the error must carry the remediation hint; stderr={stderr}" + ); +} + +/// The dry-run summary counts an already-patched package under "already +/// patched", not "can be patched". +#[test] +fn dry_run_summary_counts_already_patched() { + let tmp = tempfile::tempdir().unwrap(); + write_npm_patch(tmp.path(), "twice", "64646464-6464-4464-8464-646464646464"); + install_npm_pkg(tmp.path(), "twice", "1.0.0", Some(MM_AFTER)); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--dry-run"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + stdout.contains("Patch verification complete:"), + "stdout={stdout}" + ); + assert!( + stdout.contains("1 package already patched"), + "stdout={stdout}" + ); + assert!( + stdout.contains("0 packages can be patched"), + "stdout={stdout}" + ); +} + +/// `--verbose` detailed verification: the already-patched / hash-mismatch / +/// not-found labels, the per-file `message:` line, the `expected:` hash +/// line and the per-package failure lines. +#[test] +fn verbose_dry_run_prints_per_file_labels_and_hashes() { + let tmp = tempfile::tempdir().unwrap(); + write_three_state_npm_project(tmp.path()); + + let (code, stdout, stderr) = run_apply( + tmp.path(), + &["--offline", "--dry-run", "--verbose", "--strict"], + &[], + ); + assert_eq!(code, 1, "stderr={stderr}"); + assert!(stdout.contains("Detailed verification:"), "stdout={stdout}"); + for label in ["[already patched]", "[hash mismatch]", "[not found]"] { + assert!( + stdout.contains(&format!("package/index.js {label}")), + "the {label} label must render; stdout={stdout}" + ); + } + assert!( + stdout.contains("message: File hash does not match expected value"), + "stdout={stdout}" + ); + assert!( + stdout.contains("message: File not found"), + "stdout={stdout}" + ); + assert!( + stdout.contains(&format!( + "expected: {}", + crate::common::git_sha256(MM_BEFORE) + )), + "the expected-hash line must render for the mismatch; stdout={stdout}" + ); + for purl in ["pkg:npm/vmismatch@1.0.0", "pkg:npm/vmissing@1.0.0"] { + assert!( + stderr.contains(&format!("Failed to patch {purl}")), + "stderr={stderr}" + ); + } +} + +// ═══════════ layout notes and refusals ═══════════ + +/// A pnpm store layout gets the informational stderr note. +#[test] +fn pnpm_layout_prints_informational_note() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + std::fs::create_dir_all(tmp.path().join("node_modules").join(".pnpm")).unwrap(); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + stderr.contains( + "Note: pnpm layout detected. Copy-on-write will keep the global store untouched." + ), + "stderr={stderr}" + ); + assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); +} + +/// A `bun.lock` + `node_modules/` tree routes to the Bun arm and prints +/// its informational note (non-fatal, exit 0). +#[test] +fn bun_layout_prints_informational_note() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); + std::fs::write(tmp.path().join("bun.lock"), "{}\n").unwrap(); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!( + code, 0, + "the bun note is informational only; stderr={stderr}" + ); + assert!( + stderr.contains( + "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched." + ), + "stderr={stderr}" + ); +} + +/// A vlt install (the `node_modules/.vlt` store) prints its note and never +/// refuses. vlt install state outranks a sibling `bun.lock`, so the bun +/// note does not print. +#[test] +fn vlt_layout_prints_informational_note_in_human_mode() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + std::fs::create_dir_all(tmp.path().join("node_modules").join(".vlt")).unwrap(); + std::fs::write(tmp.path().join("bun.lock"), "{}\n").unwrap(); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!( + code, 0, + "the vlt note is informational only; stderr={stderr}" + ); + assert!( + stderr.contains( + "Note: vlt layout detected. Copy-on-write keeps vlt's shared package store \ + (/store/v1) untouched." + ), + "stderr={stderr}" + ); + assert!(!stderr.contains("bun layout detected"), "stderr={stderr}"); +} + +/// The layout notes are human-only: `--json` and `--silent` runs (the +/// install hook runs `apply --silent`) stay quiet. The hidden lock alone +/// is vlt install state too. +#[test] +fn vlt_layout_note_is_muted_under_json_and_silent() { + let tmp = tempfile::tempdir().unwrap(); + write_manifest(tmp.path(), json!({})); + std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); + std::fs::write(tmp.path().join("node_modules/.vlt-lock.json"), "{}").unwrap(); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!(stderr.contains("vlt layout detected"), "stderr={stderr}"); + for flag in ["--json", "--silent"] { + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline", flag], &[]); + assert_eq!(code, 0, "{flag}: stderr={stderr}"); + assert!( + !stderr.contains("layout detected"), + "{flag}: stderr={stderr}" + ); + } +} + +/// The yarn-PnP refusal names the layout on human stderr; a corrupt +/// manifest under the same layout prints the manifest error instead. +#[test] +fn pnp_refusal_names_the_layout_and_corrupt_manifest_does_not() { + let tmp = tempfile::tempdir().unwrap(); + write_pnp_npm_project(tmp.path()); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains("Plug'n'Play layout is not supported"), + "stderr={stderr}" + ); + + std::fs::write(tmp.path().join(".socket/manifest.json"), "{ not json").unwrap(); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains("Failed to parse manifest JSON"), + "stderr={stderr}" + ); + assert!(!stderr.contains("Plug'n'Play"), "stderr={stderr}"); +} + +// ═══════════ gem fallback-home skip warning ═══════════ + +/// Unix-only: the fallback home comes from a fake `gem` binary on PATH +/// (the `in_process_gem_fallback_home.rs` fixture shape, which pins the +/// `--json` warning code; this is its human render). +#[cfg(unix)] +mod gem_fallback_home_human { + use std::path::{Path, PathBuf}; + + use serde_json::json; + + use crate::apply_fixture::*; + use crate::common; + + const ORIGINAL: &[u8] = b"module Rack\n VERSION = 'VULNERABLE'\nend\n"; + const MARKER: &[u8] = b"# SOCKET-PATCHED-FALLBACK\n"; + + /// Fake `gem` answering `env gemdir` with `home`, so the crawler's + /// fallback resolves to exactly one gem home. + fn install_fake_gem(bin_dir: &Path, home: &Path) { + use std::os::unix::fs::PermissionsExt; + let script = format!( + "#!/bin/sh\nif [ \"$1\" = env ] && [ \"$2\" = gemdir ]; then\n printf '%s\\n' \"{}\"\n exit 0\nfi\nexit 1\n", + home.display() + ); + let bin = bin_dir.join("gem"); + std::fs::write(&bin, script).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + /// Stage a gem copy (`/gems/rack-3.1.0/lib/rack.rb` = `bytes`) + /// plus the `specifications/` marker; returns the staged file path. + fn stage_copy(root: &Path, bytes: &[u8]) -> PathBuf { + let file = root.join("gems/rack-3.1.0/lib/rack.rb"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, bytes).unwrap(); + std::fs::create_dir_all(root.join("specifications")).unwrap(); + file + } + + /// Human-mode apply with the hermetic SOCKET_* env and BUNDLE_* + /// scrubbed, PATH = the fake-gem bin dir only, BUNDLE_PATH = the store + /// root. + fn run_apply_human(project: &Path, bin_dir: &Path, store_root: &Path) -> (i32, String, String) { + let mut cmd = common::hermetic_command(&common::binary()); + cmd.args(["apply", "--offline", "--ecosystems", "gem", "--cwd"]) + .arg(project); + common::hermetic::scrub_extra(&mut cmd, &[common::hermetic::Extra::Bundle]); + cmd.env("PATH", bin_dir); + cmd.env("BUNDLE_PATH", store_root); + let out = cmd.output().expect("run socket-patch apply"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) + } + + /// Store copy patched + home copy matching NO variant: the per-copy + /// warning names the skipped copy's path and says why the skip is + /// safe. Non-fatal; the store copy is patched, the home copy untouched. + #[test] + fn mismatched_fallback_home_copy_warns_on_human_stderr() { + let foreign = b"totally different bytes\n"; + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap(); + + let store_root = root.join("bundle-store"); + let store_file = stage_copy(&store_root, ORIGINAL); + let home = root.join("gem-home"); + let home_file = stage_copy(&home, foreign); + let bin_dir = root.join("fake-bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + install_fake_gem(&bin_dir, &home); + + let patched = [ORIGINAL, MARKER].concat(); + write_manifest( + root, + json!({ + GEM_QUALIFIED_PURL: patch_record( + "69696969-6969-4969-8969-696969696969", + one_file("lib/rack.rb", ORIGINAL, &patched), + ) + }), + ); + stage_blob(root, &common::git_sha256(&patched), &patched); + + let (code, _stdout, stderr) = run_apply_human(root, &bin_dir, &store_root); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + stderr.contains("Warning: ") && stderr.contains("was not patched"), + "the best-effort skip must print its human warning; stderr={stderr}" + ); + assert!( + stderr.contains(&format!("gem-env home copy at {}", home.display())), + "the warning must name the fallback-home copy; stderr={stderr}" + ); + assert!( + stderr.contains("bundle-path copy — the one bundler loads — is patched"), + "the warning must explain why the skip is safe; stderr={stderr}" + ); + assert_eq!(std::fs::read(&store_file).unwrap(), patched); + assert_eq!(std::fs::read(&home_file).unwrap(), foreign); + } +} + +// ═══════════ dry-run --vex ═══════════ + +/// `apply --dry-run --vex ` announces that VEX generation is skipped. +#[test] +fn dry_run_with_vex_announces_the_skip() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_gem_patch( + root, + "72727272-7272-4272-8272-727272727272", + b"# SOCKET-VEX-PATCH\n", + ); + let vex_path = root.join("out.vex.json"); + + let (code, stdout, stderr) = run_apply( + root, + &[ + "--offline", + "--ecosystems", + "gem", + "--dry-run", + "--vex", + vex_path.to_str().unwrap(), + ], + &[], + ); + assert_eq!(code, 0, "stderr={stderr}"); + assert!( + stdout.contains("Skipping VEX generation (--dry-run: nothing was applied)."), + "stdout={stdout}" + ); +} diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs similarity index 96% rename from crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs rename to crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs index aa56f3799..c069ff490 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs +++ b/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs @@ -20,12 +20,11 @@ //! this path (partialFailure, empty events, NO top-level error record — //! deliberately distinct from vendor's `no_local_source` hard error). -#[path = "common/mod.rs"] -mod common; -use common::{binary, hermetic_command}; - use std::path::Path; +use crate::common::envelope::{json_string, parse_json_envelope}; +use crate::common::{binary, hermetic_command}; + /// Run `socket-patch apply` in `cwd` with a scrubbed SOCKET_* environment /// so ambient developer/CI configuration (tokens, silent toggles) can't /// change the branch under test. @@ -166,10 +165,10 @@ fn apply_json_offline_missing_source_keeps_stderr_clean() { let (code, stdout, stderr) = run_apply(tmp.path(), &["--json", "--offline"]); assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); - let v: serde_json::Value = - serde_json::from_str(&stdout).expect("apply --json must emit valid JSON"); + let v = parse_json_envelope(&stdout); assert_eq!( - v["status"], "partialFailure", + json_string(&v, "status"), + Some("partialFailure"), "the pinned envelope shape for the offline bail, got {v}" ); let chatter = stderr_chatter(&stderr); diff --git a/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs b/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs deleted file mode 100644 index 55947a163..000000000 --- a/crates/socket-patch-cli/tests/apply/covgap_commands_apply.rs +++ /dev/null @@ -1,1098 +0,0 @@ -//! Coverage-gap tests for `commands/apply.rs`. -//! -//! The uncovered surface of apply.rs is dominated by HUMAN-mode output — -//! nearly every existing apply test passes `--json` and/or `--silent` — plus -//! two never-fired go-drift variants, the whole Bun layout arm, the -//! `--check --json` envelopes. -//! Themes: -//! -//! 1. `apply --check --json`: the in-sync success envelope and the -//! machine-readable drift envelope (`go_redirect_drift` Failed events); -//! 2. the `WrongReplacePath` and `OrphanReplace` go-drift variants -//! (hand-built `go.mod` fixtures — the other variants are covered); -//! 3. `reconcile_local_go`'s human report (`Removed` / `Would remove` -//! N stale go patch redirect(s)); -//! 4. human-mode output block: "No patches to apply.", the no-matching- -//! packages warning, the npm per-package failure line, the dry-run -//! "already patched" count, `--verbose` per-file labels, the -//! pnpm/bun/vlt layout notes, and the corrupt-manifest-under-PnP -//! fall-through; -//! 5. gem fallback-home skip surfacing on human stderr; -//! 6. apply-loop wiring: a vendored release-variant base with its -//! installed tree PRESENT is skipped (not re-patched), and a qualified -//! singleton whose record holds only NEW files (no representative) -//! is treated as installed and applied; a ledger-only vendored -//! project with no manifest is the `noManifest` no-op; -//! 7. the `apply --dry-run --vex` skip message. -//! -//! Binary-driven throughout (`common::run_with_env`, `SOCKET_*`-scrubbed -//! children), hand-written camelCase manifests, git-sha256 oracle, -//! `--offline` wherever the network is not the subject under test. - -use std::path::{Path, PathBuf}; - -use serde_json::{json, Value}; - -use crate::common; - -use common::{git_sha256, parse_json_envelope, run_with_env}; - -// ───────────────────────── shared fixture helpers ───────────────────────── - -/// Run `socket-patch apply ` in `cwd` with the hermetic `SOCKET_*` -/// environment (see `common::run_with_env`) and `extra_env` landing last -/// (so tests can inject API URLs / TMPDIR). -fn run_apply(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { - let mut argv: Vec<&str> = vec!["apply"]; - argv.extend_from_slice(args); - run_with_env(cwd, &argv, extra_env) -} - -/// One camelCase manifest record (the TS-compatible on-disk schema). -fn patch_record(uuid: &str, files: Value) -> Value { - json!({ - "uuid": uuid, - "exportedAt": "2024-01-01T00:00:00Z", - "files": files, - "vulnerabilities": {}, - "description": "covgap apply fixture", - "license": "MIT", - "tier": "free" - }) -} - -/// Write `.socket/manifest.json` (creating `.socket/blobs/` alongside so -/// blob-emptiness assertions have a stable target). -fn write_manifest(root: &Path, patches: Value) { - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).expect("create .socket/blobs"); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&json!({ "patches": patches })).unwrap(), - ) - .expect("write manifest"); -} - -fn stage_blob(root: &Path, hash: &str, content: &[u8]) { - let blobs = root.join(".socket").join("blobs"); - std::fs::create_dir_all(&blobs).expect("create blobs dir"); - std::fs::write(blobs.join(hash), content).expect("stage blob"); -} - -/// Install a fake npm package at `/node_modules/` with the -/// given `index.js` bytes; returns the index.js path. -fn install_npm_pkg(root: &Path, name: &str, version: &str, index_js: &[u8]) -> PathBuf { - let pkg_dir = root.join("node_modules").join(name); - std::fs::create_dir_all(&pkg_dir).expect("create pkg dir"); - std::fs::write( - pkg_dir.join("package.json"), - format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), - ) - .expect("write package.json"); - let index = pkg_dir.join("index.js"); - std::fs::write(&index, index_js).expect("write index.js"); - index -} - -fn write_root_package_json(root: &Path) { - std::fs::write( - root.join("package.json"), - r#"{ "name": "covgap-apply-root", "version": "0.0.0" }"#, - ) - .expect("write root package.json"); -} - -/// Synthesize an installed gem under the cwd's vendor/bundle tree (the -/// layout the ruby crawler scans in local mode — same shape as -/// `cli_gem_variant_mismatch_policy.rs`). Returns the patchable file path. -fn install_gem(root: &Path, leaf: &str, file_rel: &str, contents: &[u8]) -> PathBuf { - let gem_dir = root - .join("vendor") - .join("bundle") - .join("ruby") - .join("3.4.0") - .join("gems") - .join(leaf); - let file = gem_dir.join(file_rel); - std::fs::create_dir_all(file.parent().unwrap()).expect("create gem dir"); - std::fs::write(&file, contents).expect("write gem file"); - file -} - -// ═══════════════════ 1. `--check --json` envelopes ═══════════════════ - -const GO_PURL: &str = "pkg:golang/example.com/mod@v1.0.0"; - -/// Valid manifest with one golang patch and NO committed copy / `go.mod` — -/// `--check` reports MissingCopy + MissingReplace drift (the -/// `cli_apply_silent.rs::write_drifted_go_manifest` shape). -fn write_drifted_go_manifest(root: &Path) { - write_manifest( - root, - json!({ - GO_PURL: patch_record( - "60606060-6060-4060-8060-606060606060", - json!({ "file.go": { - "beforeHash": "a".repeat(64), - "afterHash": "b".repeat(64), - }}), - ) - }), - ); -} - -/// `apply --check --json` WITH drift: the machine-readable envelope carries -/// one Failed event per drift with `errorCode: go_redirect_drift`, status -/// `partialFailure`, exit 1. -#[test] -fn check_json_with_drift_emits_failed_events_envelope() { - let tmp = tempfile::tempdir().unwrap(); - write_drifted_go_manifest(tmp.path()); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"], &[]); - assert_eq!(code, 1, "drift must exit 1; stderr={stderr}"); - let env = parse_json_envelope(stdout.trim()); - assert_eq!(env["command"], "apply", "envelope: {env}"); - assert_eq!(env["status"], "partialFailure", "envelope: {env}"); - let events = env["events"].as_array().expect("events array"); - assert!( - !events.is_empty(), - "drift must be reported as events: {env}" - ); - for e in events { - assert_eq!(e["action"], "failed", "envelope: {env}"); - assert_eq!(e["errorCode"], "go_redirect_drift", "envelope: {env}"); - assert_eq!(e["purl"], GO_PURL, "envelope: {env}"); - } -} - -/// `apply --check --json` with NO drift: the in-sync success envelope -/// (a single JSON object, status success, exit 0) — never serialized by -/// any prior test. -#[test] -fn check_json_in_sync_emits_success_envelope() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--check", "--json"], &[]); - assert_eq!(code, 0, "in-sync check must exit 0; stderr={stderr}"); - let env = parse_json_envelope(stdout.trim()); - assert_eq!(env["command"], "apply", "envelope: {env}"); - assert_eq!(env["status"], "success", "envelope: {env}"); - assert!( - env["events"].as_array().is_none_or(|e| e.is_empty()), - "an in-sync check reports no drift events: {env}" - ); -} - -// ══════════════ 2. WrongReplacePath / OrphanReplace drift ══════════════ - -/// `--check` with a socket-owned `replace` for a module the manifest no -/// longer patches: the `OrphanReplace` drift variant (never fired before). -/// Its drift-id extraction routes through the module (not a purl). -#[test] -fn check_reports_orphan_replace_drift() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - std::fs::write( - tmp.path().join("go.mod"), - "module example.com/app\n\ngo 1.21\n\n\ - replace example.com/other v1.2.3 => ./.socket/go-patches/example.com/other@v1.2.3\n", - ) - .unwrap(); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--check"], &[]); - assert_eq!(code, 1, "an orphan replace is drift; stderr={stderr}"); - assert!( - stderr.contains("OUT OF SYNC"), - "drift must print the out-of-sync report; stderr={stderr}" - ); - assert!( - stderr.contains("orphan go.mod `replace` for `example.com/other`"), - "the OrphanReplace detail must name the module; stderr={stderr}" - ); -} - -/// `--check` with a healthy copy but a socket-owned `replace` pinned at the -/// WRONG target path: the `WrongReplacePath` drift variant (never fired -/// before). The copy hashes clean, so this drift is the only finding — -/// pinning that a silently-ignored directive (go keys `replace` by -/// module+version+path) is caught even when the copy checks pass. -#[test] -fn check_reports_wrong_replace_path_drift() { - let patched = b"patched go content\n"; - let tmp = tempfile::tempdir().unwrap(); - write_manifest( - tmp.path(), - json!({ - GO_PURL: patch_record( - "61616161-6161-4161-8161-616161616161", - json!({ "file.go": { - "beforeHash": git_sha256(b"original go content\n"), - "afterHash": git_sha256(patched), - }}), - ) - }), - ); - // Committed copy whose file hashes exactly to afterHash — no - // MissingCopy/StaleCopy noise. - let copy_dir = tmp.path().join(".socket/go-patches/example.com/mod@v1.0.0"); - std::fs::create_dir_all(©_dir).unwrap(); - std::fs::write(copy_dir.join("file.go"), patched).unwrap(); - // Socket-owned directive (target under .socket/go-patches/) pointing at - // the WRONG version's copy. - std::fs::write( - tmp.path().join("go.mod"), - "module example.com/app\n\ngo 1.21\n\n\ - replace example.com/mod v1.0.0 => ./.socket/go-patches/example.com/mod@v0.0.9\n", - ) - .unwrap(); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--check"], &[]); - assert_eq!(code, 1, "a mispointed replace is drift; stderr={stderr}"); - assert!( - stderr.contains("points at ./.socket/go-patches/example.com/mod@v0.0.9"), - "the WrongReplacePath detail must name the found target; stderr={stderr}" - ); - assert!( - stderr.contains("should be ./.socket/go-patches/example.com/mod@v1.0.0"), - "the WrongReplacePath detail must name the expected target; stderr={stderr}" - ); - // Fixture sharpness: the copy validated, so the ONLY drift is the - // directive — a fixture regression that broke the copy would show up - // as extra findings here. - assert!( - !stderr.contains("missing patched copy") && !stderr.contains("stale copy"), - "the copy must verify clean (WrongReplacePath must be the only drift); stderr={stderr}" - ); -} - -// ═══════════ 3. reconcile_local_go human report (Removed / Would remove) ═══════════ - -/// Stale-redirect fixture: a socket-owned `replace` + committed copy for a -/// module the (empty) manifest no longer patches. -fn write_stale_go_redirect(root: &Path) -> String { - write_manifest(root, json!({})); - let go_mod = "module example.com/app\n\ngo 1.21\n\n\ - replace example.com/mod v1.0.0 => ./.socket/go-patches/example.com/mod@v1.0.0\n"; - std::fs::write(root.join("go.mod"), go_mod).unwrap(); - let copy_dir = root.join(".socket/go-patches/example.com/mod@v1.0.0"); - std::fs::create_dir_all(©_dir).unwrap(); - std::fs::write(copy_dir.join("file.go"), b"patched\n").unwrap(); - go_mod.to_string() -} - -/// A wet human-mode apply prunes the orphaned redirect and ANNOUNCES it — -/// "Removed 1 stale go patch redirect(s):" plus the purl — before the -/// clean-no-op "No patches to apply." exit. -#[test] -fn reconcile_announces_removed_stale_go_redirect_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - write_stale_go_redirect(tmp.path()); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!( - code, 0, - "pruning an orphan is a clean no-op; stderr={stderr}" - ); - assert!( - stdout.contains("Removed 1 stale Go patch redirect:"), - "the removal must be announced; stdout={stdout}" - ); - assert!( - stdout.contains(GO_PURL), - "the announcement must name the pruned purl; stdout={stdout}" - ); - let go_mod = std::fs::read_to_string(tmp.path().join("go.mod")).unwrap(); - assert!( - !go_mod.contains("replace"), - "the orphaned directive must actually be dropped; go.mod={go_mod}" - ); - assert!( - !tmp.path() - .join(".socket/go-patches/example.com/mod@v1.0.0") - .exists(), - "the orphaned copy dir must be pruned" - ); -} - -/// `--dry-run` uses the "Would remove" verb and mutates NOTHING: `go.mod` -/// stays byte-identical and the copy dir survives. -#[test] -fn reconcile_dry_run_says_would_remove_and_touches_nothing() { - let tmp = tempfile::tempdir().unwrap(); - let original_go_mod = write_stale_go_redirect(tmp.path()); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--dry-run"], &[]); - assert_eq!( - code, 0, - "dry-run reconcile is a clean no-op; stderr={stderr}" - ); - assert!( - 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}"); - assert_eq!( - std::fs::read_to_string(tmp.path().join("go.mod")).unwrap(), - original_go_mod, - "dry-run must leave go.mod byte-identical" - ); - assert!( - tmp.path() - .join(".socket/go-patches/example.com/mod@v1.0.0") - .exists(), - "dry-run must keep the copy dir" - ); -} - -// Shared fixture bytes for the sections below. -const MM_BEFORE: &[u8] = b"pristine content\n"; -const MM_AFTER: &[u8] = b"patched content\n"; -const MM_LOCAL: &[u8] = b"locally modified content\n"; - -// ═══════════ 4. human-mode apply output block ═══════════ - -/// The empty-scope clean success prints "No patches to apply." — the -/// postinstall-hook UX for fresh projects (json/silent suppress the line). -#[test] -fn empty_manifest_prints_no_patches_to_apply_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!( - code, 0, - "an empty manifest is a clean no-op; stderr={stderr}" - ); - assert!( - stdout.contains("No patches to apply."), - "the human no-op line must print; stdout={stdout}" - ); -} - -/// Zero packages match any in-scope patch (crawl found nothing): the -/// human warning block prints — with the in-scope count and the -/// check-your-cwd hint — and the run exits 1. -#[test] -fn no_matching_packages_prints_warning_block_and_fails() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - // One npm patch in the manifest, blob staged (so staging succeeds - // offline), but NO node_modules at all. - write_manifest( - tmp.path(), - json!({ - "pkg:npm/ghost@1.0.0": patch_record( - "63636363-6363-4363-8363-636363636363", - json!({ "package/index.js": { - "beforeHash": git_sha256(MM_BEFORE), - "afterHash": git_sha256(MM_AFTER), - }}), - ) - }), - ); - stage_blob(tmp.path(), &git_sha256(MM_AFTER), MM_AFTER); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!( - code, 1, - "an in-scope patch with no installed package fails the run; stderr={stderr}" - ); - assert!( - stderr.contains("Error: The targeted manifest patch matched no installed package:"), - "a failing run labels its cause an error; stderr={stderr}" - ); - assert!( - stderr.contains(" - pkg:npm/"), - "the error must list the unmatched purls; stderr={stderr}" - ); - assert!( - stderr.contains("--cwd points to the right directory"), - "the warning must carry the remediation hint; stderr={stderr}" - ); -} - -/// Applying then dry-running the same fixture reports the package under -/// "already patched" in the dry-run human summary (the count line had -/// never rendered with a non-empty already_patched set). -#[test] -fn dry_run_after_apply_reports_already_patched_count() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - let file = install_npm_pkg(tmp.path(), "twice", "1.0.0", MM_BEFORE); - write_manifest( - tmp.path(), - json!({ - "pkg:npm/twice@1.0.0": patch_record( - "64646464-6464-4464-8464-646464646464", - json!({ "package/index.js": { - "beforeHash": git_sha256(MM_BEFORE), - "afterHash": git_sha256(MM_AFTER), - }}), - ) - }), - ); - stage_blob(tmp.path(), &git_sha256(MM_AFTER), MM_AFTER); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!(code, 0, "the wet apply must succeed; stderr={stderr}"); - assert_eq!( - std::fs::read(&file).unwrap(), - MM_AFTER, - "the wet apply must patch the file" - ); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--dry-run"], &[]); - assert_eq!( - code, 0, - "the dry-run re-check must succeed; stderr={stderr}" - ); - assert!( - stdout.contains("Patch verification complete:"), - "stdout={stdout}" - ); - assert!( - stdout.contains("1 package already patched"), - "the no-op package must be counted as already patched; stdout={stdout}" - ); - assert!( - stdout.contains("0 packages can be patched"), - "an already-patched package must not double-count as patchable; stdout={stdout}" - ); -} - -/// `--verbose` detailed verification: the already-patched / hash-mismatch / -/// not-found labels, the per-file `message:` line, and the `expected:` -/// hash line — pinned with three single-file packages so `--strict`'s -/// first-failure early return stays deterministic. -#[test] -fn verbose_dry_run_prints_per_file_labels_and_hashes() { - let tmp = tempfile::tempdir().unwrap(); - write_root_package_json(tmp.path()); - // Already at afterHash. - install_npm_pkg(tmp.path(), "valready", "1.0.0", MM_AFTER); - // Matches neither hash. - install_npm_pkg(tmp.path(), "vmismatch", "1.0.0", MM_LOCAL); - // Package present, patched file missing. - let missing_dir = tmp.path().join("node_modules").join("vmissing"); - std::fs::create_dir_all(&missing_dir).unwrap(); - std::fs::write( - missing_dir.join("package.json"), - r#"{ "name": "vmissing", "version": "1.0.0" }"#, - ) - .unwrap(); - - let before = git_sha256(MM_BEFORE); - let after = git_sha256(MM_AFTER); - let files = json!({ "package/index.js": { "beforeHash": before, "afterHash": after } }); - write_manifest( - tmp.path(), - json!({ - "pkg:npm/valready@1.0.0": - patch_record("65656565-6565-4565-8565-656565656565", files.clone()), - "pkg:npm/vmismatch@1.0.0": - patch_record("66666666-6666-4666-8666-666666666666", files.clone()), - "pkg:npm/vmissing@1.0.0": - patch_record("67676767-6767-4767-8767-676767676767", files), - }), - ); - stage_blob(tmp.path(), &after, MM_AFTER); - - let (code, stdout, stderr) = run_apply( - tmp.path(), - &["--offline", "--dry-run", "--verbose", "--strict"], - &[], - ); - assert_eq!( - code, 1, - "strict must fail the mismatched and missing packages; stderr={stderr}" - ); - assert!(stdout.contains("Detailed verification:"), "stdout={stdout}"); - assert!( - stdout.contains("package/index.js [already patched]"), - "the already-patched label must render; stdout={stdout}" - ); - assert!( - stdout.contains("package/index.js [hash mismatch]"), - "the hash-mismatch label must render; stdout={stdout}" - ); - assert!( - stdout.contains("package/index.js [not found]"), - "the not-found label must render; stdout={stdout}" - ); - assert!( - stdout.contains("message: File hash does not match expected value"), - "the mismatch engine message must render; stdout={stdout}" - ); - assert!( - stdout.contains("message: File not found"), - "the not-found engine message must render; stdout={stdout}" - ); - assert!( - stdout.contains(&format!("expected: {before}")), - "the expected-hash line must render for the mismatch; stdout={stdout}" - ); - // The npm-branch per-package human failure lines ride along. - assert!( - stderr.contains("Failed to patch pkg:npm/vmismatch@1.0.0"), - "stderr={stderr}" - ); - assert!( - stderr.contains("Failed to patch pkg:npm/vmissing@1.0.0"), - "stderr={stderr}" - ); -} - -/// A pnpm store layout gets the informational stderr note in human mode -/// (json/silent mute it). -#[test] -fn pnpm_layout_prints_informational_note_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - std::fs::create_dir_all(tmp.path().join("node_modules").join(".pnpm")).unwrap(); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!(code, 0, "stderr={stderr}"); - assert!( - stderr.contains("Note: pnpm layout detected."), - "the pnpm layout note must print on human stderr; stderr={stderr}" - ); - assert!( - stderr.contains("Copy-on-write will keep the global store untouched."), - "the note must explain the CoW guarantee; stderr={stderr}" - ); - assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); -} - -/// The Bun layout arm — never executed by any test before: a `bun.lock` + -/// `node_modules/` tree routes to the Bun match arm and prints its -/// informational note (non-fatal, exit 0). -#[test] -fn bun_layout_prints_informational_note_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); - std::fs::write(tmp.path().join("bun.lock"), "{}\n").unwrap(); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!( - code, 0, - "the bun note is informational only; stderr={stderr}" - ); - assert!( - stderr.contains("Note: bun layout detected."), - "the bun layout note must print on human stderr; stderr={stderr}" - ); - assert!( - stderr.contains("Copy-on-write will keep ~/.bun/install/cache/ untouched."), - "the note must explain the CoW guarantee; stderr={stderr}" - ); - assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); -} - -/// A vlt install (the `node_modules/.vlt` store) prints its informational -/// note in human mode and never refuses. vlt install state outranks a -/// sibling `bun.lock`, so the bun note does not print. -#[test] -fn vlt_layout_prints_informational_note_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - std::fs::create_dir_all(tmp.path().join("node_modules").join(".vlt")).unwrap(); - std::fs::write(tmp.path().join("bun.lock"), "{}\n").unwrap(); - - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!( - code, 0, - "the vlt note is informational only; stderr={stderr}" - ); - assert!( - stderr.contains( - "Note: vlt layout detected. Copy-on-write keeps vlt's shared package store \ - (/store/v1) untouched." - ), - "the vlt layout note must print on human stderr; stderr={stderr}" - ); - assert!(!stderr.contains("bun layout detected"), "stderr={stderr}"); - assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); -} - -/// The vlt note is human-only: `--json` and `--silent` runs (the install -/// hook runs `apply --silent`) stay quiet. The hidden lock alone is vlt -/// install state too. -#[test] -fn vlt_layout_note_is_muted_under_json_and_silent() { - let tmp = tempfile::tempdir().unwrap(); - write_manifest(tmp.path(), json!({})); - std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); - std::fs::write(tmp.path().join("node_modules/.vlt-lock.json"), "{}").unwrap(); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!(code, 0, "stderr={stderr}"); - assert!( - stderr.contains("Note: vlt layout detected."), - "stderr={stderr}" - ); - for flag in ["--json", "--silent"] { - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline", flag], &[]); - assert_eq!(code, 0, "{flag}: stderr={stderr}"); - assert!( - !stderr.contains("layout detected"), - "{flag}: stderr={stderr}" - ); - } -} - -/// A corrupt manifest under a yarn-PnP layout must fall through to the -/// ordinary manifest-unreadable error — NOT the misdirected -/// `yarn_pnp_unsupported` refusal (`manifest_targets_npm`'s `_ => false` -/// arm). Anti-vacuity: the same layout WITH a valid npm-targeting manifest -/// does refuse. -#[test] -fn corrupt_manifest_under_pnp_layout_reports_manifest_error_not_refusal() { - // (a) corrupt manifest → ordinary manifest error. - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join(".socket")).unwrap(); - std::fs::write(tmp.path().join(".socket/manifest.json"), "{ not json").unwrap(); - std::fs::write(tmp.path().join(".pnp.cjs"), "// pnp loader stub\n").unwrap(); - - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!(code, 1, "a corrupt manifest must fail; stderr={stderr}"); - assert!( - stderr.contains("Error"), - "the manifest error must be reported; stderr={stderr}" - ); - assert!( - !stderr.contains("Plug'n'Play"), - "an unreadable manifest must not be misreported as a PnP refusal; stderr={stderr}" - ); - - // (b) anti-vacuity: same layout, valid npm-targeting manifest → the - // PnP refusal fires (proving the layout marker is live in fixture (a)). - let tmp = tempfile::tempdir().unwrap(); - write_manifest( - tmp.path(), - json!({ - "pkg:npm/blocked@1.0.0": patch_record( - "68686868-6868-4868-8868-686868686868", - json!({ "package/index.js": { - "beforeHash": git_sha256(MM_BEFORE), - "afterHash": git_sha256(MM_AFTER), - }}), - ) - }), - ); - std::fs::write(tmp.path().join(".pnp.cjs"), "// pnp loader stub\n").unwrap(); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); - assert_eq!(code, 1, "PnP + npm patch must refuse; stderr={stderr}"); - assert!( - stderr.contains("Plug'n'Play layout is not supported"), - "the refusal must fire when the manifest IS readable and targets npm; stderr={stderr}" - ); - - // (c) `--check` refuses the same tree instead of reading every hidden - // npm copy as not installed and exiting 0. - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--check"], &[]); - assert_eq!( - code, 1, - "PnP + npm patch must refuse --check; stdout={stdout} stderr={stderr}" - ); - assert!( - stderr.contains("Plug'n'Play layout is not supported"), - "--check must run apply's PnP refusal; stderr={stderr}" - ); -} - -// ═══════════ 5. gem fallback-home skip on human stderr ═══════════ - -/// Unix-only: the fallback home comes from a fake `gem` binary on PATH -/// (the `in_process_gem_fallback_home.rs` fixture shape, re-run in HUMAN -/// mode — every prior run passed `--json`, so the stderr warning line had -/// never printed). -#[cfg(unix)] -mod gem_fallback_home_human { - use super::*; - - const QUALIFIED_PURL: &str = "pkg:gem/rack@3.1.0?platform=ruby"; - const ORIGINAL: &[u8] = b"module Rack\n VERSION = 'VULNERABLE'\nend\n"; - const MARKER: &[u8] = b"# SOCKET-PATCHED-FALLBACK\n"; - - fn patched_bytes() -> Vec { - let mut v = ORIGINAL.to_vec(); - v.extend_from_slice(MARKER); - v - } - - /// Fake `gem` answering `env gemdir` with `home`, so the crawler's - /// fallback resolves to exactly one gem home. - fn install_fake_gem(bin_dir: &Path, home: &Path) { - use std::os::unix::fs::PermissionsExt; - let script = format!( - "#!/bin/sh\nif [ \"$1\" = env ] && [ \"$2\" = gemdir ]; then\n printf '%s\\n' \"{}\"\n exit 0\nfi\nexit 1\n", - home.display() - ); - let bin = bin_dir.join("gem"); - std::fs::write(&bin, script).unwrap(); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - - /// Stage a gem copy (`/gems/rack-3.1.0/lib/rack.rb` = `bytes`) - /// plus the `specifications/` marker; returns the staged file path. - fn stage_copy(root: &Path, bytes: &[u8]) -> PathBuf { - let file = root - .join("gems") - .join("rack-3.1.0") - .join("lib") - .join("rack.rb"); - std::fs::create_dir_all(file.parent().unwrap()).unwrap(); - std::fs::write(&file, bytes).unwrap(); - std::fs::create_dir_all(root.join("specifications")).unwrap(); - file - } - - /// Human-mode apply with the hermetic SOCKET_* env and BUNDLE_* - /// scrubbed, PATH = the fake-gem bin dir only, BUNDLE_PATH = the store - /// root. - fn run_apply_human(project: &Path, bin_dir: &Path, store_root: &Path) -> (i32, String, String) { - let mut cmd = common::hermetic_command(&common::binary()); - cmd.args(["apply", "--offline", "--ecosystems", "gem", "--cwd"]) - .arg(project); - common::hermetic::scrub_extra(&mut cmd, &[common::hermetic::Extra::Bundle]); - cmd.env("PATH", bin_dir); - cmd.env("BUNDLE_PATH", store_root); - let out = cmd.output().expect("run socket-patch apply"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) - } - - /// Store copy patched + home copy matching NO variant: human mode - /// prints the per-copy `Warning (gem_fallback_home_skipped):` stderr - /// line naming the copy path — non-fatal (exit 0), store copy patched, - /// home copy untouched. - #[test] - fn mismatched_fallback_home_copy_warns_on_human_stderr() { - let foreign = b"totally different bytes\n"; - let project = tempfile::tempdir().unwrap(); - let root = project.path(); - std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap(); - - let store_root = root.join("bundle-store"); - let store_file = stage_copy(&store_root, ORIGINAL); - let home = root.join("gem-home"); - let home_file = stage_copy(&home, foreign); - let bin_dir = root.join("fake-bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - install_fake_gem(&bin_dir, &home); - - let before_hash = git_sha256(ORIGINAL); - let after_hash = git_sha256(&patched_bytes()); - write_manifest( - root, - json!({ - QUALIFIED_PURL: patch_record( - "69696969-6969-4969-8969-696969696969", - json!({ "lib/rack.rb": { - "beforeHash": before_hash, - "afterHash": after_hash, - }}), - ) - }), - ); - stage_blob(root, &after_hash, &patched_bytes()); - - let (code, _stdout, stderr) = run_apply_human(root, &bin_dir, &store_root); - assert_eq!( - code, 0, - "a mismatched fallback-home copy must not fail the run; stderr={stderr}" - ); - assert!( - stderr.contains("Warning: ") && stderr.contains("was not patched"), - "the best-effort skip must print its human warning; stderr={stderr}" - ); - assert!( - stderr.contains("gem-env home copy at"), - "the warning must describe the skipped copy; stderr={stderr}" - ); - assert!( - stderr.contains(&home.display().to_string()), - "the warning must name the fallback-home path; stderr={stderr}" - ); - assert!( - stderr.contains("bundle-path copy — the one bundler loads — is patched"), - "the warning must explain why the skip is safe; stderr={stderr}" - ); - assert_eq!( - std::fs::read(&store_file).unwrap(), - patched_bytes(), - "the bundle-store copy must be patched" - ); - assert_eq!( - std::fs::read(&home_file).unwrap(), - foreign, - "the mismatched home copy must be left untouched" - ); - } -} - -// ═══════════ 6. apply-loop wiring: vendored base skip + new-file-only variant ═══════════ - -const GEM_BASE_PURL: &str = "pkg:gem/rack@3.1.0"; -const GEM_PRISTINE: &[u8] = b"module Rack\n VERSION = '3.1.0'\nend\n"; - -/// A vendored release-variant base WITH its installed tree present must be -/// skipped by the apply loop — only the synthesized `Skipped`/`vendored` -/// event, no re-patch of the installed bytes, no `package_not_installed` -/// noise. (Prior vendored-purl tests only covered the npm-branch skip or -/// an absent installed tree.) -#[test] -fn vendored_gem_base_with_installed_tree_is_skipped_not_repatched() { - let patched = { - let mut v = GEM_PRISTINE.to_vec(); - v.extend_from_slice(b"# SOCKET-VENDORED-PATCH\n"); - v - }; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let file = install_gem(root, "rack-3.1.0", "lib/rack.rb", GEM_PRISTINE); - let uuid = "70707070-7070-4070-8070-707070707070"; - write_manifest( - root, - json!({ - GEM_BASE_PURL: patch_record( - uuid, - json!({ "lib/rack.rb": { - "beforeHash": git_sha256(GEM_PRISTINE), - "afterHash": git_sha256(&patched), - }}), - ) - }), - ); - stage_blob(root, &git_sha256(&patched), &patched); - // Vendor ledger claiming the purl — the entries shape from - // `in_process_rollback_vendored.rs`. - let vendor_dir = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - std::fs::write( - vendor_dir.join("state.json"), - serde_json::to_vec_pretty(&json!({ - "version": 1, - "entries": { - GEM_BASE_PURL: { - "ecosystem": "gem", - "basePurl": GEM_BASE_PURL, - "uuid": uuid, - "artifact": { "path": format!(".socket/vendor/gem/{uuid}/rack-3.1.0.gem") }, - "wiring": [] - } - } - })) - .unwrap(), - ) - .unwrap(); - - let (code, stdout, stderr) = - run_apply(root, &["--offline", "--ecosystems", "gem", "--json"], &[]); - let env = parse_json_envelope(stdout.trim()); - assert_eq!( - code, 0, - "a vendor-owned base is a clean skip; envelope={env}\nstderr={stderr}" - ); - assert_eq!(env["status"], "success", "envelope: {env}"); - let events = env["events"].as_array().expect("events array"); - assert_eq!( - events.len(), - 1, - "exactly one synthesized event for the vendored base: {env}" - ); - assert_eq!(events[0]["action"], "skipped", "envelope: {env}"); - assert_eq!(events[0]["errorCode"], "vendored", "envelope: {env}"); - assert_eq!(events[0]["purl"], GEM_BASE_PURL, "envelope: {env}"); - assert!( - !events - .iter() - .any(|e| e["errorCode"] == "package_not_installed"), - "a vendored base must never be reported as not installed: {env}" - ); - assert_eq!( - std::fs::read(&file).unwrap(), - GEM_PRISTINE, - "the loop-level skip must prevent a re-patch of the installed tree" - ); -} - -/// Vendored mode is manifest-free: a project vendored by `scan`/`get --mode -/// vendored` has ONLY `.socket/vendor/state.json` (detached entries with -/// embedded records) and no manifest. The hooked `apply` on such a project -/// is the calm `noManifest` no-op — it never reads the ledger, never takes -/// the lock (so never creates `apply.lock`) and leaves `.socket/` exactly as -/// it found it: the committed artifacts ARE the patch. -#[test] -fn apply_on_ledger_only_vendored_project_is_a_no_manifest_no_op() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - write_root_package_json(root); - let uuid = "80808080-8080-4080-8080-808080808080"; - let vendor_dir = root.join(".socket/vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - let ledger = serde_json::to_vec_pretty(&json!({ - "version": 1, - "entries": { "pkg:npm/vend-only@1.0.0": { - "ecosystem": "npm", - "basePurl": "pkg:npm/vend-only@1.0.0", - "uuid": uuid, - "artifact": { "path": format!(".socket/vendor/npm/{uuid}/vend-only-1.0.0.tgz") }, - "wiring": [], - "detached": true, - "record": patch_record( - uuid, - json!({ "package/index.js": { - "beforeHash": git_sha256(MM_BEFORE), - "afterHash": git_sha256(MM_AFTER), - }}), - ), - }} - })) - .unwrap(); - std::fs::write(vendor_dir.join("state.json"), &ledger).unwrap(); - - let (code, stdout, stderr) = run_apply(root, &["--json"], &[]); - let env = parse_json_envelope(stdout.trim()); - assert_eq!(code, 0, "envelope={env}\nstderr={stderr}"); - assert_eq!(env["status"], "noManifest", "envelope: {env}"); - assert_eq!(env["events"], json!([]), "envelope: {env}"); - - let mut names: Vec = std::fs::read_dir(root.join(".socket")) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .collect(); - names.sort(); - assert_eq!( - names, - vec!["vendor"], - "apply must not touch .socket/ on a manifest-less project (no manifest, \ - no blobs/, no apply.lock)" - ); - assert_eq!( - std::fs::read(vendor_dir.join("state.json")).unwrap(), - ledger, - "the ledger survives byte-identical" - ); -} - -/// A QUALIFIED gem singleton whose record holds only NEW files (empty -/// `beforeHash` ⇒ no representative file) has nothing to disqualify it: -/// the gated apply loop must treat it as installed, attempt it, and -/// CREATE the new file from the staged blob. -#[test] -fn qualified_singleton_with_only_new_files_is_attempted_and_applied() { - const QUALIFIED_PURL: &str = "pkg:gem/rack@3.1.0?platform=ruby"; - const SHIM: &[u8] = b"# security shim injected by socket-patch\n"; - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - // Installed gem WITHOUT the new file (lib/rack.rb only anchors the - // crawler's discovery — it is not in the record). - let gem_file = install_gem(root, "rack-3.1.0", "lib/rack.rb", GEM_PRISTINE); - let shim_path = gem_file.parent().unwrap().join("new_shim.rb"); - assert!(!shim_path.exists()); - let after_hash = git_sha256(SHIM); - write_manifest( - root, - json!({ - QUALIFIED_PURL: patch_record( - "71717171-7171-4171-8171-717171717171", - json!({ "lib/new_shim.rb": { - "beforeHash": "", - "afterHash": after_hash, - }}), - ) - }), - ); - stage_blob(root, &after_hash, SHIM); - - let (code, stdout, stderr) = - run_apply(root, &["--offline", "--ecosystems", "gem", "--json"], &[]); - let env = parse_json_envelope(stdout.trim()); - assert_eq!( - code, 0, - "a no-representative variant must apply cleanly; envelope={env}\nstderr={stderr}" - ); - assert_eq!(env["status"], "success", "envelope: {env}"); - let events = env["events"].as_array().expect("events array"); - assert!( - events - .iter() - .any(|e| e["action"] == "applied" && e["purl"] == QUALIFIED_PURL), - "the qualified singleton must be reported applied: {env}" - ); - assert_eq!( - std::fs::read(&shim_path).unwrap(), - SHIM, - "the new file must be created with the staged blob's bytes" - ); - assert_eq!( - std::fs::read(&gem_file).unwrap(), - GEM_PRISTINE, - "the untouched anchor file must stay pristine" - ); -} - -// ═══════════ 7. dry-run --vex skip message ═══════════ - -/// `apply --dry-run --vex ` in human mode: nothing was applied, so -/// VEX generation is skipped WITH the explanatory message, and no -/// attestation file is written. -#[test] -fn dry_run_with_vex_skips_generation_and_writes_no_file() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let file = install_gem(root, "rack-3.1.0", "lib/rack.rb", GEM_PRISTINE); - let patched = { - let mut v = GEM_PRISTINE.to_vec(); - v.extend_from_slice(b"# SOCKET-VEX-PATCH\n"); - v - }; - write_manifest( - root, - json!({ - GEM_BASE_PURL: patch_record( - "72727272-7272-4272-8272-727272727272", - json!({ "lib/rack.rb": { - "beforeHash": git_sha256(GEM_PRISTINE), - "afterHash": git_sha256(&patched), - }}), - ) - }), - ); - stage_blob(root, &git_sha256(&patched), &patched); - let vex_path = root.join("out.vex.json"); - - let (code, stdout, stderr) = run_apply( - root, - &[ - "--offline", - "--ecosystems", - "gem", - "--dry-run", - "--vex", - vex_path.to_str().unwrap(), - ], - &[], - ); - assert_eq!(code, 0, "the dry-run verify must succeed; stderr={stderr}"); - assert!( - stdout.contains("Skipping VEX generation (--dry-run: nothing was applied)."), - "the skip must be announced; stdout={stdout}" - ); - assert!( - !vex_path.exists(), - "a dry run must never write an attestation file" - ); - assert_eq!( - std::fs::read(&file).unwrap(), - GEM_PRISTINE, - "a dry run must not modify the package" - ); -} diff --git a/crates/socket-patch-cli/tests/apply/main.rs b/crates/socket-patch-cli/tests/apply/main.rs index 2ed840f61..2abb0ab98 100644 --- a/crates/socket-patch-cli/tests/apply/main.rs +++ b/crates/socket-patch-cli/tests/apply/main.rs @@ -1,4 +1,6 @@ -//! `apply`: invariants, network behavior, silent/exit modes and the gem and npm multi-copy apply paths. +//! `apply`: invariants, network behavior, silent/exit modes, the `--json` +//! failure paths and their human render checks, and the gem and npm +//! multi-copy apply paths. //! //! One test binary per command: each module was its own binary. @@ -11,12 +13,15 @@ mod vlt_hosted_common; #[path = "../vlt_hosted_common/vendored.rs"] mod vlt_vendored; +mod apply_failure_paths; +mod apply_fixture; mod apply_invariants; mod apply_network; +mod apply_render; +mod apply_silent_staging_errors; mod bun_global_store; mod check_verifies_installed_tree; mod cli_gem_variant_mismatch_policy; -mod covgap_commands_apply; mod e2e_safety_advisories; mod gem_stale_bundler_plugin; mod in_process_gem_config_warning; 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 7a3e20860..5d5312c25 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 @@ -13,7 +13,7 @@ //! prints exclusively under `--json`. //! //! Same class fixed for the DISK stager's arms in -//! `coverage_fix_apply_silent_mute_exit.rs`; the offline arm is shared +//! `apply/apply_silent_staging_errors.rs`; the offline arm is shared //! (`report_offline_missing`) and is pinned here for vendor too. //! //! Under `--json` the diagnostics stay off stderr — the envelope is the diff --git a/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs b/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs index f1d559a21..d64eae552 100644 --- a/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs +++ b/crates/socket-patch-cli/tests/scan/covgap_commands_fetch_stage.rs @@ -13,7 +13,7 @@ //! //! The `--silent` variants of the offline / download-failure diagnostics //! (errors only, never nothing) are pinned by -//! `coverage_fix_apply_silent_mute_exit.rs`; this suite covers the +//! `apply/apply_silent_staging_errors.rs`; this suite covers the //! human-mode (non-quiet) output shape and the mem-stager arms. use std::path::Path; diff --git a/scripts/ci-test-durations.json b/scripts/ci-test-durations.json index 61e0ab69c..65ef21471 100644 --- a/scripts/ci-test-durations.json +++ b/scripts/ci-test-durations.json @@ -10,7 +10,7 @@ "targets": { "api_retry_e2e": 2.01, "api_timeout_e2e": 4.29, - "apply": 10.48, + "apply": 14.53, "binary_fetch_error_classification_e2e": 0.01, "blob_fetcher_edges_e2e": 2.06, "child_deadline_guard": 0.25, @@ -36,7 +36,6 @@ "cli_sigpipe": 0.0, "command_module_layering": 0.11, "contract_gradle_codes": 0.93, - "coverage_fix_apply_silent_mute_exit": 4.05, "coverage_fix_rollback_ecosystem_scoped_hosted": 0.09, "coverage_fix_scan_hosted_dryrun_vendored": 1.61, "coverage_fix_vendor_silent_mute_exit": 7.4, From 4d90f26f06af929607cce3275c9247be81ad3cbb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:11:56 +0000 Subject: [PATCH 3/3] Apply suite: review fixes for the split The orphan-replace --json test no longer reads the drift sentence; the module-keyed event already says which arm fired. The reconcile tests pin that the envelope carries no event for a pruned redirect, the bun and vlt render tests again check the run falls through to "No patches to apply.", and the silent staging tests reuse the suite's runner and manifest writer instead of private copies. Refs #1089. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/apply/apply_failure_paths.rs | 23 ++++---- .../tests/apply/apply_fixture.rs | 4 +- .../tests/apply/apply_render.rs | 6 ++- .../apply/apply_silent_staging_errors.rs | 52 ++++++------------- 4 files changed, 32 insertions(+), 53 deletions(-) diff --git a/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs b/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs index 30fcfbcd5..263ce1ca6 100644 --- a/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs +++ b/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs @@ -21,15 +21,11 @@ use serde_json::json; use crate::apply_fixture::*; use crate::common::envelope::{ - envelope_error_code, event_triples, events, find_event, has_event, json_string, + envelope_error_code, event_purls, event_triples, events, find_event, has_event, json_string, parse_json_envelope, }; use crate::common::git_sha256; -/// The `reason` of an `OrphanReplace` drift event: it shares -/// `go_redirect_drift` with the other drift arms. -const ORPHAN_REPLACE: &str = "orphan go.mod `replace`"; - // ═══════════════════ 1. `--check --json` envelopes ═══════════════════ /// `apply --check --json` WITH drift: one Failed event per drift with @@ -49,9 +45,9 @@ fn check_json_with_drift_emits_failed_events_envelope() { !triples.is_empty(), "drift must be reported as events: {env}" ); - for (purl, action, code) in triples { + for (purl, action, err_code) in triples { assert_eq!( - (purl.as_str(), action.as_str(), code.as_str()), + (purl.as_str(), action.as_str(), err_code.as_str()), (GO_PURL, "failed", "go_redirect_drift"), "{env}" ); @@ -73,7 +69,8 @@ fn check_json_in_sync_emits_success_envelope() { } /// A socket-owned `replace` for a module the manifest no longer patches: -/// the `OrphanReplace` arm. Its event is keyed by the module, not a purl. +/// the `OrphanReplace` arm, the only drift keyed by the module instead of +/// a purl. #[test] fn check_reports_orphan_replace_drift() { let tmp = tempfile::tempdir().unwrap(); @@ -97,8 +94,6 @@ fn check_reports_orphan_replace_drift() { )], "{env}" ); - let reason = events(&env)[0]["reason"].as_str().unwrap_or_default(); - assert!(reason.contains(ORPHAN_REPLACE), "{env}"); } /// A healthy copy but a socket-owned `replace` pinned at the WRONG target @@ -169,6 +164,9 @@ fn reconcile_prunes_stale_go_redirect() { ); let env = parse_json_envelope(stdout.trim()); assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + // The pruning is announced only in human mode (`apply_render.rs`); + // the envelope carries no event for it. + assert!(events(&env).is_empty(), "{env}"); let go_mod = std::fs::read_to_string(tmp.path().join("go.mod")).unwrap(); assert!( !go_mod.contains("replace"), @@ -194,6 +192,7 @@ fn reconcile_dry_run_touches_nothing() { ); let env = parse_json_envelope(stdout.trim()); assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); + assert!(events(&env).is_empty(), "{env}"); assert_eq!( std::fs::read_to_string(tmp.path().join("go.mod")).unwrap(), original_go_mod, @@ -499,9 +498,7 @@ fn qualified_singleton_with_only_new_files_is_attempted_and_applied() { ); assert_eq!(json_string(&env, "status"), Some("success"), "{env}"); assert!( - events(&env) - .iter() - .any(|e| e["action"] == "applied" && e["purl"] == GEM_QUALIFIED_PURL), + event_purls(&env, "applied").contains(&GEM_QUALIFIED_PURL.to_string()), "the qualified singleton must be reported applied: {env}" ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/apply/apply_fixture.rs b/crates/socket-patch-cli/tests/apply/apply_fixture.rs index 142b296a4..2f9b11738 100644 --- a/crates/socket-patch-cli/tests/apply/apply_fixture.rs +++ b/crates/socket-patch-cli/tests/apply/apply_fixture.rs @@ -48,8 +48,8 @@ pub fn one_file(path: &str, before: &[u8], after: &[u8]) -> Value { json!({ path: { "beforeHash": git_sha256(before), "afterHash": git_sha256(after) } }) } -/// Write `.socket/manifest.json` (creating `.socket/blobs/` alongside so -/// blob-emptiness assertions have a stable target). +/// Write `.socket/manifest.json`, creating `.socket/blobs/` alongside as +/// a real project has it. pub fn write_manifest(root: &Path, patches: Value) { let socket = root.join(".socket"); std::fs::create_dir_all(socket.join("blobs")).expect("create .socket/blobs"); diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs index 48b89111d..052d22914 100644 --- a/crates/socket-patch-cli/tests/apply/apply_render.rs +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -204,7 +204,7 @@ fn bun_layout_prints_informational_note() { std::fs::create_dir_all(tmp.path().join("node_modules")).unwrap(); std::fs::write(tmp.path().join("bun.lock"), "{}\n").unwrap(); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); assert_eq!( code, 0, "the bun note is informational only; stderr={stderr}" @@ -215,6 +215,7 @@ fn bun_layout_prints_informational_note() { ), "stderr={stderr}" ); + assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); } /// A vlt install (the `node_modules/.vlt` store) prints its note and never @@ -227,7 +228,7 @@ fn vlt_layout_prints_informational_note_in_human_mode() { std::fs::create_dir_all(tmp.path().join("node_modules").join(".vlt")).unwrap(); std::fs::write(tmp.path().join("bun.lock"), "{}\n").unwrap(); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); assert_eq!( code, 0, "the vlt note is informational only; stderr={stderr}" @@ -240,6 +241,7 @@ fn vlt_layout_prints_informational_note_in_human_mode() { "stderr={stderr}" ); assert!(!stderr.contains("bun layout detected"), "stderr={stderr}"); + assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); } /// The layout notes are human-only: `--json` and `--silent` runs (the diff --git a/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs b/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs index c069ff490..467d22830 100644 --- a/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs +++ b/crates/socket-patch-cli/tests/apply/apply_silent_staging_errors.rs @@ -22,22 +22,10 @@ use std::path::Path; -use crate::common::envelope::{json_string, parse_json_envelope}; -use crate::common::{binary, hermetic_command}; +use serde_json::json; -/// Run `socket-patch apply` in `cwd` with a scrubbed SOCKET_* environment -/// so ambient developer/CI configuration (tokens, silent toggles) can't -/// change the branch under test. -fn run_apply(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = hermetic_command(&binary()); - cmd.arg("apply").args(args).current_dir(cwd); - let out = cmd.output().expect("run socket-patch apply"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) -} +use crate::apply_fixture::{patch_record, run_apply, write_manifest}; +use crate::common::envelope::{json_string, parse_json_envelope}; /// Non-error stderr lines: drop the unconditional core API-token warning /// (both its lead line and its "Got: ... Continuing anyway" continuation) @@ -55,26 +43,17 @@ fn stderr_chatter(stderr: &str) -> Vec { } /// Valid manifest with one npm patch entry and NO blob/diff/package -/// artifact anywhere under `.socket/` — staging has no usable source. +/// artifact anywhere under `.socket/`: staging has no usable source. fn write_sourceless_manifest(root: &Path) { - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).unwrap(); - std::fs::write( - socket.join("manifest.json"), - r#"{ "patches": { - "pkg:npm/left-pad@1.3.0": { - "uuid": "11111111-1111-4111-8111-111111111111", - "exportedAt": "2026-01-01T00:00:00Z", - "files": { "index.js": { - "beforeHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "afterHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }}, - "vulnerabilities": {}, "description": "x", - "license": "MIT", "tier": "free" - } - }}"#, - ) - .unwrap(); + write_manifest( + root, + json!({ + "pkg:npm/left-pad@1.3.0": patch_record( + "11111111-1111-4111-8111-111111111111", + json!({ "index.js": { "beforeHash": "a".repeat(64), "afterHash": "b".repeat(64) } }), + ) + }), + ); } /// A guaranteed-unreachable local endpoint: bind an ephemeral port, then @@ -97,7 +76,7 @@ fn apply_silent_offline_missing_source_keeps_error_output() { let tmp = tempfile::tempdir().unwrap(); write_sourceless_manifest(tmp.path()); - let (code, stdout, stderr) = run_apply(tmp.path(), &["--silent", "--offline"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--silent", "--offline"], &[]); assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); assert!( stdout.trim().is_empty(), @@ -133,6 +112,7 @@ fn apply_silent_online_download_failure_keeps_error_output() { "--org", "test-org", ], + &[], ); assert_eq!(code, 1, "failed downloads + no source must fail: {stderr}"); assert!( @@ -163,7 +143,7 @@ fn apply_json_offline_missing_source_keeps_stderr_clean() { let tmp = tempfile::tempdir().unwrap(); write_sourceless_manifest(tmp.path()); - let (code, stdout, stderr) = run_apply(tmp.path(), &["--json", "--offline"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--json", "--offline"], &[]); assert_eq!(code, 1, "offline + no local source must fail: {stderr}"); let v = parse_json_envelope(&stdout); assert_eq!(