diff --git a/crates/socket-patch-cli/tests/apply/apply_fixture.rs b/crates/socket-patch-cli/tests/apply/apply_fixture.rs index 27719dac2..68c09fb76 100644 --- a/crates/socket-patch-cli/tests/apply/apply_fixture.rs +++ b/crates/socket-patch-cli/tests/apply/apply_fixture.rs @@ -9,7 +9,8 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Value}; -use crate::common::{git_sha256, run_with_env}; +use crate::common::hermetic::{self, Extra}; +use crate::common::{binary, git_sha256, hermetic_command, run_with_env}; pub const GO_PURL: &str = "pkg:golang/example.com/mod@v1.0.0"; pub const GO_COPY_DIR: &str = ".socket/go-patches/example.com/mod@v1.0.0"; @@ -93,8 +94,10 @@ pub fn write_root_package_json(root: &Path) { } /// Synthesize an installed gem under the cwd's vendor/bundle tree (the -/// layout the ruby crawler scans in local mode — same shape as -/// `cli_gem_variant_mismatch_policy.rs`). Returns the patchable file path. +/// layout the ruby crawler scans in local mode; a real `gem install` +/// produces exactly this `gems/-[-]` leaf, +/// verified against RubyGems on ruby 3.4). Returns the patchable file +/// path. pub fn install_gem(root: &Path, leaf: &str, file_rel: &str, contents: &[u8]) -> PathBuf { let gem_dir = root .join("vendor") @@ -225,3 +228,136 @@ pub fn write_sourceless_npm_patches(root: &Path, n: u32) { .collect(); write_manifest(root, Value::Object(patches)); } + +// ═══════════ gem release-variant mismatch fixtures ═══════════ +// +// Shared by `cli_gem_variant_mismatch_policy.rs` (the `--json` +// contracts) and `apply_render.rs` (the human output). + +/// The rack singleton's on-disk bytes after a local edit: they match +/// neither the record's beforeHash nor its afterHash. +pub const GEM_LOCAL: &[u8] = b"module Rack\n VERSION = '3.1.0'\nend\n# local tweak\n"; +pub const NOKOGIRI_LINUX_PRISTINE: &[u8] = b"module Nokogiri\n VERSION = '1.16.5'\nend\n"; +pub const NOKOGIRI_DARWIN_BEFORE: &[u8] = b"# nokogiri.rb from the arm64-darwin gem\n"; +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) { + let mut cmd = hermetic_command(&binary()); + cmd.arg("apply") + .args(["--offline", "--ecosystems", "gem"]) + .args(extra) + .current_dir(cwd); + hermetic::scrub_extra(&mut cmd, &[Extra::Bundle]); + 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(), + ) +} + +/// `pkg:gem/nokogiri@1.16.5?platform=`. +pub fn nokogiri_purl(platform: &str) -> String { + format!("pkg:gem/nokogiri@1.16.5?platform={platform}") +} + +/// Singleton fixture: one bare-PURL gem record (`GEM_BASE_PURL`) whose +/// only file was locally modified on disk (`GEM_LOCAL`); the afterHash +/// blob is staged so the offline apply has the full patched bytes. +/// Returns the on-disk file path and the exact patched bytes a correct +/// warn-overwrite must produce. +pub fn gem_singleton_mismatch_fixture(root: &Path) -> (PathBuf, Vec) { + let patched = [GEM_PRISTINE, b"\n# SOCKET-SINGLETON-PATCH\n"].concat(); + let file = install_gem(root, "rack-3.1.0", "lib/rack.rb", GEM_LOCAL); + write_manifest( + root, + json!({ + GEM_BASE_PURL: patch_record( + "31313131-3131-4131-8131-313131313131", + one_file("lib/rack.rb", GEM_PRISTINE, &patched), + ) + }), + ); + stage_blob(root, &git_sha256(&patched), &patched); + // Fixture sanity: the on-disk bytes must match neither hash, or the + // mismatch path under test is never taken. + assert_ne!(git_sha256(GEM_LOCAL), git_sha256(GEM_PRISTINE)); + assert_ne!(git_sha256(GEM_LOCAL), git_sha256(&patched)); + (file, patched) +} + +/// The x86_64-linux nokogiri record (`lib/nokogiri.rb`, pristine → +/// pristine + `NOKOGIRI_LINUX_MARKER`), blob staged. Returns the +/// patched bytes. +fn write_nokogiri_linux_record( + root: &Path, + patches: &mut serde_json::Map, +) -> Vec { + let patched = [NOKOGIRI_LINUX_PRISTINE, NOKOGIRI_LINUX_MARKER].concat(); + patches.insert( + nokogiri_purl("x86_64-linux"), + patch_record( + "41414141-4141-4141-8141-414141414141", + one_file("lib/nokogiri.rb", NOKOGIRI_LINUX_PRISTINE, &patched), + ), + ); + stage_blob(root, &git_sha256(&patched), &patched); + patched +} + +/// Qualified-singleton fixture: a lone `?platform=x86_64-linux` record +/// with the arm64-darwin gem installed, whose bytes match neither of the +/// record's hashes. Returns the on-disk file path. +pub fn gem_wrong_platform_fixture(root: &Path) -> PathBuf { + let file = install_gem( + root, + "nokogiri-1.16.5-arm64-darwin", + "lib/nokogiri.rb", + NOKOGIRI_DARWIN_BEFORE, + ); + let mut patches = serde_json::Map::new(); + let linux_patched = write_nokogiri_linux_record(root, &mut patches); + write_manifest(root, Value::Object(patches)); + // Fixture sanity: the darwin bytes must match neither hash, or the + // wrong-platform path under test is never taken. + assert_ne!( + git_sha256(NOKOGIRI_DARWIN_BEFORE), + git_sha256(NOKOGIRI_LINUX_PRISTINE) + ); + assert_ne!( + git_sha256(NOKOGIRI_DARWIN_BEFORE), + git_sha256(&linux_patched) + ); + file +} + +/// Multi-variant fixture: the x86_64-linux gem is installed with +/// `on_disk` bytes; the manifest has its record plus an arm64-darwin +/// sibling describing a DIFFERENT distribution whose beforeHash can never +/// match. Both afterHash blobs are staged. Returns the on-disk file path +/// and the linux and darwin patched bytes. +pub fn gem_multi_variant_fixture(root: &Path, on_disk: &[u8]) -> (PathBuf, Vec, Vec) { + let file = install_gem( + root, + "nokogiri-1.16.5-x86_64-linux", + "lib/nokogiri.rb", + on_disk, + ); + let mut patches = serde_json::Map::new(); + let linux_patched = write_nokogiri_linux_record(root, &mut patches); + let darwin_patched = [NOKOGIRI_DARWIN_BEFORE, NOKOGIRI_DARWIN_MARKER].concat(); + patches.insert( + nokogiri_purl("arm64-darwin"), + patch_record( + "42424242-4242-4242-8242-424242424242", + one_file("lib/nokogiri.rb", NOKOGIRI_DARWIN_BEFORE, &darwin_patched), + ), + ); + stage_blob(root, &git_sha256(&darwin_patched), &darwin_patched); + write_manifest(root, Value::Object(patches)); + (file, linux_patched, darwin_patched) +} diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs index 74d126129..84c54948d 100644 --- a/crates/socket-patch-cli/tests/apply/apply_render.rs +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -7,7 +7,8 @@ //! `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 +//! yarn-PnP refusal, the gem fallback-home skip warning, 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. @@ -401,6 +402,88 @@ mod gem_fallback_home_human { } } +// ═══════════ gem variant mismatch policy ═══════════ +// +// The human legs of `cli_gem_variant_mismatch_policy.rs`, which asserts +// the same cases through `--json` codes. + +const GEM_OVERWRITE_LINE: &str = "did not match the patch's expected original content"; + +/// The default-policy overwrite prints the npm-family mismatch warning, +/// naming the package and what happened to the file. +#[test] +fn gem_singleton_overwrite_prints_mismatch_warning() { + let tmp = tempfile::tempdir().unwrap(); + gem_singleton_mismatch_fixture(tmp.path()); + let (code, _stdout, stderr) = run_gem_apply(tmp.path(), &[]); + assert_eq!(code, 0, "stderr={stderr}"); + let warning = stderr + .lines() + .find(|l| l.contains(GEM_OVERWRITE_LINE)) + .unwrap_or_else(|| panic!("no mismatch warning; stderr={stderr}")); + assert!(warning.contains(GEM_BASE_PURL), "stderr={stderr}"); + assert!( + warning.contains("applied the full verified patched content"), + "stderr={stderr}" + ); +} + +/// `--strict` prints the per-package failure with the hash refusal, not +/// the overwrite warning. +#[test] +fn gem_singleton_strict_prints_hash_refusal() { + let tmp = tempfile::tempdir().unwrap(); + gem_singleton_mismatch_fixture(tmp.path()); + let (code, _stdout, stderr) = run_gem_apply(tmp.path(), &["--strict"]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains(&format!("Failed to patch {GEM_BASE_PURL}")), + "stderr={stderr}" + ); + assert!( + stderr.contains("File hash does not match expected value"), + "stderr={stderr}" + ); + assert!(!stderr.contains(GEM_OVERWRITE_LINE), "stderr={stderr}"); +} + +/// A base with no matching variant prints the no-matching-variant error, +/// never the overwrite warning. +fn assert_no_matching_variant_error(root: &std::path::Path) { + let (code, _stdout, stderr) = run_gem_apply(root, &[]); + assert_eq!(code, 1, "stderr={stderr}"); + assert!( + stderr.contains("no matching variant found"), + "stderr={stderr}" + ); + assert!(!stderr.contains(GEM_OVERWRITE_LINE), "stderr={stderr}"); +} + +#[test] +fn gem_wrong_platform_prints_no_matching_variant() { + let tmp = tempfile::tempdir().unwrap(); + gem_wrong_platform_fixture(tmp.path()); + assert_no_matching_variant_error(tmp.path()); +} + +#[test] +fn gem_multi_variant_none_matching_prints_no_matching_variant() { + let tmp = tempfile::tempdir().unwrap(); + let local = [NOKOGIRI_LINUX_PRISTINE, b"# local tweak\n"].concat(); + gem_multi_variant_fixture(tmp.path(), &local); + assert_no_matching_variant_error(tmp.path()); +} + +/// A skipped sibling variant prints no overwrite warning. +#[test] +fn gem_mismatched_sibling_prints_no_overwrite_warning() { + let tmp = tempfile::tempdir().unwrap(); + gem_multi_variant_fixture(tmp.path(), NOKOGIRI_LINUX_PRISTINE); + let (code, _stdout, stderr) = run_gem_apply(tmp.path(), &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert!(!stderr.contains(GEM_OVERWRITE_LINE), "stderr={stderr}"); +} + // ═══════════ dry-run --vex ═══════════ /// `apply --dry-run --vex ` announces that VEX generation is skipped. diff --git a/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs b/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs index d47c732b3..0e6570a52 100644 --- a/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs +++ b/crates/socket-patch-cli/tests/apply/cli_gem_variant_mismatch_policy.rs @@ -25,123 +25,27 @@ //! (a sibling mismatch means "different distribution", not "locally //! modified"). When NO variant matches, the base still fails with //! "no matching variant found" and the file stays untouched. +//! +//! Behavior is asserted through the `--json` envelope (exit code, +//! `status`, event actions and `errorCode`s) and the bytes on disk. The +//! human wording of each case lives in `apply_render.rs` (#1089); both +//! files share the fixtures in `apply_fixture.rs`. In `--json` a no-matching-variant miss +//! shows as exit 1 with `status: partialFailure` and no `applied` or +//! `failed` event for the base. -use crate::common::hermetic::{self, Extra}; -use crate::common::{binary, git_sha256, hermetic_command}; - -use std::path::{Path, PathBuf}; - -const SINGLETON_PURL: &str = "pkg:gem/rack@3.1.0"; -const UUID_SINGLETON: &str = "31313131-3131-4131-8131-313131313131"; - -const PRISTINE: &[u8] = b"module Rack\n VERSION = '3.1.0'\nend\n"; -const LOCAL: &[u8] = b"module Rack\n VERSION = '3.1.0'\nend\n# local tweak\n"; -const PATCH_MARKER: &[u8] = b"\n# SOCKET-SINGLETON-PATCH\n"; - -const MULTI_NAME: &str = "nokogiri"; -const MULTI_VERSION: &str = "1.16.5"; -const UUID_LINUX: &str = "41414141-4141-4141-8141-414141414141"; -const UUID_DARWIN: &str = "42424242-4242-4242-8242-424242424242"; - -const LINUX_PRISTINE: &[u8] = b"module Nokogiri\n VERSION = '1.16.5'\nend\n"; -const LINUX_MARKER: &[u8] = b"\n# SOCKET-LINUX-PATCH\n"; -const DARWIN_BEFORE: &[u8] = b"# nokogiri.rb from the arm64-darwin gem\n"; -const DARWIN_MARKER: &[u8] = b"\n# DARWIN-MARKER\n"; - -fn with_marker(base: &[u8], marker: &[u8]) -> Vec { - let mut v = base.to_vec(); - v.extend_from_slice(marker); - v -} - -/// 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. -fn run_apply(cwd: &Path, extra: &[&str]) -> (i32, String, String) { - let mut cmd = hermetic_command(&binary()); - cmd.arg("apply") - .args(["--offline", "--ecosystems", "gem"]) - .args(extra) - .current_dir(cwd); - hermetic::scrub_extra(&mut cmd, &[Extra::Bundle]); - 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(), - ) -} - -/// Synthesize an installed gem under the cwd's vendor/bundle tree (what -/// the ruby crawler scans in local mode; a real `gem install` produces -/// exactly this `gems/-[-]` leaf — verified -/// against RubyGems on ruby 3.4). Returns the patchable file path. -fn install_gem(cwd: &Path, leaf: &str, file_rel: &str, contents: &[u8]) -> PathBuf { - let gem_dir = cwd - .join("vendor") - .join("bundle") - .join("ruby") - .join("3.4.0") - .join("gems") - .join(leaf); - let file = gem_dir.join(file_rel); - std::fs::create_dir_all(file.parent().unwrap()).expect("create gem dir"); - std::fs::write(&file, contents).expect("write gem file"); - file -} - -fn patch_record(uuid: &str, file: &str, before_hash: &str, after_hash: &str) -> serde_json::Value { - serde_json::json!({ - "uuid": uuid, - "exportedAt": "2024-01-01T00:00:00Z", - "files": { file: { "beforeHash": before_hash, "afterHash": after_hash } }, - "vulnerabilities": {}, - "description": "gem variant mismatch fixture", - "license": "MIT", - "tier": "free" - }) -} - -fn write_socket_dir(cwd: &Path, patches: serde_json::Value, blobs: &[(&str, &[u8])]) { - let socket = cwd.join(".socket"); - let blobs_dir = socket.join("blobs"); - std::fs::create_dir_all(&blobs_dir).expect("create .socket/blobs"); - std::fs::write( - socket.join("manifest.json"), - serde_json::to_vec_pretty(&serde_json::json!({ "patches": patches })).unwrap(), - ) - .expect("write manifest"); - for (hash, content) in blobs { - std::fs::write(blobs_dir.join(hash), content).expect("write blob"); - } -} +use crate::apply_fixture::{ + gem_multi_variant_fixture, gem_singleton_mismatch_fixture, gem_wrong_platform_fixture, + nokogiri_purl, run_gem_apply, GEM_BASE_PURL, GEM_LOCAL, NOKOGIRI_DARWIN_BEFORE, + NOKOGIRI_DARWIN_MARKER, NOKOGIRI_LINUX_PRISTINE, +}; +use crate::common::envelope::{ + assert_envelope_invariants, event_codes, event_purls, find_event, has_event, + parse_json_envelope, +}; +use crate::common::git_sha256; -/// Singleton fixture: one bare-PURL gem record whose only file was -/// locally modified on disk (matches NEITHER beforeHash nor afterHash); -/// the afterHash blob is staged so the offline apply has the full -/// patched bytes available. Returns the on-disk file path and the exact -/// patched bytes a correct warn-overwrite must produce. -fn singleton_fixture(cwd: &Path) -> (PathBuf, Vec) { - let patched = with_marker(PRISTINE, PATCH_MARKER); - let file = install_gem(cwd, "rack-3.1.0", "lib/rack.rb", LOCAL); - write_socket_dir( - cwd, - serde_json::json!({ - SINGLETON_PURL: patch_record( - UUID_SINGLETON, - "lib/rack.rb", - &git_sha256(PRISTINE), - &git_sha256(&patched), - ) - }), - &[(&git_sha256(&patched), patched.as_slice())], - ); - // Fixture sanity: the on-disk bytes must match neither hash, or the - // mismatch path under test is never taken. - assert_ne!(git_sha256(LOCAL), git_sha256(PRISTINE)); - assert_ne!(git_sha256(LOCAL), git_sha256(&patched)); - (file, patched) -} +/// The per-file warning code of a default-policy overwrite. +const OVERWRITTEN: &str = "content_mismatch_overwritten"; /// Default policy: the singleton's local modification is overwritten with /// the full verified patched content and surfaced as a warning — the same @@ -149,52 +53,31 @@ fn singleton_fixture(cwd: &Path) -> (PathBuf, Vec) { #[test] fn singleton_mismatch_default_warns_and_applies() { let tmp = tempfile::tempdir().expect("tempdir"); - let (file, patched) = singleton_fixture(tmp.path()); + let (file, patched) = gem_singleton_mismatch_fixture(tmp.path()); - let (code, _stdout, stderr) = run_apply(tmp.path(), &[]); + let (code, stdout, _stderr) = run_gem_apply(tmp.path(), &["--json"]); assert_eq!( code, 0, - "default mismatch on a singleton variant is a warning, not an error; stderr={stderr}" + "default mismatch on a singleton variant is a warning, not an error; stdout={stdout}" ); assert_eq!( std::fs::read(&file).expect("read patched file"), patched, "the file must carry exactly the verified patched bytes" ); - assert!( - stderr.contains("did not match the patch's expected original content"), - "the overwrite must be surfaced as the npm-family mismatch warning; stderr={stderr}" - ); - assert!( - stderr.contains(SINGLETON_PURL), - "the warning must name the package; stderr={stderr}" - ); - assert!( - stderr.contains("applied the full verified patched content"), - "the warning must say what happened to the file; stderr={stderr}" - ); - - // JSON envelope twin: `applied` event for the purl plus the per-file - // warning event — the same shape apply_network pins for npm. - let tmp = tempfile::tempdir().expect("tempdir"); - let (file, patched) = singleton_fixture(tmp.path()); - let (code, stdout, _stderr) = run_apply(tmp.path(), &["--json"]); - assert_eq!(code, 0, "json run must also succeed; stdout={stdout}"); - assert_eq!(std::fs::read(&file).expect("read patched file"), patched); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "apply"); assert_eq!(v["status"], "success", "{v:#}"); - let events = v["events"].as_array().expect("events array"); - assert!( - events - .iter() - .any(|e| e["action"] == "applied" && e["purl"] == SINGLETON_PURL), - "singleton must be reported applied: {events:?}" + assert_eq!( + event_purls(&v, "applied"), + [GEM_BASE_PURL], + "singleton must be reported applied: {v:#}" ); + // The overwrite rides as the npm-family per-file warning event, the + // same shape apply_network pins for npm, naming the package. assert!( - events - .iter() - .any(|e| e["errorCode"] == "content_mismatch_overwritten"), - "the overwrite must ride as a warning event: {events:?}" + has_event(&v, "skipped", OVERWRITTEN, GEM_BASE_PURL), + "the overwrite must ride as a warning event: {v:#}" ); } @@ -203,30 +86,34 @@ fn singleton_mismatch_default_warns_and_applies() { #[test] fn singleton_mismatch_strict_refuses() { let tmp = tempfile::tempdir().expect("tempdir"); - let (file, _patched) = singleton_fixture(tmp.path()); + let (file, _patched) = gem_singleton_mismatch_fixture(tmp.path()); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--strict"]); + let (code, stdout, _stderr) = run_gem_apply(tmp.path(), &["--strict", "--json"]); assert_eq!( code, 1, - "--strict must refuse the mismatch; stderr={stderr}" + "--strict must refuse the mismatch; stdout={stdout}" ); assert_eq!( std::fs::read(&file).expect("read file"), - LOCAL, + GEM_LOCAL, "--strict must not modify the file" ); + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "apply"); + assert_eq!(v["status"], "partialFailure", "{v:#}"); + // The package fails on the per-file hash refusal (`apply_failed`), + // not the no-matching-variant miss, which reports no `failed` event. + let failed = find_event(&v, "failed", Some("apply_failed")); + assert_eq!(failed["purl"], GEM_BASE_PURL, "{v:#}"); assert!( - stderr.contains(&format!("Failed to patch {SINGLETON_PURL}")), - "--strict must fail the package with a per-package error; stderr={stderr}" - ); - assert!( - stderr.contains("File hash does not match expected value"), - "--strict must name the actual refusal (the hash mismatch), not a \ - generic no-matching-variant miss; stderr={stderr}" + failed["error"] + .as_str() + .is_some_and(|e| e.contains("lib/rack.rb")), + "the refusal must name the mismatched file: {v:#}" ); assert!( - !stderr.contains("did not match the patch's expected original content"), - "--strict must not claim an overwrite happened; stderr={stderr}" + !event_codes(&v).iter().any(|c| c == OVERWRITTEN), + "--strict must not claim an overwrite happened: {v:#}" ); } @@ -235,19 +122,23 @@ fn singleton_mismatch_strict_refuses() { #[test] fn singleton_mismatch_force_still_applies() { let tmp = tempfile::tempdir().expect("tempdir"); - let (file, patched) = singleton_fixture(tmp.path()); + let (file, patched) = gem_singleton_mismatch_fixture(tmp.path()); - let (code, _stdout, stderr) = run_apply(tmp.path(), &["--force"]); - assert_eq!(code, 0, "--force must keep applying; stderr={stderr}"); + let (code, stdout, _stderr) = run_gem_apply(tmp.path(), &["--force", "--json"]); + assert_eq!(code, 0, "--force must keep applying; stdout={stdout}"); assert_eq!( std::fs::read(&file).expect("read patched file"), patched, "--force must overwrite with the verified patched bytes" ); -} - -fn multi_purl(platform: &str) -> String { - format!("pkg:gem/{MULTI_NAME}@{MULTI_VERSION}?platform={platform}") + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "apply"); + assert_eq!(v["status"], "success", "{v:#}"); + assert_eq!(event_purls(&v, "applied"), [GEM_BASE_PURL], "{v:#}"); + assert!( + has_event(&v, "skipped", OVERWRITTEN, GEM_BASE_PURL), + "{v:#}" + ); } /// A QUALIFIED singleton keeps the fail-closed gate: a lone @@ -263,90 +154,32 @@ fn multi_purl(platform: &str) -> String { #[test] fn qualified_singleton_wrong_platform_fails_closed() { let tmp = tempfile::tempdir().expect("tempdir"); - let linux_patched = with_marker(LINUX_PRISTINE, LINUX_MARKER); - // On disk: the arm64-darwin gem, whose bytes match NEITHER of the - // linux record's hashes. - let file = install_gem( - tmp.path(), - &format!("{MULTI_NAME}-{MULTI_VERSION}-arm64-darwin"), - &format!("lib/{MULTI_NAME}.rb"), - DARWIN_BEFORE, - ); - write_socket_dir( - tmp.path(), - serde_json::json!({ - multi_purl("x86_64-linux"): patch_record( - UUID_LINUX, - "lib/nokogiri.rb", - &git_sha256(LINUX_PRISTINE), - &git_sha256(&linux_patched), - ), - }), - &[(&git_sha256(&linux_patched), linux_patched.as_slice())], - ); - // Fixture sanity: the darwin bytes must match neither hash, or the - // wrong-platform path under test is never taken. - assert_ne!(git_sha256(DARWIN_BEFORE), git_sha256(LINUX_PRISTINE)); - assert_ne!(git_sha256(DARWIN_BEFORE), git_sha256(&linux_patched)); + let file = gem_wrong_platform_fixture(tmp.path()); - let (code, _stdout, stderr) = run_apply(tmp.path(), &[]); + let (code, stdout, _stderr) = run_gem_apply(tmp.path(), &["--json"]); assert_eq!( code, 1, - "a qualified singleton whose distribution is not on disk must fail closed; stderr={stderr}" - ); - assert!( - stderr.contains("no matching variant found"), - "the failure must stay the no-matching-variant error; stderr={stderr}" + "a qualified singleton whose distribution is not on disk must fail closed; stdout={stdout}" ); + let v = parse_json_envelope(&stdout); + assert_envelope_invariants(&v, "apply"); + assert_eq!(v["status"], "partialFailure", "{v:#}"); + assert!(event_purls(&v, "applied").is_empty(), "{v:#}"); + // No variant was attempted, so no `failed` event either: that tells + // this miss apart from a variant that was tried and refused. + assert!(event_purls(&v, "failed").is_empty(), "{v:#}"); assert!( - !stderr.contains("did not match the patch's expected original content"), + !event_codes(&v).iter().any(|c| c == OVERWRITTEN), "a wrong-distribution record must never be surfaced as a \ - local-modification overwrite; stderr={stderr}" + local-modification overwrite: {v:#}" ); assert_eq!( std::fs::read(&file).expect("read file"), - DARWIN_BEFORE, + NOKOGIRI_DARWIN_BEFORE, "the installed distribution's bytes must be untouched" ); } -/// Multi-variant fixture: the x86_64-linux variant is installed (its -/// beforeHash matches the on-disk bytes unless `local_bytes` overrides -/// them); the arm64-darwin sibling describes a DIFFERENT distribution -/// whose beforeHash can never match. Both afterHash blobs are staged. -fn multi_variant_fixture(cwd: &Path, on_disk: &[u8]) -> (PathBuf, Vec, Vec) { - let linux_patched = with_marker(LINUX_PRISTINE, LINUX_MARKER); - let darwin_patched = with_marker(DARWIN_BEFORE, DARWIN_MARKER); - let file = install_gem( - cwd, - &format!("{MULTI_NAME}-{MULTI_VERSION}-x86_64-linux"), - &format!("lib/{MULTI_NAME}.rb"), - on_disk, - ); - write_socket_dir( - cwd, - serde_json::json!({ - multi_purl("x86_64-linux"): patch_record( - UUID_LINUX, - "lib/nokogiri.rb", - &git_sha256(LINUX_PRISTINE), - &git_sha256(&linux_patched), - ), - multi_purl("arm64-darwin"): patch_record( - UUID_DARWIN, - "lib/nokogiri.rb", - &git_sha256(DARWIN_BEFORE), - &git_sha256(&darwin_patched), - ), - }), - &[ - (&git_sha256(&linux_patched), linux_patched.as_slice()), - (&git_sha256(&darwin_patched), darwin_patched.as_slice()), - ], - ); - (file, linux_patched, darwin_patched) -} - /// Over-broadening guard: a MULTI-variant base keeps the current /// behavior. The installed variant applies; the mismatched sibling means /// "different distribution" and is skipped — its bytes must never be @@ -355,12 +188,13 @@ fn multi_variant_fixture(cwd: &Path, on_disk: &[u8]) -> (PathBuf, Vec, Vec