From 9dae15215fca8a27e155475849a606a2c948e8ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:57:13 +0000 Subject: [PATCH 1/3] Start #1941 apply fixture consolidation Assisted-by: Claude Code:claude-opus-5-5 From ae3de22d0b37f5d659a1d615ee51aac3ea92c742 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:02:10 +0000 Subject: [PATCH 2/3] Build tests/apply fixtures through apply_fixture Nine suites of the apply test binary wrote their own manifest, blob, npm package, gem and event-lookup helpers beside apply_fixture's. They now call apply_fixture (one_file/hashed_file, write_manifest, stage_blob, install_npm_pkg, install_gem, write_gem_patch, link_dir) and common::envelope::event_for. stage_blob writes through common::write_blob. The four gem runners share apply_fixture::run_gem, so gem_stale_bundler_plugin's child now gets the BUNDLE_* scrub the others had. The fallback-home human test moves next to its --json twin and reuses its fixture. apply_invariants compares .socket/ snapshots instead of a digest plus two byte checks. The explicit SOCKET_TELEMETRY_DISABLED=1 pairs, which hermetic::command forces, are gone. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/apply/apply_fixture.rs | 59 +++- .../tests/apply/apply_invariants.rs | 301 ++++-------------- .../tests/apply/apply_network.rs | 215 ++++--------- .../tests/apply/apply_render.rs | 107 ------- .../tests/apply/bun_global_store.rs | 90 ++---- .../tests/apply/cargo_lock_source.rs | 60 +--- .../apply/check_verifies_installed_tree.rs | 90 ++---- .../tests/apply/gem_stale_bundler_plugin.rs | 39 +-- .../apply/in_process_gem_config_warning.rs | 61 +--- .../apply/in_process_gem_fallback_home.rs | 97 +++--- .../tests/apply/lockfile_only_skip.rs | 143 +++------ .../tests/apply/pnpm_global_virtual_store.rs | 100 ++---- .../socket-patch-cli/tests/common/envelope.rs | 9 + 13 files changed, 377 insertions(+), 994 deletions(-) diff --git a/crates/socket-patch-cli/tests/apply/apply_fixture.rs b/crates/socket-patch-cli/tests/apply/apply_fixture.rs index 68c09fb76..64df44f8c 100644 --- a/crates/socket-patch-cli/tests/apply/apply_fixture.rs +++ b/crates/socket-patch-cli/tests/apply/apply_fixture.rs @@ -1,5 +1,4 @@ -//! Fixtures and runners shared by `apply_failure_paths.rs` (the `--json` -//! contracts) and `apply_render.rs` (the human output) (#1089). +//! Fixtures and runners shared by every suite of the `apply` test binary. //! //! Binary-driven throughout (`common::run_with_env`, `SOCKET_*`-scrubbed //! children), hand-written camelCase manifests, git-sha256 oracle, @@ -10,7 +9,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; use crate::common::hermetic::{self, Extra}; -use crate::common::{binary, git_sha256, hermetic_command, run_with_env}; +use crate::common::{binary, git_sha256, hermetic_command, jvm_env, run_with_env, write_blob}; 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"; @@ -46,7 +45,12 @@ pub fn patch_record(uuid: &str, files: Value) -> Value { /// 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) } }) + hashed_file(path, &git_sha256(before), &git_sha256(after)) +} + +/// [`one_file`] from the two hashes. +pub fn hashed_file(path: &str, before_hash: &str, after_hash: &str) -> Value { + json!({ path: { "beforeHash": before_hash, "afterHash": after_hash } }) } /// Write `.socket/manifest.json`, creating `.socket/blobs/` alongside as @@ -62,9 +66,7 @@ pub fn write_manifest(root: &Path, patches: Value) { } 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"); + write_blob(&root.join(".socket"), hash, content); } /// Install a fake npm package at `/node_modules/` with the @@ -85,6 +87,25 @@ pub fn install_npm_pkg(root: &Path, name: &str, version: &str, index_js: Option< index } +/// Link `link` to the directory `target`: a symlink on Unix, a junction +/// on Windows. +pub fn link_dir(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + let link: PathBuf = link.components().collect(); + let target: PathBuf = target.components().collect(); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .status() + .unwrap(); + assert!(status.success(), "mklink /J failed"); + } +} + pub fn write_root_package_json(root: &Path) { std::fs::write( root.join("package.json"), @@ -242,17 +263,16 @@ pub const NOKOGIRI_DARWIN_BEFORE: &[u8] = b"# nokogiri.rb from the arm64-darwin pub const NOKOGIRI_LINUX_MARKER: &[u8] = b"\n# SOCKET-LINUX-PATCH\n"; pub const NOKOGIRI_DARWIN_MARKER: &[u8] = b"\n# DARWIN-MARKER\n"; -/// Run `socket-patch apply --offline --ecosystems gem ` in `cwd` -/// with the hermetic `SOCKET_*` environment (`hermetic_command`) and the -/// ambient `BUNDLE_*` scrubbed, so only the argv decides behavior. -pub fn run_gem_apply(cwd: &Path, extra: &[&str]) -> (i32, String, String) { +/// Run `socket-patch ` in `cwd` like `common::run_with_env`, with +/// the ambient `BUNDLE_*` also scrubbed so only the argv and `env` decide +/// what Bundler state the gem crawler reads. +pub fn run_gem(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = hermetic_command(&binary()); - cmd.arg("apply") - .args(["--offline", "--ecosystems", "gem"]) - .args(extra) - .current_dir(cwd); + cmd.args(args).current_dir(cwd); + jvm_env::isolate_cli(&mut cmd); hermetic::scrub_extra(&mut cmd, &[Extra::Bundle]); - let out = cmd.output().expect("run socket-patch apply"); + cmd.envs(env.iter().copied()); + let out = cmd.output().expect("run socket-patch"); ( out.status.code().unwrap_or(-1), String::from_utf8_lossy(&out.stdout).to_string(), @@ -260,6 +280,13 @@ pub fn run_gem_apply(cwd: &Path, extra: &[&str]) -> (i32, String, String) { ) } +/// [`run_gem`] of `apply --offline --ecosystems gem `. +pub fn run_gem_apply(cwd: &Path, extra: &[&str]) -> (i32, String, String) { + let mut argv = vec!["apply", "--offline", "--ecosystems", "gem"]; + argv.extend_from_slice(extra); + run_gem(cwd, &argv, &[]) +} + /// `pkg:gem/nokogiri@1.16.5?platform=`. pub fn nokogiri_purl(platform: &str) -> String { format!("pkg:gem/nokogiri@1.16.5?platform={platform}") diff --git a/crates/socket-patch-cli/tests/apply/apply_invariants.rs b/crates/socket-patch-cli/tests/apply/apply_invariants.rs index ac613dc4f..89939b2d0 100644 --- a/crates/socket-patch-cli/tests/apply/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply/apply_invariants.rs @@ -15,98 +15,33 @@ //! crawler won't match, which trips the "no packages found / offline" //! branches and exercises the invariants without needing a real fixture. -use crate::common::{binary, git_sha256, hermetic_command}; +use std::path::Path; -use std::path::{Path, PathBuf}; - -use sha2::{Digest, Sha256}; - -/// Minimal manifest with one synthetic patch entry. The PURL points at a -/// package that won't be found on disk; the `afterHash` blob is missing -/// from `.socket/blobs/`. This forces every branch we want to test — -/// `--offline` bails out, and the no-mutation invariant holds because -/// nothing actually runs. -const MANIFEST_JSON: &str = r#"{ - "patches": { - "pkg:npm/__invariant_test_pkg__@9.9.9": { - "uuid": "11111111-1111-4111-8111-111111111111", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", - "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" - } - }, - "vulnerabilities": {}, - "description": "synthetic invariant test patch", - "license": "MIT", - "tier": "free" - } - } -}"#; +use serde_json::json; + +use super::apply_fixture::{ + hashed_file, install_npm_pkg, one_file, patch_record, stage_blob, write_manifest, + write_root_package_json, +}; +use crate::common::{binary, git_sha256, hermetic_command}; +/// One synthetic npm patch whose package is not installed and whose +/// `afterHash` blob is missing from `.socket/blobs/`, so `--offline` bails +/// and nothing else runs. `.socket/blobs/` holds a sentinel apply must not +/// touch. fn write_project(root: &Path) { - let socket = root.join(".socket"); - std::fs::create_dir_all(&socket).expect("create .socket"); - std::fs::write(socket.join("manifest.json"), MANIFEST_JSON).expect("write manifest"); - // Pre-create the blobs dir with a sentinel file so the recursive - // hash has something stable to chew on. Apply must not delete or - // alter this file. - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).expect("create blobs dir"); - std::fs::write(blobs.join("sentinel"), b"do not modify me").expect("write sentinel"); - // Empty node_modules so the npm crawler returns nothing. + write_root_package_json(root); std::fs::create_dir_all(root.join("node_modules")).expect("create node_modules"); - // A package.json so the crawler considers this a project root. - std::fs::write( - root.join("package.json"), - r#"{"name":"invariant-test","version":"0.0.0"}"#, - ) - .expect("write package.json"); -} - -/// Recursive, stable hash of every regular file under `dir`. Combines -/// each file's relative path and bytes into a single SHA-256 so any -/// change — adding, removing, or rewriting a file — flips the digest. -/// -/// Nothing is excluded: the advisory `apply.lock` that `apply` takes -/// exists only while the command holds it (the guard unlinks it on -/// exit), so a snapshot taken after the run must not see it either. A -/// lock file surviving a run is itself a regression this hash catches. -fn dir_hash(dir: &Path) -> String { - let mut files: Vec<(PathBuf, Vec)> = Vec::new(); - collect_files(dir, dir, &mut files); - files.sort_by(|a, b| a.0.cmp(&b.0)); - let mut hasher = Sha256::new(); - for (rel, bytes) in files { - hasher.update(rel.to_string_lossy().as_bytes()); - hasher.update(b"\0"); - hasher.update(&bytes); - hasher.update(b"\0"); - } - hex::encode(hasher.finalize()) -} - -fn collect_files(root: &Path, dir: &Path, out: &mut Vec<(PathBuf, Vec)>) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let path = entry.path(); - let file_type = match entry.file_type() { - Ok(t) => t, - Err(_) => continue, - }; - if file_type.is_dir() { - collect_files(root, &path, out); - } else if file_type.is_file() { - let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); - if let Ok(bytes) = std::fs::read(&path) { - out.push((rel, bytes)); - } - } - } + write_manifest( + root, + json!({ + "pkg:npm/__invariant_test_pkg__@9.9.9": patch_record( + "11111111-1111-4111-8111-111111111111", + hashed_file("package/index.js", &"0".repeat(64), &"1".repeat(64)), + ) + }), + ); + stage_blob(root, "sentinel", b"do not modify me"); } fn run_apply(cwd: &Path, extra: &[&str]) -> (i32, String) { @@ -154,59 +89,22 @@ const SCOPED_PATCHED: &[u8] = b"module.exports = function safe() { return 'ok'; /// Used to prove the offline no-local-source guard is scoped to the /// patches the run can actually apply (`--ecosystems` filter). fn write_mixed_scope_project(root: &Path) { - let before = git_sha256(SCOPED_ORIGINAL); - let after = git_sha256(SCOPED_PATCHED); - - std::fs::write( - root.join("package.json"), - r#"{"name":"scope-test","version":"0.0.0"}"#, - ) - .expect("write package.json"); - - let pkg = root.join("node_modules").join("scopedpkg"); - std::fs::create_dir_all(&pkg).expect("create package dir"); - std::fs::write( - pkg.join("package.json"), - r#"{"name":"scopedpkg","version":"1.0.0"}"#, - ) - .expect("write pkg package.json"); - std::fs::write(pkg.join("index.js"), SCOPED_ORIGINAL).expect("write index.js"); - - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).expect("create blobs"); - std::fs::write(socket.join("blobs").join(&after), SCOPED_PATCHED).expect("write blob"); - let manifest = format!( - r#"{{ - "patches": {{ - "{SCOPED_NPM_PURL}": {{ - "uuid": "33333333-3333-4333-8333-333333333333", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ - "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} - }}, - "vulnerabilities": {{}}, - "description": "in-scope npm patch with local sources", - "license": "MIT", - "tier": "free" - }}, - "pkg:pypi/__ghost_pkg__@9.9.9": {{ - "uuid": "44444444-4444-4444-8444-444444444444", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ - "ghost.py": {{ - "beforeHash": "2222222222222222222222222222222222222222222222222222222222222222", - "afterHash": "3333333333333333333333333333333333333333333333333333333333333333" - }} - }}, - "vulnerabilities": {{}}, - "description": "out-of-scope pypi patch with NO local source", - "license": "MIT", - "tier": "free" - }} - }} -}}"# - ); - std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); + write_root_package_json(root); + install_npm_pkg(root, "scopedpkg", "1.0.0", Some(SCOPED_ORIGINAL)); + write_manifest( + root, + json!({ + SCOPED_NPM_PURL: patch_record( + "33333333-3333-4333-8333-333333333333", + one_file("package/index.js", SCOPED_ORIGINAL, SCOPED_PATCHED), + ), + "pkg:pypi/__ghost_pkg__@9.9.9": patch_record( + "44444444-4444-4444-8444-444444444444", + hashed_file("ghost.py", &"2".repeat(64), &"3".repeat(64)), + ), + }), + ); + stage_blob(root, &git_sha256(SCOPED_PATCHED), SCOPED_PATCHED); } /// Regression: the `--offline` no-local-source guard (and the download @@ -311,14 +209,14 @@ fn offline_with_missing_source_emits_partial_failure() { #[test] fn apply_does_not_mutate_socket_dir_offline() { // Even on the failure path (offline + missing source), apply must - // not touch `.socket/`. The directory hash should match exactly. + // not touch `.socket/`: every file's bytes must match exactly. let tmp = tempfile::tempdir().expect("tempdir"); write_project(tmp.path()); let socket = tmp.path().join(".socket"); - let before = dir_hash(&socket); + let before = snapshot_dir(&socket); let (code, stdout) = run_apply(tmp.path(), &["--offline", "--silent"]); - let after = dir_hash(&socket); + let after = snapshot_dir(&socket); // The run must have actually taken the failure path we care about — // otherwise an apply that errored out *before* reaching any write @@ -332,23 +230,7 @@ fn apply_does_not_mutate_socket_dir_offline() { v["status"], "partialFailure", "expected the offline partialFailure path, got {v}" ); - assert_eq!( - before, after, - "apply --offline must not mutate .socket/; hash changed" - ); - // Belt-and-suspenders against a dir_hash blind spot: read the two - // payload files back and confirm they are byte-identical to what - // `write_project` laid down. - assert_eq!( - std::fs::read(socket.join("blobs").join("sentinel")).expect("sentinel survives"), - b"do not modify me", - "apply must not rewrite the blobs sentinel" - ); - assert_eq!( - std::fs::read_to_string(socket.join("manifest.json")).expect("manifest survives"), - MANIFEST_JSON, - "apply must not rewrite manifest.json" - ); + assert_eq!(before, after, "apply --offline must not mutate .socket/"); } #[test] @@ -360,15 +242,12 @@ fn apply_does_not_mutate_socket_dir_when_no_packages_match() { write_project(tmp.path()); let socket = tmp.path().join(".socket"); - let before = dir_hash(&socket); + let before = snapshot_dir(&socket); let (code, stdout) = run_apply(tmp.path(), &["--silent"]); - let after = dir_hash(&socket); + let after = snapshot_dir(&socket); - // Previously this test discarded the result entirely (`let _ = ...`), - // so a build that crashed, hung, exited 0, or wrote garbage to stdout - // would still "pass" as long as it happened not to touch `.socket/`. - // Pin the contract: the no-usable-source run reports partialFailure - // and exits non-zero, AND leaves `.socket/` untouched. + // Pin the bail itself, or a run that died early would leave + // `.socket/` untouched and pass vacuously. assert_eq!( code, 1, "no-match / unfetchable run must exit 1; stdout=\n{stdout}" @@ -399,20 +278,7 @@ fn apply_does_not_mutate_socket_dir_when_no_packages_match() { ); assert_eq!( before, after, - "apply must not mutate .socket/ on the no-match path; hash changed" - ); - assert_eq!( - std::fs::read(socket.join("blobs").join("sentinel")).expect("sentinel survives"), - b"do not modify me", - "apply must not rewrite the blobs sentinel on the no-match path" - ); - // Belt-and-suspenders against a dir_hash blind spot (same as the - // offline test): the manifest must be byte-identical to what - // `write_project` laid down. - assert_eq!( - std::fs::read_to_string(socket.join("manifest.json")).expect("manifest survives"), - MANIFEST_JSON, - "apply must not rewrite manifest.json on the no-match path" + "apply must not mutate .socket/ on the no-match path" ); } @@ -538,60 +404,23 @@ fn apply_with_unreadable_socket_dir_fails_closed() { /// - `scopedpkg@1.0.0` is installed on disk and fully applicable; /// - `__ghost_pkg__@9.9.9` matches nothing on disk. fn write_partial_match_project(root: &Path) { - let before = git_sha256(SCOPED_ORIGINAL); - let after = git_sha256(SCOPED_PATCHED); - let ghost_after = git_sha256(b"ghost patched\n"); - - std::fs::write( - root.join("package.json"), - r#"{"name":"partial-match-test","version":"0.0.0"}"#, - ) - .expect("write package.json"); - - let pkg = root.join("node_modules").join("scopedpkg"); - std::fs::create_dir_all(&pkg).expect("create package dir"); - std::fs::write( - pkg.join("package.json"), - r#"{"name":"scopedpkg","version":"1.0.0"}"#, - ) - .expect("write pkg package.json"); - std::fs::write(pkg.join("index.js"), SCOPED_ORIGINAL).expect("write index.js"); - - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).expect("create blobs"); - std::fs::write(socket.join("blobs").join(&after), SCOPED_PATCHED).expect("write blob"); - std::fs::write(socket.join("blobs").join(&ghost_after), b"ghost patched\n") - .expect("write ghost blob"); - let before_ghost = git_sha256(b"ghost original\n"); - let manifest = format!( - r#"{{ - "patches": {{ - "{SCOPED_NPM_PURL}": {{ - "uuid": "33333333-3333-4333-8333-333333333333", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ - "package/index.js": {{ "beforeHash": "{before}", "afterHash": "{after}" }} - }}, - "vulnerabilities": {{}}, - "description": "installed npm patch with local sources", - "license": "MIT", - "tier": "free" - }}, - "pkg:npm/__ghost_pkg__@9.9.9": {{ - "uuid": "44444444-4444-4444-8444-444444444444", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ - "package/index.js": {{ "beforeHash": "{before_ghost}", "afterHash": "{ghost_after}" }} - }}, - "vulnerabilities": {{}}, - "description": "npm patch whose package is not installed", - "license": "MIT", - "tier": "free" - }} - }} -}}"# - ); - std::fs::write(socket.join("manifest.json"), manifest).expect("write manifest"); + write_root_package_json(root); + install_npm_pkg(root, "scopedpkg", "1.0.0", Some(SCOPED_ORIGINAL)); + write_manifest( + root, + json!({ + SCOPED_NPM_PURL: patch_record( + "33333333-3333-4333-8333-333333333333", + one_file("package/index.js", SCOPED_ORIGINAL, SCOPED_PATCHED), + ), + "pkg:npm/__ghost_pkg__@9.9.9": patch_record( + "44444444-4444-4444-8444-444444444444", + one_file("package/index.js", b"ghost original\n", b"ghost patched\n"), + ), + }), + ); + stage_blob(root, &git_sha256(SCOPED_PATCHED), SCOPED_PATCHED); + stage_blob(root, &git_sha256(b"ghost patched\n"), b"ghost patched\n"); } /// PINS the current (asymmetric) unmatched-purl exit semantics so any diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 4878b1639..7de2be290 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -12,67 +12,17 @@ use std::path::Path; +use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use super::apply_fixture::{ + hashed_file, install_npm_pkg, patch_record, stage_blob, write_manifest, write_root_package_json, +}; use crate::common::{binary, git_sha256, hermetic}; const ORG_SLUG: &str = "test-org"; -fn write_npm_package(root: &Path, name: &str, version: &str, file_path: &str, file_content: &[u8]) { - 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 pkg json"); - let full = pkg_dir.join(file_path); - if let Some(parent) = full.parent() { - std::fs::create_dir_all(parent).expect("create file parent"); - } - std::fs::write(&full, file_content).expect("write package file"); -} - -fn write_root_package_json(root: &Path) { - std::fs::write( - root.join("package.json"), - r#"{ "name": "apply-test-root", "version": "0.0.0" }"#, - ) - .expect("write root package.json"); -} - -fn write_manifest_with_patch( - socket: &Path, - purl: &str, - uuid: &str, - before_hash: &str, - after_hash: &str, -) { - std::fs::create_dir_all(socket).expect("create .socket"); - let body = format!( - r#"{{ - "patches": {{ - "{purl}": {{ - "uuid": "{uuid}", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ - "package/index.js": {{ - "beforeHash": "{before_hash}", - "afterHash": "{after_hash}" - }} - }}, - "vulnerabilities": {{}}, - "description": "Apply network test patch", - "license": "MIT", - "tier": "free" - }} - }} -}}"# - ); - std::fs::write(socket.join("manifest.json"), body).expect("write manifest"); -} - fn run_apply(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) { // Pass the API URL/token/org via env vars (equivalent to the global // --api-url/--api-token/--org flags). @@ -120,15 +70,12 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package( + install_npm_pkg(tmp.path(), "apply-network-test", "1.0.0", Some(before)); + let socket = tmp.path().join(".socket"); + write_manifest( tmp.path(), - "apply-network-test", - "1.0.0", - "index.js", - before, + json!({ purl: patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), ); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); assert_eq!( @@ -229,9 +176,11 @@ async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "skipped", "1.0.0", "index.js", before); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); + install_npm_pkg(tmp.path(), "skipped", "1.0.0", Some(before)); + write_manifest( + tmp.path(), + json!({ purl: patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), + ); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--ecosystems", "pypi"]); // Filtering out npm leaves nothing in scope: there is genuinely no @@ -306,20 +255,14 @@ async fn apply_dry_run_emits_verified_event_without_writing() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "dryrun-target", "1.0.0", "index.js", before); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/dryrun-target@1.0.0", - "11111111-1111-4111-8111-111111111111", - &before_hash, - &after_hash, + install_npm_pkg(tmp.path(), "dryrun-target", "1.0.0", Some(before)); + write_manifest( + tmp.path(), + json!({ "pkg:npm/dryrun-target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &before_hash, &after_hash)) }), ); // Pre-stage the after blob so we don't need to mock the network // path; we just want to verify dry-run reports the action correctly. - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(&after_hash), after).unwrap(); + stage_blob(tmp.path(), &after_hash, after); // No mock needed — apply finds everything locally. let out = hermetic::command(&binary()) @@ -379,25 +322,13 @@ async fn apply_with_force_overrides_hash_mismatch() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package( + install_npm_pkg(tmp.path(), "force-target", "1.0.0", Some(actual_before)); + write_manifest( tmp.path(), - "force-target", - "1.0.0", - "index.js", - actual_before, - ); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/force-target@1.0.0", - "11111111-1111-4111-8111-111111111111", - &expected_before_hash, - &after_hash, + json!({ "pkg:npm/force-target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &expected_before_hash, &after_hash)) }), ); // Pre-stage the after blob so we don't need the network. - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(&after_hash), after).unwrap(); + stage_blob(tmp.path(), &after_hash, after); // Without --force apply should fail (hash mismatch). With --force it // should bypass the verification and write the patched content. @@ -446,18 +377,12 @@ async fn apply_hash_mismatch_opt_in_warns_and_applies_strict_fails() { let fixture = || { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "mismatch", "1.0.0", "index.js", actual_before); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/mismatch@1.0.0", - "11111111-1111-4111-8111-111111111111", - &expected_before_hash, - &after_hash, + install_npm_pkg(tmp.path(), "mismatch", "1.0.0", Some(actual_before)); + write_manifest( + tmp.path(), + json!({ "pkg:npm/mismatch@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &expected_before_hash, &after_hash)) }), ); - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(&after_hash), after).unwrap(); + stage_blob(tmp.path(), &after_hash, after); tmp }; @@ -576,17 +501,11 @@ async fn apply_pypi_package_uses_python_crawler() { ) .unwrap(); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:pypi/pypi_target@1.0.0", - "11111111-1111-4111-8111-111111111111", - &before_hash, - &after_hash, + write_manifest( + tmp.path(), + json!({ "pkg:pypi/pypi_target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &before_hash, &after_hash)) }), ); - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(&after_hash), after).unwrap(); + stage_blob(tmp.path(), &after_hash, after); // Run apply restricted to pypi. With the venv staged on disk and the // after-blob pre-cached, this must locate the package via the python @@ -638,18 +557,13 @@ async fn apply_uses_locally_cached_blob_without_fetching() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "cached", "1.0.0", "index.js", before); + install_npm_pkg(tmp.path(), "cached", "1.0.0", Some(before)); let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/cached@1.0.0", - "22222222-2222-4222-8222-222222222222", - &before_hash, - &after_hash, + write_manifest( + tmp.path(), + json!({ "pkg:npm/cached@1.0.0": patch_record("22222222-2222-4222-8222-222222222222", hashed_file("package/index.js", &before_hash, &after_hash)) }), ); - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(&after_hash), after).unwrap(); + stage_blob(tmp.path(), &after_hash, after); // No mock server. If apply tries to hit the network, the test will // fail (connection refused) — proving the local-blob fast path is @@ -683,7 +597,7 @@ async fn apply_uses_locally_cached_blob_without_fetching() { // `.socket/blobs/` must still contain the cached blob (apply is // read-only against the persistent cache). assert!( - blobs.join(&after_hash).exists(), + socket.join("blobs").join(&after_hash).exists(), "cached blob must survive apply" ); } @@ -713,20 +627,16 @@ async fn apply_mismatch_redownloads_full_blob_and_applies() { let uuid = "11111111-1111-4111-8111-111111111111"; let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package( + install_npm_pkg( tmp.path(), "mismatch", "1.0.0", - "index.js", - b"DIFFERENT-CONTENT\n", + Some(b"DIFFERENT-CONTENT\n"), ); let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/mismatch@1.0.0", - uuid, - &expected_before_hash, - &after_hash, + write_manifest( + tmp.path(), + json!({ "pkg:npm/mismatch@1.0.0": patch_record(uuid, hashed_file("package/index.js", &expected_before_hash, &after_hash)) }), ); // A leftover LOCAL package archive exists, but nothing reads it: only // the blob can produce the patched bytes, and no blob is cached. @@ -803,18 +713,12 @@ async fn offline_apply_with_token_makes_zero_network_requests() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "airgap-test", "1.0.0", "index.js", before); - let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/airgap-test@1.0.0", - "33333333-3333-4333-8333-333333333333", - &before_hash, - &after_hash, + install_npm_pkg(tmp.path(), "airgap-test", "1.0.0", Some(before)); + write_manifest( + tmp.path(), + json!({ "pkg:npm/airgap-test@1.0.0": patch_record("33333333-3333-4333-8333-333333333333", hashed_file("package/index.js", &before_hash, &after_hash)) }), ); - let blobs = socket.join("blobs"); - std::fs::create_dir_all(&blobs).expect("create blobs"); - std::fs::write(blobs.join(&after_hash), after).expect("stage blob"); + stage_blob(tmp.path(), &after_hash, after); let mut cmd = hermetic::command(&binary()); cmd.args(["apply", "--json", "--offline"]) @@ -904,14 +808,11 @@ async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); - write_npm_package(tmp.path(), "pkgcache", "1.0.0", "index.js", before); + install_npm_pkg(tmp.path(), "pkgcache", "1.0.0", Some(before)); let socket = tmp.path().join(".socket"); - write_manifest_with_patch( - &socket, - "pkg:npm/pkgcache@1.0.0", - uuid, - &before_hash, - &after_hash, + write_manifest( + tmp.path(), + json!({ "pkg:npm/pkgcache@1.0.0": patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), ); write_package_archive( &socket.join("packages"), @@ -974,20 +875,22 @@ async fn mismatch_overwrites_every_copy_of_a_duplicated_package() { write_root_package_json(tmp.path()); // Root copy: already patched (afterHash bytes) — the probe finds no // mismatch here. - write_npm_package(tmp.path(), "dupcopy", "1.0.0", "index.js", after); + install_npm_pkg(tmp.path(), "dupcopy", "1.0.0", Some(after)); // Nested duplicate under a real intermediate package: locally modified, // matching neither hash. - write_npm_package(tmp.path(), "parent", "1.0.0", "index.js", b"parent\n"); - write_npm_package( + install_npm_pkg(tmp.path(), "parent", "1.0.0", Some(b"parent\n")); + install_npm_pkg( &tmp.path().join("node_modules").join("parent"), "dupcopy", "1.0.0", - "index.js", - b"locally modified\n", + Some(b"locally modified\n"), ); let socket = tmp.path().join(".socket"); - write_manifest_with_patch(&socket, purl, uuid, &before_hash, &after_hash); + write_manifest( + tmp.path(), + json!({ purl: patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), + ); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--allow-patch-conflicts"]); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); assert_eq!( diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs index e05135562..79b676bd0 100644 --- a/crates/socket-patch-cli/tests/apply/apply_render.rs +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -295,113 +295,6 @@ fn pnp_refusal_names_the_layout_and_corrupt_manifest_does_not() { 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); - } -} - // ═══════════ gem variant mismatch policy ═══════════ // // The human legs of `cli_gem_variant_mismatch_policy.rs`, which asserts diff --git a/crates/socket-patch-cli/tests/apply/bun_global_store.rs b/crates/socket-patch-cli/tests/apply/bun_global_store.rs index 0ebcfa482..e94788850 100644 --- a/crates/socket-patch-cli/tests/apply/bun_global_store.rs +++ b/crates/socket-patch-cli/tests/apply/bun_global_store.rs @@ -10,32 +10,15 @@ use std::path::{Path, PathBuf}; -use serde_json::{json, Value}; +use serde_json::Value; -use crate::common; - -use common::{git_sha256, parse_json_envelope, run_with_env}; +use super::apply_fixture::{link_dir, one_file, patch_record, stage_blob, write_manifest}; +use crate::common::envelope::event_for; +use crate::common::{git_sha256, parse_json_envelope, run_with_env}; const BEFORE: &[u8] = b"module.exports = 'pristine';\n"; const AFTER: &[u8] = b"module.exports = 'patched';\n"; -fn link_dir(target: &Path, link: &Path) { - #[cfg(unix)] - std::os::unix::fs::symlink(target, link).unwrap(); - #[cfg(windows)] - { - let link: PathBuf = link.components().collect(); - let target: PathBuf = target.components().collect(); - let status = std::process::Command::new("cmd") - .args(["/C", "mklink", "/J"]) - .arg(&link) - .arg(&target) - .status() - .unwrap(); - assert!(status.success(), "mklink /J failed"); - } -} - /// The layout Bun 1.3.14 writes with `linker = "isolated"` and /// `globalStore = true`: `proj/node_modules/{left-pad,is-odd}` link to /// `.bun//node_modules/`, each `.bun/` links to @@ -87,55 +70,24 @@ fn stage(tmp: &Path, bytes: &[u8]) -> (PathBuf, PathBuf, PathBuf) { (proj, left_pad, number) } -fn write_manifest(root: &Path, purls: &[&str]) { - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - for bytes in [BEFORE, AFTER] { - std::fs::write(socket.join("blobs").join(git_sha256(bytes)), bytes).unwrap(); - } - let patches: serde_json::Map = purls +fn write_patches(root: &Path, purls: &[&str]) { + let patches = purls .iter() .enumerate() .map(|(i, purl)| { - let patch = json!({ - "uuid": format!("63563563-0000-4000-8000-00000000000{i}"), - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "package/index.js": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - }}, - "vulnerabilities": {}, - "description": "bun global store fixture", - "license": "MIT", - "tier": "free" - }); - (purl.to_string(), patch) + let uuid = format!("63563563-0000-4000-8000-00000000000{i}"); + let record = patch_record(&uuid, one_file("package/index.js", BEFORE, AFTER)); + (purl.to_string(), record) }) .collect(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&json!({ "patches": patches })).unwrap(), - ) - .unwrap(); -} - -/// `purl`'s entry in an apply envelope's `events` or a rollback -/// envelope's `results`. -fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { - v["events"] - .as_array() - .expect("events array") - .iter() - .find(|e| e["purl"] == purl) - .unwrap_or_else(|| panic!("no event for {purl}: {v}")) + write_manifest(root, Value::Object(patches)); + for bytes in [BEFORE, AFTER] { + stage_blob(root, &git_sha256(bytes), bytes); + } } fn run(proj: &Path, command: &str) -> Value { - let (code, stdout, stderr) = run_with_env( - proj, - &[command, "--offline", "--json"], - &[("SOCKET_TELEMETRY_DISABLED", "1")], - ); + let (code, stdout, stderr) = run_with_env(proj, &[command, "--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_ne!( code, 0, @@ -145,7 +97,7 @@ fn run(proj: &Path, command: &str) -> Value { } fn assert_refused(v: &Value, purl: &str) { - let ev = event(v, purl); + let ev = event_for(v, purl); assert_ne!(ev["errorCode"], "package_not_installed", "{ev}"); let text = ev.to_string(); assert!( @@ -161,7 +113,7 @@ fn assert_refused(v: &Value, purl: &str) { fn apply_refuses_bun_global_store_packages() { let tmp = tempfile::tempdir().unwrap(); let (proj, left_pad, number) = stage(tmp.path(), BEFORE); - write_manifest( + write_patches( &proj, &["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@6.0.0"], ); @@ -181,7 +133,7 @@ fn apply_refuses_bun_global_store_packages() { fn rollback_refuses_bun_global_store_packages() { let tmp = tempfile::tempdir().unwrap(); let (proj, left_pad, number) = stage(tmp.path(), AFTER); - write_manifest( + write_patches( &proj, &["pkg:npm/left-pad@1.3.0", "pkg:npm/is-number@6.0.0"], ); @@ -205,13 +157,9 @@ fn apply_note_names_the_bun_global_store() { let tmp = tempfile::tempdir().unwrap(); let (proj, left_pad, _) = stage(tmp.path(), BEFORE); std::fs::write(proj.join("bun.lock"), "{}\n").unwrap(); - write_manifest(&proj, &["pkg:npm/left-pad@1.3.0"]); + write_patches(&proj, &["pkg:npm/left-pad@1.3.0"]); - let (code, _stdout, stderr) = run_with_env( - &proj, - &["apply", "--offline"], - &[("SOCKET_TELEMETRY_DISABLED", "1")], - ); + let (code, _stdout, stderr) = run_with_env(&proj, &["apply", "--offline"], &[]); assert_ne!( code, 0, "the shared-store refusal fails the apply; {stderr}" diff --git a/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs b/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs index 5d1c7854c..9ec2c6284 100644 --- a/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs +++ b/crates/socket-patch-cli/tests/apply/cargo_lock_source.rs @@ -9,41 +9,14 @@ //! source, so agent mode must leave it alone. A registry-sourced lock entry //! (the control) is still patched. -use std::path::Path; - use serde_json::{json, Value}; +use super::apply_fixture::{one_file, patch_record, stage_blob, write_manifest}; use crate::cargo_lock_source_fixture::{ stage_registry_copy, write_project, Shape, AFTER, BEFORE, PURL, }; -use crate::common; - -use common::{git_sha256, parse_json_envelope, run_with_env}; - -/// `.socket/manifest.json` with one agent-mode record for [`PURL`], and the -/// after blob staged so apply runs offline. -fn write_manifest(root: &Path) { - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - std::fs::write(socket.join("blobs").join(git_sha256(AFTER)), AFTER).unwrap(); - let manifest = json!({ "patches": { PURL: { - "uuid": "50650650-0000-4000-8000-000000000506", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "package/src/lib.rs": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - }}, - "vulnerabilities": {}, - "description": "cargo lock source fixture", - "license": "MIT", - "tier": "free" - }}}); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&manifest).unwrap(), - ) - .unwrap(); -} +use crate::common::envelope::event_for; +use crate::common::{git_sha256, parse_json_envelope, run_with_env}; /// Stage `shape`, run `apply --offline --json`, and return the exit code, /// the envelope and the registry copy's `src/lib.rs` afterwards. @@ -52,7 +25,14 @@ fn apply(shape: Shape, extra: &[&str]) -> (i32, Value, Vec) { let project = tmp.path().join("app"); std::fs::create_dir_all(&project).unwrap(); write_project(&project, shape); - write_manifest(&project); + write_manifest( + &project, + json!({ PURL: patch_record( + "50650650-0000-4000-8000-000000000506", + one_file("package/src/lib.rs", BEFORE, AFTER), + )}), + ); + stage_blob(&project, &git_sha256(AFTER), AFTER); let cargo_home = tmp.path().join("cargo-home"); let lib = stage_registry_copy(&cargo_home, BEFORE); @@ -61,10 +41,7 @@ fn apply(shape: Shape, extra: &[&str]) -> (i32, Value, Vec) { let (code, stdout, stderr) = run_with_env( &project, &argv, - &[ - ("SOCKET_TELEMETRY_DISABLED", "1"), - ("CARGO_HOME", cargo_home.to_str().unwrap()), - ], + &[("CARGO_HOME", cargo_home.to_str().unwrap())], ); let v = parse_json_envelope(&stdout); assert!(!v.is_null(), "{shape:?}: no envelope\n{stdout}\n{stderr}"); @@ -97,7 +74,7 @@ fn crate_overridden_away_from_the_registry_is_not_patched() { lib, BEFORE, "{shape:?}: the unused registry copy must stay untouched: {v}" ); - let ev = event(&v, shape); + let ev = event_for(&v, PURL); assert_eq!(ev["action"], "skipped", "{shape:?}: {v}"); match shape { // The user's own `[patch]` or `[replace]` entry: the consent @@ -129,16 +106,7 @@ fn allowed_patch_conflict_still_leaves_the_unused_registry_copy() { let (code, v, lib) = apply(shape, &["--allow-patch-conflicts"]); assert_eq!(lib, BEFORE, "{shape:?}: {v}"); assert_eq!(code, 1, "{shape:?}: must not report success: {v}"); - let ev = event(&v, shape); + let ev = event_for(&v, PURL); assert_eq!(ev["errorCode"], "package_not_installed", "{shape:?}: {ev}"); } } - -fn event(v: &Value, shape: Shape) -> &Value { - v["events"] - .as_array() - .expect("events array") - .iter() - .find(|e| e["purl"] == PURL) - .unwrap_or_else(|| panic!("{shape:?}: no event for {PURL}: {v}")) -} diff --git a/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs b/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs index 5d44fc7b6..d50d768f6 100644 --- a/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs +++ b/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs @@ -11,9 +11,12 @@ use std::path::Path; use serde_json::{json, Value}; -use crate::common; -use common::envelope::events; -use common::{git_sha256, parse_json_envelope, run_with_env}; +use super::apply_fixture::{ + install_gem, install_npm_pkg, one_file, patch_record, stage_blob, write_manifest, + write_root_package_json, +}; +use crate::common::envelope::events; +use crate::common::{git_sha256, parse_json_envelope, run_with_env}; const PURL: &str = "pkg:npm/check-target@1.0.0"; const ORIGINAL: &[u8] = b"module.exports = 'vulnerable';\n"; @@ -22,7 +25,7 @@ const PATCHED: &[u8] = b"module.exports = 'patched';\n"; fn run_check(cwd: &Path, extra: &[&str]) -> (i32, String, String) { let mut argv = vec!["apply", "--check", "--offline"]; argv.extend_from_slice(extra); - run_with_env(cwd, &argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]) + run_with_env(cwd, &argv, &[]) } /// An npm agent project: `.socket/manifest.json` patching @@ -31,38 +34,16 @@ fn run_check(cwd: &Path, extra: &[&str]) -> (i32, String, String) { fn project(installed: Option<&[u8]>) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); - std::fs::write( - root.join("package.json"), - r#"{ "name": "check-root", "version": "0.0.0" }"#, - ) - .unwrap(); - std::fs::create_dir_all(root.join(".socket")).unwrap(); - let manifest = json!({ "patches": { PURL: { - "uuid": "27272727-2727-4272-8272-272727272727", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "index.js": { - "beforeHash": git_sha256(ORIGINAL), - "afterHash": git_sha256(PATCHED), - }}, - "vulnerabilities": {}, - "description": "apply --check fixture", - "license": "MIT", - "tier": "free", - }}}); - std::fs::write( - root.join(".socket/manifest.json"), - serde_json::to_vec_pretty(&manifest).unwrap(), - ) - .unwrap(); + write_root_package_json(root); + write_manifest( + root, + json!({ PURL: patch_record( + "27272727-2727-4272-8272-272727272727", + one_file("index.js", ORIGINAL, PATCHED), + )}), + ); if let Some(bytes) = installed { - let pkg = root.join("node_modules/check-target"); - std::fs::create_dir_all(&pkg).unwrap(); - std::fs::write( - pkg.join("package.json"), - r#"{ "name": "check-target", "version": "1.0.0" }"#, - ) - .unwrap(); - std::fs::write(pkg.join("index.js"), bytes).unwrap(); + install_npm_pkg(root, "check-target", "1.0.0", Some(bytes)); } tmp } @@ -216,33 +197,16 @@ fn gem_project(installed: &[u8]) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap(); - let file = root.join("vendor/bundle/ruby/3.4.0/gems/nokogiri-1.16.5/lib/nokogiri.rb"); - std::fs::create_dir_all(file.parent().unwrap()).unwrap(); - std::fs::write(&file, installed).unwrap(); + install_gem(root, "nokogiri-1.16.5", "lib/nokogiri.rb", installed); std::fs::create_dir_all(root.join("vendor/bundle/ruby/3.4.0/specifications")).unwrap(); - std::fs::create_dir_all(root.join(".socket/blobs")).unwrap(); - let manifest = json!({ "patches": { GEM_LINUX: { - "uuid": "41414141-4141-4141-8141-414141414141", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "lib/nokogiri.rb": { - "beforeHash": git_sha256(GEM_ORIGINAL), - "afterHash": git_sha256(GEM_PATCHED), - }}, - "vulnerabilities": {}, - "description": "apply --check gem variant fixture", - "license": "MIT", - "tier": "free", - }}}); - std::fs::write( - root.join(".socket/manifest.json"), - serde_json::to_vec_pretty(&manifest).unwrap(), - ) - .unwrap(); - std::fs::write( - root.join(".socket/blobs").join(git_sha256(GEM_PATCHED)), - GEM_PATCHED, - ) - .unwrap(); + write_manifest( + root, + json!({ GEM_LINUX: patch_record( + "41414141-4141-4141-8141-414141414141", + one_file("lib/nokogiri.rb", GEM_ORIGINAL, GEM_PATCHED), + )}), + ); + stage_blob(root, &git_sha256(GEM_PATCHED), GEM_PATCHED); tmp } @@ -274,7 +238,7 @@ fn check_fails_on_a_qualified_gem_matching_no_variant() { let (code, stdout, stderr) = run_with_env( tmp.path(), &["apply", "--offline", "--ecosystems", "gem"], - &[("SOCKET_TELEMETRY_DISABLED", "1")], + &[], ); assert_eq!(code, 1, "stdout={stdout}\nstderr={stderr}"); } @@ -387,7 +351,7 @@ fn printed_remedy(stderr: &str) -> Vec { fn run_remedy_then_check(cwd: &Path, remedy: &[String], check: &[&str]) -> (i32, String) { let mut argv: Vec<&str> = remedy.iter().map(String::as_str).collect(); argv.push("--offline"); - let (code, stdout, stderr) = run_with_env(cwd, &argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]); + let (code, stdout, stderr) = run_with_env(cwd, &argv, &[]); assert_eq!( code, 0, "the printed remedy runs\nstdout={stdout}\nstderr={stderr}" diff --git a/crates/socket-patch-cli/tests/apply/gem_stale_bundler_plugin.rs b/crates/socket-patch-cli/tests/apply/gem_stale_bundler_plugin.rs index 52e38dba6..8bec82583 100644 --- a/crates/socket-patch-cli/tests/apply/gem_stale_bundler_plugin.rs +++ b/crates/socket-patch-cli/tests/apply/gem_stale_bundler_plugin.rs @@ -7,11 +7,9 @@ //! 2.3-2.5 then fail every `bundle install` with a `LoadError`. scan and //! apply on a gem project must say so, with the one-line fix. -use crate::common::{git_sha256, run_with_env}; - use std::path::Path; -const PURL: &str = "pkg:gem/rack@3.1.0"; +use super::apply_fixture::{run_gem, write_gem_patch}; const CODE: &str = "gem_bundler_plugin_stale"; /// A Gemfile project whose `.bundle/plugin/index` registers @@ -38,34 +36,11 @@ fn build_project(root: &Path, plugin_dir_present: bool, with_patch: bool) -> Str std::fs::create_dir_all(&plugin_dir).unwrap(); } if with_patch { - let original = b"module Rack\n VERSION = 'VULNERABLE'\nend\n"; - let mut patched = original.to_vec(); - patched.extend_from_slice(b"# SOCKET-PATCHED\n"); - let before_hash = git_sha256(original); - let after_hash = git_sha256(&patched); - let gem_lib = root.join("vendor/bundle/ruby/3.2.0/gems/rack-3.1.0/lib"); - std::fs::create_dir_all(&gem_lib).unwrap(); - std::fs::write(gem_lib.join("rack.rb"), original).unwrap(); - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - std::fs::write( - socket.join("manifest.json"), - format!( - r#"{{ "patches": {{ - "{PURL}": {{ - "uuid": "636f6e66-6967-4761-8264-000000001295", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "lib/rack.rb": {{ - "beforeHash": "{before_hash}", "afterHash": "{after_hash}" - }}}}, - "vulnerabilities": {{}}, "description": "stale-plugin fixture", - "license": "MIT", "tier": "free" - }} - }}}}"# - ), - ) - .unwrap(); - std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap(); + write_gem_patch( + root, + "636f6e66-6967-4761-8264-000000001295", + b"# SOCKET-PATCHED\n", + ); } dir } @@ -79,7 +54,7 @@ fn run(root: &Path, args: &[&str]) -> (i32, String, String) { let mut argv: Vec<&str> = args.to_vec(); let cwd = root.display().to_string(); argv.extend(["--cwd", cwd.as_str()]); - run_with_env( + run_gem( root, &argv, &[ diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs index fe4147d98..f3115fc61 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs @@ -10,12 +10,9 @@ //! * non-JSON runs print ONE stderr warning, gated on `!--silent` //! (`--silent` = errors only — a bare crawler eprintln violated that). -use crate::common::hermetic::{self, Extra}; -use crate::common::{binary, git_sha256, hermetic_command}; - use std::path::Path; -const PURL: &str = "pkg:gem/rack@3.1.0"; +use super::apply_fixture::{run_gem, write_gem_patch}; const CODE: &str = "gem_bundle_config_path_ignored"; /// Project fixture: Gemfile + `.bundle/config` pointing `BUNDLE_PATH` at @@ -36,43 +33,11 @@ fn build_project(root: &Path, outside: &Path, with_store: bool) { std::fs::create_dir_all(outside.join("specifications")).unwrap(); if with_store { - let original = b"module Rack\n VERSION = 'VULNERABLE'\nend\n"; - let mut patched = original.to_vec(); - patched.extend_from_slice(b"# SOCKET-PATCHED\n"); - let before_hash = git_sha256(original); - let after_hash = git_sha256(&patched); - - let gem_lib = root - .join("vendor") - .join("bundle") - .join("ruby") - .join("3.2.0") - .join("gems") - .join("rack-3.1.0") - .join("lib"); - std::fs::create_dir_all(&gem_lib).unwrap(); - std::fs::write(gem_lib.join("rack.rb"), original).unwrap(); - - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - std::fs::write( - socket.join("manifest.json"), - format!( - r#"{{ "patches": {{ - "{PURL}": {{ - "uuid": "636f6e66-6967-4761-8264-000000000000", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "lib/rack.rb": {{ - "beforeHash": "{before_hash}", "afterHash": "{after_hash}" - }}}}, - "vulnerabilities": {{}}, "description": "config-warning fixture", - "license": "MIT", "tier": "free" - }} - }}}}"# - ), - ) - .unwrap(); - std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap(); + write_gem_patch( + root, + "636f6e66-6967-4761-8264-000000000000", + b"# SOCKET-PATCHED\n", + ); } } @@ -82,16 +47,10 @@ fn build_project(root: &Path, outside: &Path, with_store: bool) { fn run(root: &Path, args: &[&str]) -> (i32, String, String) { let empty_path = root.join("empty-bin"); std::fs::create_dir_all(&empty_path).unwrap(); - let mut cmd = hermetic_command(&binary()); - cmd.args(args).arg("--cwd").arg(root); - hermetic::scrub_extra(&mut cmd, &[Extra::Bundle]); - cmd.env("PATH", &empty_path); - let out = cmd.output().expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - String::from_utf8_lossy(&out.stderr).to_string(), - ) + let mut argv = args.to_vec(); + let cwd = root.display().to_string(); + argv.extend(["--cwd", cwd.as_str()]); + run_gem(root, &argv, &[("PATH", empty_path.to_str().unwrap())]) } fn assert_warning_in(env: &serde_json::Value, value_fragment: &str, ctx: &str) { diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs index d81914f5e..8b9550db9 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs @@ -20,13 +20,16 @@ #![cfg(unix)] -use crate::common::hermetic::{self, Extra}; -use crate::common::{binary, git_sha256, hermetic_command}; - use std::path::{Path, PathBuf}; -const BASE_PURL: &str = "pkg:gem/rack@3.1.0"; -const QUALIFIED_PURL: &str = "pkg:gem/rack@3.1.0?platform=ruby"; +use serde_json::json; + +use super::apply_fixture::{ + one_file, patch_record, run_gem, stage_blob, write_manifest, GEM_BASE_PURL as BASE_PURL, + GEM_QUALIFIED_PURL as QUALIFIED_PURL, +}; +use crate::common::git_sha256; + const SKIP_CODE: &str = "gem_fallback_home_skipped"; const ORIGINAL: &[u8] = b"module Rack\n VERSION = 'VULNERABLE'\nend\n"; @@ -65,31 +68,6 @@ fn stage_copy(root: &Path, bytes: &[u8]) -> PathBuf { file } -fn stage_manifest(root: &Path, purl: &str) { - let before_hash = git_sha256(ORIGINAL); - let after_hash = git_sha256(&patched_bytes()); - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - std::fs::write( - socket.join("manifest.json"), - format!( - r#"{{ "patches": {{ - "{purl}": {{ - "uuid": "66616c6c-6261-4b63-8368-6f6d65000000", - "exportedAt": "2024-01-01T00:00:00Z", - "files": {{ "lib/rack.rb": {{ - "beforeHash": "{before_hash}", "afterHash": "{after_hash}" - }}}}, - "vulnerabilities": {{}}, "description": "fallback-home fixture", - "license": "MIT", "tier": "free" - }} - }}}}"# - ), - ) - .unwrap(); - std::fs::write(socket.join("blobs").join(&after_hash), patched_bytes()).unwrap(); -} - struct Fixture { project: tempfile::TempDir, store_file: Option, @@ -116,7 +94,15 @@ fn build_fixture(with_store: bool, home_bytes: &[u8], purl: &str) -> Fixture { std::fs::create_dir_all(&bin_dir).unwrap(); install_fake_gem(&bin_dir, &home); - stage_manifest(root, purl); + let patched = patched_bytes(); + write_manifest( + root, + json!({ purl: patch_record( + "66616c6c-6261-4b63-8368-6f6d65000000", + one_file("lib/rack.rb", ORIGINAL, &patched), + )}), + ); + stage_blob(root, &git_sha256(&patched), &patched); Fixture { project, store_file, @@ -130,21 +116,15 @@ fn build_fixture(with_store: bool, home_bytes: &[u8], purl: &str) -> Fixture { /// the fake-gem bin dir only, and BUNDLE_PATH set to the store root when /// present. fn run_apply(fx: &Fixture, extra: &[&str]) -> (i32, String, String) { - let mut cmd = hermetic_command(&binary()); - cmd.args(["apply", "--offline", "--ecosystems", "gem", "--cwd"]) - .arg(fx.project.path()) - .args(extra); - hermetic::scrub_extra(&mut cmd, &[Extra::Bundle]); - cmd.env("PATH", &fx.bin_dir); + let cwd = fx.project.path().to_str().unwrap(); + let mut argv = vec!["apply", "--offline", "--ecosystems", "gem", "--cwd", cwd]; + argv.extend_from_slice(extra); + let bin_dir = fx.bin_dir.to_str().unwrap(); + let mut env = vec![("PATH", bin_dir)]; if let Some(store_root) = &fx.store_root { - cmd.env("BUNDLE_PATH", store_root); + env.push(("BUNDLE_PATH", store_root.to_str().unwrap())); } - 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(), - ) + run_gem(fx.project.path(), &argv, &env) } fn parse_env(stdout: &str) -> serde_json::Value { @@ -349,3 +329,32 @@ fn failing_fallback_home_copy_write_is_nonfatal_when_store_patched() { "apply exited 0 on this tree, so --check must too.\nstdout={stdout}\nstderr={stderr}" ); } + +/// The human render of (a): the per-copy warning names the skipped +/// copy's path and says why the skip is safe. +#[test] +fn mismatched_fallback_home_copy_warns_on_human_stderr() { + let foreign = b"totally different bytes\n"; + let fx = build_fixture(true, foreign, QUALIFIED_PURL); + + let (code, _stdout, stderr) = run_apply(&fx, &[]); + 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}" + ); + let home = fx.project.path().join("gem-home"); + 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(fx.store_file.as_ref().unwrap()).unwrap(), + patched_bytes() + ); + assert_eq!(std::fs::read(&fx.home_file).unwrap(), foreign); +} diff --git a/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs b/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs index feffac100..46be5c61e 100644 --- a/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs +++ b/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs @@ -12,63 +12,27 @@ use std::path::Path; use serde_json::{json, Value}; -use crate::common; - -use common::{git_sha256, parse_json_envelope, run_with_env}; - -const BEFORE: &[u8] = b"pristine content\n"; -const AFTER: &[u8] = b"patched content\n"; - -fn run_apply(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut argv: Vec<&str> = vec!["apply"]; - argv.extend_from_slice(args); - run_with_env(cwd, &argv, &[("SOCKET_TELEMETRY_DISABLED", "1")]) -} - -fn record(uuid: &str) -> Value { - json!({ - "uuid": uuid, - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "package/index.js": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - }}, - "vulnerabilities": {}, - "description": "lockfile-only apply fixture", - "license": "MIT", - "tier": "free" - }) -} +use super::apply_fixture::{ + install_npm_pkg, one_file, patch_record, run_apply, stage_blob, write_manifest, MM_AFTER, + MM_BEFORE, +}; +use crate::common::envelope::event_for; +use crate::common::{git_sha256, parse_json_envelope}; /// `.socket/manifest.json` holding one record per purl, with the after /// blob staged so the offline source guard is never what fires. -fn write_manifest(root: &Path, purls: &[&str]) { - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - std::fs::write(socket.join("blobs").join(git_sha256(AFTER)), AFTER).unwrap(); - let mut patches = serde_json::Map::new(); - for (i, purl) in purls.iter().enumerate() { - patches.insert( - (*purl).to_string(), - record(&format!("40340340-0000-4000-8000-00000000000{i}")), - ); - } - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&json!({ "patches": patches })).unwrap(), - ) - .unwrap(); -} - -fn install_npm_pkg(root: &Path, name: &str, version: &str) { - let dir = root.join("node_modules").join(name); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("package.json"), - format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), - ) - .unwrap(); - std::fs::write(dir.join("index.js"), BEFORE).unwrap(); +fn write_patches(root: &Path, purls: &[&str]) { + let patches = purls + .iter() + .enumerate() + .map(|(i, purl)| { + let uuid = format!("40340340-0000-4000-8000-00000000000{i}"); + let record = patch_record(&uuid, one_file("package/index.js", MM_BEFORE, MM_AFTER)); + (purl.to_string(), record) + }) + .collect(); + write_manifest(root, Value::Object(patches)); + stage_blob(root, &git_sha256(MM_AFTER), MM_AFTER); } /// npm v3 lock: `chokidar` installed, `fsevents` an `os: darwin` optional @@ -104,7 +68,7 @@ fn write_optional_skipped_project(root: &Path) { .unwrap(), ) .unwrap(); - install_npm_pkg(root, "chokidar", "3.6.0"); + install_npm_pkg(root, "chokidar", "3.6.0", Some(MM_BEFORE)); } /// npm v3 lock after `npm ci --omit=dev`: `left-pad` (prod) installed, @@ -144,16 +108,7 @@ fn write_omit_dev_project(root: &Path) { .unwrap(), ) .unwrap(); - install_npm_pkg(root, "left-pad", "1.3.0"); -} - -fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { - v["events"] - .as_array() - .expect("events array") - .iter() - .find(|e| e["purl"] == purl) - .unwrap_or_else(|| panic!("no event for {purl}: {v}")) + install_npm_pkg(root, "left-pad", "1.3.0", Some(MM_BEFORE)); } /// #403: the only manifest patch targets a platform-skipped optional @@ -162,21 +117,21 @@ fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { fn platform_skipped_optional_dependency_is_a_calm_skip() { let tmp = tempfile::tempdir().unwrap(); write_optional_skipped_project(tmp.path()); - write_manifest(tmp.path(), &["pkg:npm/fsevents@2.3.3"]); + write_patches(tmp.path(), &["pkg:npm/fsevents@2.3.3"]); - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_eq!( code, 0, "lock-resolved, host-skipped package must not fail; {v}\n{stderr}" ); assert_eq!(v["status"], "success", "{v}"); - let ev = event(&v, "pkg:npm/fsevents@2.3.3"); + let ev = event_for(&v, "pkg:npm/fsevents@2.3.3"); assert_eq!(ev["action"], "skipped", "{ev}"); assert_eq!(ev["errorCode"], "package_not_installed", "{ev}"); // Human path: a note, never the exit-flipping error. - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"]); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); assert_eq!(code, 0, "{stderr}"); assert!( !stderr.contains("Error:"), @@ -193,9 +148,9 @@ fn platform_skipped_optional_dependency_is_a_calm_skip() { fn silent_apply_on_platform_skipped_optional_dependency_exits_zero() { let tmp = tempfile::tempdir().unwrap(); write_optional_skipped_project(tmp.path()); - write_manifest(tmp.path(), &["pkg:npm/fsevents@2.3.3"]); + write_patches(tmp.path(), &["pkg:npm/fsevents@2.3.3"]); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline", "--silent"]); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline", "--silent"], &[]); assert_eq!(code, 0, "{stderr}"); assert!( stderr.trim().is_empty(), @@ -209,16 +164,16 @@ fn silent_apply_on_platform_skipped_optional_dependency_exits_zero() { fn omitted_dev_dependency_is_a_calm_skip() { let tmp = tempfile::tempdir().unwrap(); write_omit_dev_project(tmp.path()); - write_manifest(tmp.path(), &["pkg:npm/kind-of@6.0.3"]); + write_patches(tmp.path(), &["pkg:npm/kind-of@6.0.3"]); - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_eq!( code, 0, "omitted devDependency must not fail; {v}\n{stderr}" ); assert_eq!(v["status"], "success", "{v}"); - let ev = event(&v, "pkg:npm/kind-of@6.0.3"); + let ev = event_for(&v, "pkg:npm/kind-of@6.0.3"); assert_eq!(ev["action"], "skipped", "{ev}"); assert_eq!(ev["errorCode"], "package_not_installed", "{ev}"); } @@ -229,12 +184,12 @@ fn omitted_dev_dependency_is_a_calm_skip() { fn unresolved_purl_still_fails_alongside_a_lock_resolved_one() { let tmp = tempfile::tempdir().unwrap(); write_optional_skipped_project(tmp.path()); - write_manifest( + write_patches( tmp.path(), &["pkg:npm/fsevents@2.3.3", "pkg:npm/ghost@1.0.0"], ); - let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"]); + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_eq!( code, 1, @@ -242,10 +197,14 @@ fn unresolved_purl_still_fails_alongside_a_lock_resolved_one() { ); assert_eq!(v["status"], "partialFailure", "{v}"); for purl in ["pkg:npm/fsevents@2.3.3", "pkg:npm/ghost@1.0.0"] { - assert_eq!(event(&v, purl)["errorCode"], "package_not_installed", "{v}"); + assert_eq!( + event_for(&v, purl)["errorCode"], + "package_not_installed", + "{v}" + ); } - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"]); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); assert_eq!(code, 1, "{stderr}"); assert!( stderr.contains("Error: The targeted manifest patch matched no installed package:") @@ -271,9 +230,9 @@ fn no_lockfile_all_miss_still_fails() { let tmp = tempfile::tempdir().unwrap(); write_optional_skipped_project(tmp.path()); std::fs::remove_file(tmp.path().join("package-lock.json")).unwrap(); - write_manifest(tmp.path(), &["pkg:npm/fsevents@2.3.3"]); + write_patches(tmp.path(), &["pkg:npm/fsevents@2.3.3"]); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"]); + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); assert_eq!(code, 1, "{stderr}"); assert!( stderr.contains("Error: The targeted manifest patch matched no installed package:"), @@ -314,26 +273,16 @@ fn write_cargo_project(root: &Path) { std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); } -fn run_cargo_apply(cwd: &Path, cargo_home: &Path, args: &[&str]) -> (i32, String, String) { - let mut argv: Vec<&str> = vec!["apply"]; - argv.extend_from_slice(args); - let home = cargo_home.to_str().unwrap(); - run_with_env( - cwd, - &argv, - &[("SOCKET_TELEMETRY_DISABLED", "1"), ("CARGO_HOME", home)], - ) -} - fn assert_cold_cargo_apply_fails(project: &Path, cargo_home: &Path) { - let (code, stdout, stderr) = run_cargo_apply(project, cargo_home, &["--offline", "--json"]); + let cargo_env = [("CARGO_HOME", cargo_home.to_str().unwrap())]; + let (code, stdout, stderr) = run_apply(project, &["--offline", "--json"], &cargo_env); let v = parse_json_envelope(&stdout); assert_eq!( code, 1, "an unfetched cargo crate must fail apply, not skip calmly; {v}\n{stderr}" ); assert_eq!(v["status"], "partialFailure", "{v}"); - let ev = event(&v, CARGO_PURL); + let ev = event_for(&v, CARGO_PURL); assert_eq!(ev["errorCode"], "package_not_installed", "{ev}"); let detail = ev.to_string(); assert!( @@ -342,7 +291,7 @@ fn assert_cold_cargo_apply_fails(project: &Path, cargo_home: &Path) { ); // Human path: the exit-flipping error plus the actionable remedy. - let (code, _stdout, stderr) = run_cargo_apply(project, cargo_home, &["--offline"]); + let (code, _stdout, stderr) = run_apply(project, &["--offline"], &cargo_env); assert_eq!(code, 1, "{stderr}"); assert!( stderr.contains("Error: The targeted manifest patch matched no installed package:") @@ -352,7 +301,7 @@ fn assert_cold_cargo_apply_fails(project: &Path, cargo_home: &Path) { ); // `--silent` (the hook / CI shape) still prints the error and exits 1. - let (code, _stdout, stderr) = run_cargo_apply(project, cargo_home, &["--offline", "--silent"]); + let (code, _stdout, stderr) = run_apply(project, &["--offline", "--silent"], &cargo_env); assert_eq!(code, 1, "{stderr}"); assert!(stderr.contains("cargo fetch"), "{stderr}"); } @@ -365,7 +314,7 @@ fn cargo_crate_on_cold_registry_cache_fails_with_fetch_remedy() { let project = tmp.path().join("app"); std::fs::create_dir_all(&project).unwrap(); write_cargo_project(&project); - write_manifest(&project, &[CARGO_PURL]); + write_patches(&project, &[CARGO_PURL]); let cargo_home = tmp.path().join("cargo-home"); std::fs::create_dir_all(&cargo_home).unwrap(); @@ -380,7 +329,7 @@ fn cargo_crate_with_pruned_registry_src_fails_with_fetch_remedy() { let project = tmp.path().join("app"); std::fs::create_dir_all(&project).unwrap(); write_cargo_project(&project); - write_manifest(&project, &[CARGO_PURL]); + write_patches(&project, &[CARGO_PURL]); let cargo_home = tmp.path().join("cargo-home"); let cache = cargo_home.join("registry/cache/index.crates.io-1949cf8c6b5b557f"); std::fs::create_dir_all(&cache).unwrap(); diff --git a/crates/socket-patch-cli/tests/apply/pnpm_global_virtual_store.rs b/crates/socket-patch-cli/tests/apply/pnpm_global_virtual_store.rs index 1c84dd21c..ead9a31cb 100644 --- a/crates/socket-patch-cli/tests/apply/pnpm_global_virtual_store.rs +++ b/crates/socket-patch-cli/tests/apply/pnpm_global_virtual_store.rs @@ -11,32 +11,15 @@ use std::path::{Path, PathBuf}; -use serde_json::{json, Value}; +use serde_json::json; -use crate::common; - -use common::{git_sha256, parse_json_envelope, run_with_env}; +use super::apply_fixture::{link_dir, one_file, patch_record, stage_blob, write_manifest}; +use crate::common::envelope::event_for; +use crate::common::{git_sha256, parse_json_envelope, run_with_env}; const BEFORE: &[u8] = b"module.exports = 'pristine';\n"; const AFTER: &[u8] = b"module.exports = 'patched';\n"; -fn link_dir(target: &Path, link: &Path) { - #[cfg(unix)] - std::os::unix::fs::symlink(target, link).unwrap(); - #[cfg(windows)] - { - let link: PathBuf = link.components().collect(); - let target: PathBuf = target.components().collect(); - let status = std::process::Command::new("cmd") - .args(["/C", "mklink", "/J"]) - .arg(&link) - .arg(&target) - .status() - .unwrap(); - assert!(status.success(), "mklink /J failed"); - } -} - fn write_pkg(dir: &Path, name: &str, version: &str) { std::fs::create_dir_all(dir).unwrap(); std::fs::write( @@ -90,36 +73,15 @@ fn stage(tmp: &Path) -> (PathBuf, PathBuf, PathBuf) { (proj, number, left_pad) } -fn write_manifest(root: &Path, purl: &str) { - let socket = root.join(".socket"); - std::fs::create_dir_all(socket.join("blobs")).unwrap(); - std::fs::write(socket.join("blobs").join(git_sha256(AFTER)), AFTER).unwrap(); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&json!({ "patches": { purl: { - "uuid": "36236236-0000-4000-8000-000000000001", - "exportedAt": "2024-01-01T00:00:00Z", - "files": { "package/index.js": { - "beforeHash": git_sha256(BEFORE), - "afterHash": git_sha256(AFTER), - }}, - "vulnerabilities": {}, - "description": "pnpm global virtual store fixture", - "license": "MIT", - "tier": "free" - }}})) - .unwrap(), - ) - .unwrap(); -} - -fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { - v["events"] - .as_array() - .expect("events array") - .iter() - .find(|e| e["purl"] == purl) - .unwrap_or_else(|| panic!("no event for {purl}: {v}")) +fn write_patch(root: &Path, purl: &str) { + write_manifest( + root, + json!({ purl: patch_record( + "36236236-0000-4000-8000-000000000001", + one_file("package/index.js", BEFORE, AFTER), + )}), + ); + stage_blob(root, &git_sha256(AFTER), AFTER); } /// #362: the transitive `is-number` is refused as shared, like a direct @@ -129,20 +91,16 @@ fn event<'a>(v: &'a Value, purl: &str) -> &'a Value { fn transitive_global_virtual_store_dep_is_refused_not_skipped() { let tmp = tempfile::tempdir().unwrap(); let (proj, number, _) = stage(tmp.path()); - write_manifest(&proj, "pkg:npm/is-number@6.0.0"); + write_patch(&proj, "pkg:npm/is-number@6.0.0"); - let (code, stdout, stderr) = run_with_env( - &proj, - &["apply", "--offline", "--json"], - &[("SOCKET_TELEMETRY_DISABLED", "1")], - ); + let (code, stdout, stderr) = run_with_env(&proj, &["apply", "--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_ne!( code, 0, "a shared-store refusal fails the run; {v}\n{stderr}" ); assert_eq!(v["status"], "partialFailure", "{v}"); - let ev = event(&v, "pkg:npm/is-number@6.0.0"); + let ev = event_for(&v, "pkg:npm/is-number@6.0.0"); assert_ne!(ev["errorCode"], "package_not_installed", "{ev}"); assert!( ev.to_string().contains("shared by other projects") @@ -158,16 +116,12 @@ fn transitive_global_virtual_store_dep_is_refused_not_skipped() { fn other_projects_global_virtual_store_entries_stay_invisible() { let tmp = tempfile::tempdir().unwrap(); let (proj, _, left_pad) = stage(tmp.path()); - write_manifest(&proj, "pkg:npm/left-pad@1.3.0"); + write_patch(&proj, "pkg:npm/left-pad@1.3.0"); - let (code, stdout, stderr) = run_with_env( - &proj, - &["apply", "--offline", "--json"], - &[("SOCKET_TELEMETRY_DISABLED", "1")], - ); + let (code, stdout, stderr) = run_with_env(&proj, &["apply", "--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_ne!(code, 0, "{v}\n{stderr}"); - let ev = event(&v, "pkg:npm/left-pad@1.3.0"); + let ev = event_for(&v, "pkg:npm/left-pad@1.3.0"); assert_eq!(ev["errorCode"], "package_not_installed", "{ev}"); assert_eq!(std::fs::read(left_pad.join("index.js")).unwrap(), BEFORE); } @@ -202,16 +156,12 @@ fn workspace_member_transitive_global_virtual_store_dep_is_refused() { ) .unwrap(); link_dir(&odd, &member_nm.join("is-odd")); - write_manifest(&proj, "pkg:npm/is-number@6.0.0"); + write_patch(&proj, "pkg:npm/is-number@6.0.0"); - let (code, stdout, stderr) = run_with_env( - &proj, - &["apply", "--offline", "--json"], - &[("SOCKET_TELEMETRY_DISABLED", "1")], - ); + let (code, stdout, stderr) = run_with_env(&proj, &["apply", "--offline", "--json"], &[]); let v = parse_json_envelope(&stdout); assert_ne!(code, 0, "{v}\n{stderr}"); - let ev = event(&v, "pkg:npm/is-number@6.0.0"); + let ev = event_for(&v, "pkg:npm/is-number@6.0.0"); assert_ne!(ev["errorCode"], "package_not_installed", "{ev}"); assert!(ev.to_string().contains("enableGlobalVirtualStore"), "{ev}"); assert_eq!(std::fs::read(number.join("index.js")).unwrap(), BEFORE); @@ -245,16 +195,16 @@ fn workspace_member_refusal_holds_when_run_from_the_member() { ) .unwrap(); link_dir(&odd, &member_nm.join("is-odd")); - write_manifest(&member, "pkg:npm/is-number@6.0.0"); + write_patch(&member, "pkg:npm/is-number@6.0.0"); let (code, stdout, stderr) = run_with_env( &member, &["apply", "--offline", "--json", "--cwd", "."], - &[("SOCKET_TELEMETRY_DISABLED", "1")], + &[], ); let v = parse_json_envelope(&stdout); assert_ne!(code, 0, "{v}\n{stderr}"); - let ev = event(&v, "pkg:npm/is-number@6.0.0"); + let ev = event_for(&v, "pkg:npm/is-number@6.0.0"); assert_ne!(ev["errorCode"], "package_not_installed", "{ev}"); assert!(ev.to_string().contains("enableGlobalVirtualStore"), "{ev}"); assert_eq!(std::fs::read(number.join("index.js")).unwrap(), BEFORE); diff --git a/crates/socket-patch-cli/tests/common/envelope.rs b/crates/socket-patch-cli/tests/common/envelope.rs index 948c2f7e2..b0183ff87 100644 --- a/crates/socket-patch-cli/tests/common/envelope.rs +++ b/crates/socket-patch-cli/tests/common/envelope.rs @@ -65,6 +65,15 @@ pub fn find_event<'a>(envelope: &'a Value, action: &str, error_code: Option<&str }) } +/// The first event for `purl`; panics with the envelope when there is +/// none. +pub fn event_for<'a>(envelope: &'a Value, purl: &str) -> &'a Value { + events(envelope) + .iter() + .find(|e| e["purl"] == purl) + .unwrap_or_else(|| panic!("no event for {purl} in:\n{envelope:#}")) +} + /// Each event as `(purl, action, errorCode)`, absent fields as `""`. pub fn event_triples(envelope: &Value) -> Vec<(String, String, String)> { events(envelope) From e89dde25d2df9c54179e7fb3067a32ccbb00eecf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:10:37 +0000 Subject: [PATCH 3/3] Apply /code-review findings on apply fixtures Gem suites that pin --cwd run their child from the runner's directory again, so a crawler reading the process cwd still fails them. apply_invariants runs apply through apply_fixture::run_apply, the --cwd remedy test stages its blob with stage_blob, and the gem --check fixture derives its specifications dir from install_gem's path. bun_global_store and lockfile_only_skip build their records through one index_js_patches helper. apply_network passes bytes to one_file instead of precomputed hashes. Stale module docs updated. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/apply/apply_fixture.rs | 24 +++++++++++- .../tests/apply/apply_invariants.rs | 37 ++++++------------ .../tests/apply/apply_network.rs | 39 +++++++------------ .../tests/apply/apply_render.rs | 2 +- .../tests/apply/bun_global_store.rs | 13 +------ .../apply/check_verifies_installed_tree.rs | 16 +++----- .../apply/in_process_gem_config_warning.rs | 6 ++- .../apply/in_process_gem_fallback_home.rs | 2 +- .../tests/apply/lockfile_only_skip.rs | 19 +++------ 9 files changed, 70 insertions(+), 88 deletions(-) diff --git a/crates/socket-patch-cli/tests/apply/apply_fixture.rs b/crates/socket-patch-cli/tests/apply/apply_fixture.rs index 64df44f8c..6429e7511 100644 --- a/crates/socket-patch-cli/tests/apply/apply_fixture.rs +++ b/crates/socket-patch-cli/tests/apply/apply_fixture.rs @@ -53,6 +53,22 @@ pub fn hashed_file(path: &str, before_hash: &str, after_hash: &str) -> Value { json!({ path: { "beforeHash": before_hash, "afterHash": after_hash } }) } +/// One `package/index.js` record from `before` to `after` per purl, the +/// i-th with uuid `-0000-4000-8000-`. +pub fn index_js_patches(purls: &[&str], uuid_prefix: &str, before: &[u8], after: &[u8]) -> Value { + let files = one_file("package/index.js", before, after); + Value::Object( + purls + .iter() + .enumerate() + .map(|(i, purl)| { + let uuid = format!("{uuid_prefix}-0000-4000-8000-{i:012}"); + (purl.to_string(), patch_record(&uuid, files.clone())) + }) + .collect(), + ) +} + /// Write `.socket/manifest.json`, creating `.socket/blobs/` alongside as /// a real project has it. pub fn write_manifest(root: &Path, patches: Value) { @@ -253,7 +269,9 @@ pub fn write_sourceless_npm_patches(root: &Path, n: u32) { // ═══════════ gem release-variant mismatch fixtures ═══════════ // // Shared by `cli_gem_variant_mismatch_policy.rs` (the `--json` -// contracts) and `apply_render.rs` (the human output). +// contracts) and `apply_render.rs` (the human output). `run_gem` also +// runs the gem_stale_bundler_plugin, in_process_gem_config_warning and +// in_process_gem_fallback_home children. /// The rack singleton's on-disk bytes after a local edit: they match /// neither the record's beforeHash nor its afterHash. @@ -265,7 +283,9 @@ pub const NOKOGIRI_DARWIN_MARKER: &[u8] = b"\n# DARWIN-MARKER\n"; /// Run `socket-patch ` in `cwd` like `common::run_with_env`, with /// the ambient `BUNDLE_*` also scrubbed so only the argv and `env` decide -/// what Bundler state the gem crawler reads. +/// what Bundler state the gem crawler reads. Suites that pin `--cwd` +/// pass `Path::new(".")` so the child stays in the runner's directory and +/// a crawler reading the process cwd instead of `--cwd` finds nothing. pub fn run_gem(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = hermetic_command(&binary()); cmd.args(args).current_dir(cwd); diff --git a/crates/socket-patch-cli/tests/apply/apply_invariants.rs b/crates/socket-patch-cli/tests/apply/apply_invariants.rs index 89939b2d0..bd31b1584 100644 --- a/crates/socket-patch-cli/tests/apply/apply_invariants.rs +++ b/crates/socket-patch-cli/tests/apply/apply_invariants.rs @@ -20,7 +20,7 @@ use std::path::Path; use serde_json::json; use super::apply_fixture::{ - hashed_file, install_npm_pkg, one_file, patch_record, stage_blob, write_manifest, + hashed_file, install_npm_pkg, one_file, patch_record, run_apply, stage_blob, write_manifest, write_root_package_json, }; use crate::common::{binary, git_sha256, hermetic_command}; @@ -44,20 +44,6 @@ fn write_project(root: &Path) { stage_blob(root, "sentinel", b"do not modify me"); } -fn run_apply(cwd: &Path, extra: &[&str]) -> (i32, String) { - let mut args = vec!["apply", "--json"]; - args.extend_from_slice(extra); - let out = hermetic_command(&binary()) - .args(&args) - .current_dir(cwd) - .output() - .expect("run socket-patch"); - ( - out.status.code().unwrap_or(-1), - String::from_utf8_lossy(&out.stdout).to_string(), - ) -} - /// Every counter in the envelope's `summary` block must be exactly 0. /// We enumerate the keys explicitly (rather than "applied == 0") so a /// regression that started reporting work on these no-op paths — e.g. a @@ -119,9 +105,10 @@ fn offline_ecosystems_filter_ignores_out_of_scope_missing_source() { let tmp = tempfile::tempdir().expect("tempdir"); write_mixed_scope_project(tmp.path()); - let (code, stdout) = run_apply( + let (code, stdout, _) = run_apply( tmp.path(), - &["--offline", "--silent", "--ecosystems", "npm"], + &["--json", "--offline", "--silent", "--ecosystems", "npm"], + &[], ); let v: serde_json::Value = serde_json::from_str(&stdout).expect("apply --json must emit valid JSON"); @@ -156,7 +143,7 @@ fn offline_ecosystems_filter_ignores_out_of_scope_missing_source() { // still fire — the guard is scoped, not disabled. let tmp2 = tempfile::tempdir().expect("tempdir"); write_mixed_scope_project(tmp2.path()); - let (code2, stdout2) = run_apply(tmp2.path(), &["--offline", "--silent"]); + let (code2, stdout2, _) = run_apply(tmp2.path(), &["--json", "--offline", "--silent"], &[]); assert_eq!( code2, 1, "with no ecosystem filter the sourceless pypi patch is in scope and \ @@ -172,7 +159,7 @@ fn offline_with_missing_source_emits_partial_failure() { let tmp = tempfile::tempdir().expect("tempdir"); write_project(tmp.path()); - let (code, stdout) = run_apply(tmp.path(), &["--offline", "--silent"]); + let (code, stdout, _) = run_apply(tmp.path(), &["--json", "--offline", "--silent"], &[]); // Exit code 1 is contract: any patch without a usable source under // `--offline` flips the run to partialFailure. @@ -215,7 +202,7 @@ fn apply_does_not_mutate_socket_dir_offline() { let socket = tmp.path().join(".socket"); let before = snapshot_dir(&socket); - let (code, stdout) = run_apply(tmp.path(), &["--offline", "--silent"]); + let (code, stdout, _) = run_apply(tmp.path(), &["--json", "--offline", "--silent"], &[]); let after = snapshot_dir(&socket); // The run must have actually taken the failure path we care about — @@ -243,7 +230,7 @@ fn apply_does_not_mutate_socket_dir_when_no_packages_match() { let socket = tmp.path().join(".socket"); let before = snapshot_dir(&socket); - let (code, stdout) = run_apply(tmp.path(), &["--silent"]); + let (code, stdout, _) = run_apply(tmp.path(), &["--json", "--silent"], &[]); let after = snapshot_dir(&socket); // Pin the bail itself, or a run that died early would leave @@ -290,7 +277,7 @@ fn apply_does_not_mutate_socket_dir_when_no_packages_match() { fn apply_with_no_socket_dir_emits_no_manifest_envelope() { let tmp = tempfile::tempdir().expect("tempdir"); // Note: NO .socket/ directory at all — completely fresh tree. - let (code, stdout) = run_apply(tmp.path(), &[]); + let (code, stdout, _) = run_apply(tmp.path(), &["--json"], &[]); assert_eq!(code, 0, "no-manifest is not an error; stdout=\n{stdout}"); let v: serde_json::Value = serde_json::from_str(&stdout).expect("envelope must be valid JSON"); assert_eq!(v["command"], "apply"); @@ -380,7 +367,7 @@ fn apply_with_unreadable_socket_dir_fails_closed() { return; } - let (code, stdout) = run_apply(tmp.path(), &[]); + let (code, stdout, _) = run_apply(tmp.path(), &["--json"], &[]); restore(); assert_ne!( @@ -451,7 +438,7 @@ fn unmatched_purl_exit_semantics_are_pinned() { // with the miss visible only as a skipped event. let tmp = tempfile::tempdir().expect("tempdir"); write_partial_match_project(tmp.path()); - let (code, stdout) = run_apply(tmp.path(), &["--offline", "--silent"]); + let (code, stdout, _) = run_apply(tmp.path(), &["--json", "--offline", "--silent"], &[]); let v: serde_json::Value = serde_json::from_str(&stdout).expect("apply --json must emit valid JSON"); assert_eq!(code, 0, "mixed manifest pins exit 0 today; envelope:\n{v}"); @@ -482,7 +469,7 @@ fn unmatched_purl_exit_semantics_are_pinned() { let tmp2 = tempfile::tempdir().expect("tempdir"); write_partial_match_project(tmp2.path()); std::fs::remove_dir_all(tmp2.path().join("node_modules")).expect("uninstall scopedpkg"); - let (code2, stdout2) = run_apply(tmp2.path(), &["--offline", "--silent"]); + let (code2, stdout2, _) = run_apply(tmp2.path(), &["--json", "--offline", "--silent"], &[]); let v2: serde_json::Value = serde_json::from_str(&stdout2).expect("apply --json must emit valid JSON"); assert_eq!( diff --git a/crates/socket-patch-cli/tests/apply/apply_network.rs b/crates/socket-patch-cli/tests/apply/apply_network.rs index 7de2be290..cc29a2329 100644 --- a/crates/socket-patch-cli/tests/apply/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply/apply_network.rs @@ -17,7 +17,7 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::apply_fixture::{ - hashed_file, install_npm_pkg, patch_record, stage_blob, write_manifest, write_root_package_json, + install_npm_pkg, one_file, patch_record, stage_blob, write_manifest, write_root_package_json, }; use crate::common::{binary, git_sha256, hermetic}; @@ -51,7 +51,6 @@ fn run_apply(cwd: &Path, api_url: &str, extra: &[&str]) -> (i32, String, String) async fn apply_online_fetches_missing_blob_and_patches_file() { let before = b"before\n"; let after = b"after\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); let mock = MockServer::start().await; @@ -74,7 +73,7 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { let socket = tmp.path().join(".socket"); write_manifest( tmp.path(), - json!({ purl: patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ purl: patch_record(uuid, one_file("package/index.js", before, after)) }), ); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &[]); @@ -167,8 +166,6 @@ async fn apply_online_fetches_missing_blob_and_patches_file() { async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { let before = b"before\n"; let after = b"after\n"; - let before_hash = git_sha256(before); - let after_hash = git_sha256(after); let mock = MockServer::start().await; let purl = "pkg:npm/skipped@1.0.0"; @@ -179,7 +176,7 @@ async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { install_npm_pkg(tmp.path(), "skipped", "1.0.0", Some(before)); write_manifest( tmp.path(), - json!({ purl: patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ purl: patch_record(uuid, one_file("package/index.js", before, after)) }), ); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--ecosystems", "pypi"]); @@ -250,7 +247,6 @@ async fn apply_with_ecosystem_filter_excluding_npm_skips_all_npm_patches() { async fn apply_dry_run_emits_verified_event_without_writing() { let before = b"before\n"; let after = b"after\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); let tmp = tempfile::tempdir().expect("tempdir"); @@ -258,7 +254,7 @@ async fn apply_dry_run_emits_verified_event_without_writing() { install_npm_pkg(tmp.path(), "dryrun-target", "1.0.0", Some(before)); write_manifest( tmp.path(), - json!({ "pkg:npm/dryrun-target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ "pkg:npm/dryrun-target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", one_file("package/index.js", before, after)) }), ); // Pre-stage the after blob so we don't need to mock the network // path; we just want to verify dry-run reports the action correctly. @@ -318,14 +314,13 @@ async fn apply_with_force_overrides_hash_mismatch() { let after_hash = git_sha256(after); let expected_before = b"expected-before\n"; let actual_before = b"DIFFERENT-CONTENT\n"; // wrong before content - let expected_before_hash = git_sha256(expected_before); let tmp = tempfile::tempdir().expect("tempdir"); write_root_package_json(tmp.path()); install_npm_pkg(tmp.path(), "force-target", "1.0.0", Some(actual_before)); write_manifest( tmp.path(), - json!({ "pkg:npm/force-target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &expected_before_hash, &after_hash)) }), + json!({ "pkg:npm/force-target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", one_file("package/index.js", expected_before, after)) }), ); // Pre-stage the after blob so we don't need the network. stage_blob(tmp.path(), &after_hash, after); @@ -372,7 +367,6 @@ async fn apply_hash_mismatch_opt_in_warns_and_applies_strict_fails() { let after_hash = git_sha256(after); let expected_before = b"expected-before\n"; let actual_before = b"DIFFERENT-CONTENT\n"; - let expected_before_hash = git_sha256(expected_before); let fixture = || { let tmp = tempfile::tempdir().expect("tempdir"); @@ -380,7 +374,7 @@ async fn apply_hash_mismatch_opt_in_warns_and_applies_strict_fails() { install_npm_pkg(tmp.path(), "mismatch", "1.0.0", Some(actual_before)); write_manifest( tmp.path(), - json!({ "pkg:npm/mismatch@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &expected_before_hash, &after_hash)) }), + json!({ "pkg:npm/mismatch@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", one_file("package/index.js", expected_before, after)) }), ); stage_blob(tmp.path(), &after_hash, after); tmp @@ -467,7 +461,6 @@ async fn apply_hash_mismatch_opt_in_warns_and_applies_strict_fails() { async fn apply_pypi_package_uses_python_crawler() { let before = b"def hello():\n return 'before'\n"; let after = b"def hello():\n return 'after'\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); let tmp = tempfile::tempdir().expect("tempdir"); @@ -503,7 +496,7 @@ async fn apply_pypi_package_uses_python_crawler() { write_manifest( tmp.path(), - json!({ "pkg:pypi/pypi_target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ "pkg:pypi/pypi_target@1.0.0": patch_record("11111111-1111-4111-8111-111111111111", one_file("package/index.js", before, after)) }), ); stage_blob(tmp.path(), &after_hash, after); @@ -552,7 +545,6 @@ async fn apply_pypi_package_uses_python_crawler() { async fn apply_uses_locally_cached_blob_without_fetching() { let before = b"before\n"; let after = b"after\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); let tmp = tempfile::tempdir().expect("tempdir"); @@ -561,7 +553,7 @@ async fn apply_uses_locally_cached_blob_without_fetching() { let socket = tmp.path().join(".socket"); write_manifest( tmp.path(), - json!({ "pkg:npm/cached@1.0.0": patch_record("22222222-2222-4222-8222-222222222222", hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ "pkg:npm/cached@1.0.0": patch_record("22222222-2222-4222-8222-222222222222", one_file("package/index.js", before, after)) }), ); stage_blob(tmp.path(), &after_hash, after); @@ -613,7 +605,6 @@ async fn apply_uses_locally_cached_blob_without_fetching() { async fn apply_mismatch_redownloads_full_blob_and_applies() { let after = b"after\n"; let after_hash = git_sha256(after); - let expected_before_hash = git_sha256(b"expected-before\n"); let mock = MockServer::start().await; Mock::given(method("GET")) @@ -636,7 +627,10 @@ async fn apply_mismatch_redownloads_full_blob_and_applies() { let socket = tmp.path().join(".socket"); write_manifest( tmp.path(), - json!({ "pkg:npm/mismatch@1.0.0": patch_record(uuid, hashed_file("package/index.js", &expected_before_hash, &after_hash)) }), + json!({ "pkg:npm/mismatch@1.0.0": patch_record( + uuid, + one_file("package/index.js", b"expected-before\n", after), + )}), ); // A leftover LOCAL package archive exists, but nothing reads it: only // the blob can produce the patched bytes, and no blob is cached. @@ -705,7 +699,6 @@ async fn apply_mismatch_redownloads_full_blob_and_applies() { async fn offline_apply_with_token_makes_zero_network_requests() { let before = b"offline airgap before\n"; let after = b"offline airgap after\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); // No mocks mounted: every request is a violation, and all are recorded. @@ -716,7 +709,7 @@ async fn offline_apply_with_token_makes_zero_network_requests() { install_npm_pkg(tmp.path(), "airgap-test", "1.0.0", Some(before)); write_manifest( tmp.path(), - json!({ "pkg:npm/airgap-test@1.0.0": patch_record("33333333-3333-4333-8333-333333333333", hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ "pkg:npm/airgap-test@1.0.0": patch_record("33333333-3333-4333-8333-333333333333", one_file("package/index.js", before, after)) }), ); stage_blob(tmp.path(), &after_hash, after); @@ -798,7 +791,6 @@ fn write_package_archive(packages: &Path, uuid: &str, entries: &[(&str, &[u8])]) async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { let before = b"pkgcache before\n"; let after = b"pkgcache after\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); let uuid = "44444444-4444-4444-8444-444444444444"; @@ -812,7 +804,7 @@ async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { let socket = tmp.path().join(".socket"); write_manifest( tmp.path(), - json!({ "pkg:npm/pkgcache@1.0.0": patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ "pkg:npm/pkgcache@1.0.0": patch_record(uuid, one_file("package/index.js", before, after)) }), ); write_package_archive( &socket.join("packages"), @@ -857,7 +849,6 @@ async fn apply_online_ignores_legacy_package_archive_when_downloads_fail() { async fn mismatch_overwrites_every_copy_of_a_duplicated_package() { let before = b"before\n"; let after = b"after\n"; - let before_hash = git_sha256(before); let after_hash = git_sha256(after); let mock = MockServer::start().await; @@ -889,7 +880,7 @@ async fn mismatch_overwrites_every_copy_of_a_duplicated_package() { let socket = tmp.path().join(".socket"); write_manifest( tmp.path(), - json!({ purl: patch_record(uuid, hashed_file("package/index.js", &before_hash, &after_hash)) }), + json!({ purl: patch_record(uuid, one_file("package/index.js", before, after)) }), ); let (code, stdout, stderr) = run_apply(tmp.path(), &mock.uri(), &["--allow-patch-conflicts"]); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs index 79b676bd0..7c6b4c171 100644 --- a/crates/socket-patch-cli/tests/apply/apply_render.rs +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -7,7 +7,7 @@ //! `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, the gem +//! yarn-PnP refusal, the gem //! variant mismatch warning and no-matching-variant error, the //! `--dry-run --vex` skip line, and the staging layer's offline //! no-local-source report and download result line. diff --git a/crates/socket-patch-cli/tests/apply/bun_global_store.rs b/crates/socket-patch-cli/tests/apply/bun_global_store.rs index e94788850..90be18994 100644 --- a/crates/socket-patch-cli/tests/apply/bun_global_store.rs +++ b/crates/socket-patch-cli/tests/apply/bun_global_store.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use serde_json::Value; -use super::apply_fixture::{link_dir, one_file, patch_record, stage_blob, write_manifest}; +use super::apply_fixture::{index_js_patches, link_dir, stage_blob, write_manifest}; use crate::common::envelope::event_for; use crate::common::{git_sha256, parse_json_envelope, run_with_env}; @@ -71,16 +71,7 @@ fn stage(tmp: &Path, bytes: &[u8]) -> (PathBuf, PathBuf, PathBuf) { } fn write_patches(root: &Path, purls: &[&str]) { - let patches = purls - .iter() - .enumerate() - .map(|(i, purl)| { - let uuid = format!("63563563-0000-4000-8000-00000000000{i}"); - let record = patch_record(&uuid, one_file("package/index.js", BEFORE, AFTER)); - (purl.to_string(), record) - }) - .collect(); - write_manifest(root, Value::Object(patches)); + write_manifest(root, index_js_patches(purls, "63563563", BEFORE, AFTER)); for bytes in [BEFORE, AFTER] { stage_blob(root, &git_sha256(bytes), bytes); } diff --git a/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs b/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs index d50d768f6..228b322b4 100644 --- a/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs +++ b/crates/socket-patch-cli/tests/apply/check_verifies_installed_tree.rs @@ -197,8 +197,10 @@ fn gem_project(installed: &[u8]) -> tempfile::TempDir { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap(); - install_gem(root, "nokogiri-1.16.5", "lib/nokogiri.rb", installed); - std::fs::create_dir_all(root.join("vendor/bundle/ruby/3.4.0/specifications")).unwrap(); + let file = install_gem(root, "nokogiri-1.16.5", "lib/nokogiri.rb", installed); + // `/gems//lib/nokogiri.rb` -> `/specifications`. + let ruby_dir = file.ancestors().nth(4).unwrap(); + std::fs::create_dir_all(ruby_dir.join("specifications")).unwrap(); write_manifest( root, json!({ GEM_LINUX: patch_record( @@ -362,19 +364,13 @@ fn run_remedy_then_check(cwd: &Path, remedy: &[String], check: &[&str]) -> (i32, /// Stores the patched bytes as the after-hash blob, so `apply --offline` /// can heal the tree. -fn store_after_blob(project_root: &Path) { - let blobs = project_root.join(".socket/blobs"); - std::fs::create_dir_all(&blobs).unwrap(); - std::fs::write(blobs.join(git_sha256(PATCHED)), PATCHED).unwrap(); -} - /// #1219: a `--cwd` check's remedy keeps `--cwd`. Before, it said plain /// `socket-patch apply`, which from the parent dir found no manifest, /// exited 0 and left the project unpatched. #[test] fn check_remedy_keeps_cwd_and_heals_the_checked_tree() { let tmp = project(Some(ORIGINAL)); - store_after_blob(tmp.path()); + stage_blob(tmp.path(), &git_sha256(PATCHED), PATCHED); let parent = tmp.path().parent().unwrap(); let dir = tmp.path().file_name().unwrap().to_str().unwrap(); @@ -397,7 +393,7 @@ fn check_remedy_keeps_cwd_and_heals_the_checked_tree() { #[test] fn check_remedy_keeps_global_prefix_and_heals_the_checked_tree() { let tmp = project(None); - store_after_blob(tmp.path()); + stage_blob(tmp.path(), &git_sha256(PATCHED), PATCHED); let prefix = tmp.path().join("global lib").join("node_modules"); let index = prefix.join("check-target/index.js"); std::fs::create_dir_all(index.parent().unwrap()).unwrap(); diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs index f3115fc61..7c9dfeabe 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_gem_config_warning.rs @@ -50,7 +50,11 @@ fn run(root: &Path, args: &[&str]) -> (i32, String, String) { let mut argv = args.to_vec(); let cwd = root.display().to_string(); argv.extend(["--cwd", cwd.as_str()]); - run_gem(root, &argv, &[("PATH", empty_path.to_str().unwrap())]) + run_gem( + Path::new("."), + &argv, + &[("PATH", empty_path.to_str().unwrap())], + ) } fn assert_warning_in(env: &serde_json::Value, value_fragment: &str, ctx: &str) { diff --git a/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs b/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs index 8b9550db9..851ef7fb9 100644 --- a/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs +++ b/crates/socket-patch-cli/tests/apply/in_process_gem_fallback_home.rs @@ -124,7 +124,7 @@ fn run_apply(fx: &Fixture, extra: &[&str]) -> (i32, String, String) { if let Some(store_root) = &fx.store_root { env.push(("BUNDLE_PATH", store_root.to_str().unwrap())); } - run_gem(fx.project.path(), &argv, &env) + run_gem(Path::new("."), &argv, &env) } fn parse_env(stdout: &str) -> serde_json::Value { diff --git a/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs b/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs index 46be5c61e..1ea37a29d 100644 --- a/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs +++ b/crates/socket-patch-cli/tests/apply/lockfile_only_skip.rs @@ -10,11 +10,10 @@ use std::path::Path; -use serde_json::{json, Value}; +use serde_json::json; use super::apply_fixture::{ - install_npm_pkg, one_file, patch_record, run_apply, stage_blob, write_manifest, MM_AFTER, - MM_BEFORE, + index_js_patches, install_npm_pkg, run_apply, stage_blob, write_manifest, MM_AFTER, MM_BEFORE, }; use crate::common::envelope::event_for; use crate::common::{git_sha256, parse_json_envelope}; @@ -22,16 +21,10 @@ use crate::common::{git_sha256, parse_json_envelope}; /// `.socket/manifest.json` holding one record per purl, with the after /// blob staged so the offline source guard is never what fires. fn write_patches(root: &Path, purls: &[&str]) { - let patches = purls - .iter() - .enumerate() - .map(|(i, purl)| { - let uuid = format!("40340340-0000-4000-8000-00000000000{i}"); - let record = patch_record(&uuid, one_file("package/index.js", MM_BEFORE, MM_AFTER)); - (purl.to_string(), record) - }) - .collect(); - write_manifest(root, Value::Object(patches)); + write_manifest( + root, + index_js_patches(purls, "40340340", MM_BEFORE, MM_AFTER), + ); stage_blob(root, &git_sha256(MM_AFTER), MM_AFTER); }