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..263ce1ca6 --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/apply_failure_paths.rs @@ -0,0 +1,556 @@ +//! `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_purls, event_triples, events, find_event, has_event, json_string, + parse_json_envelope, +}; +use crate::common::git_sha256; + +// ═══════════════════ 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, err_code) in triples { + assert_eq!( + (purl.as_str(), action.as_str(), err_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, the only drift keyed by the module instead of +/// 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}" + ); +} + +/// 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}"); + // 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"), + "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!(events(&env).is_empty(), "{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!( + event_purls(&env, "applied").contains(&GEM_QUALIFIED_PURL.to_string()), + "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..2f9b11738 --- /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 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"); + 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..052d22914 --- /dev/null +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -0,0 +1,431 @@ +//! `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}" + ); + assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); +} + +/// 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}"); + assert!(stdout.contains("No patches to apply."), "stdout={stdout}"); +} + +/// 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 76% 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..467d22830 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,25 +20,12 @@ //! 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; -/// 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 serde_json::json; + +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) @@ -56,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 @@ -98,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(), @@ -134,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!( @@ -164,12 +143,12 @@ 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: 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,