From 722f100b4cc74db04bc66a0f2724972e008c071b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 18:53:08 -0400 Subject: [PATCH 01/17] fix(audit): relocate fixability provider integration test (#8425) Move the slow provider-wiring test to homeboy-refactor, preserving coverage through homeboy-code-audit public compute_fixability without an audit-to-refactor dependency. AI assistance: openai/gpt-5.6-sol via OpenCode relocated the integration test and verified the affected crates. Chris Huber remains responsible for every line. --- crates/homeboy-code-audit/src/report_test.rs | 56 +------------------ .../src/audit_fixability_provider.rs | 53 ++++++++++++++++++ 2 files changed, 54 insertions(+), 55 deletions(-) diff --git a/crates/homeboy-code-audit/src/report_test.rs b/crates/homeboy-code-audit/src/report_test.rs index b9013e7b34..37402320ed 100644 --- a/crates/homeboy-code-audit/src/report_test.rs +++ b/crates/homeboy-code-audit/src/report_test.rs @@ -280,64 +280,10 @@ fn test_compute_fixability_skips_structural_only_results() { assert!(compute_fixability(&result).is_none()); } -#[cfg(feature = "slow-tests")] -#[test] -fn test_compute_fixability_counts_fixes_from_real_audit() { - use std::fs; - - // Fixability planning is inverted behind a provider the CLI registers at - // startup; a core lib test never runs the CLI, so register the refactor-backed - // provider explicitly — otherwise fixability is always unavailable (None). - crate::refactor::audit_fixability_provider::register(); - - let dir = tempfile::tempdir().expect("temp dir"); - let root = dir.path(); - - // Create a minimal codebase with a detectable convention + outlier - fs::create_dir_all(root.join("commands")).unwrap(); - // Two conforming files establish a convention (methods: run + helper) - fs::write( - root.join("commands/good_one.rs"), - "pub fn run() {}\npub fn helper() {}\n", - ) - .unwrap(); - fs::write( - root.join("commands/good_two.rs"), - "pub fn run() {}\npub fn helper() {}\n", - ) - .unwrap(); - // One outlier is missing a method → should produce a fixable finding - fs::write(root.join("commands/bad.rs"), "pub fn run() {}\n").unwrap(); - - // Run a real audit - let result = crate::audit_path_with_id("fixability-test", &root.to_string_lossy()) - .expect("audit should run"); - - // Compute fixability - let fixability = compute_fixability(&result); - - // Should have at least some fixable findings (the missing method outlier) - if let Some(fix) = fixability { - assert!( - fix.fixable_count > 0, - "expected at least one fixable finding" - ); - // automated + manual_only should equal fixable_count - assert_eq!( - fix.fixable_count, - fix.automated_count + fix.manual_only_count - ); - // by_kind should not be empty - assert!(!fix.by_kind.is_empty(), "expected per-kind breakdown"); - } - // Note: fixability may also be None if the minimal codebase doesn't trigger - // enough conventions — that's acceptable for this test. -} - // Runs the full audit-with-analysis pipeline (walk + fingerprint + detectors + // fixability planning) over a fixture tree, matching the broad-machinery slow // tier described in `docs/internals/test-tiers.md` — the same tier its sibling -// `test_compute_fixability_counts_fixes_from_real_audit` already lives in. +// provider-wiring regression in `homeboy-refactor` lives in. #[cfg(feature = "slow-tests")] #[test] fn test_compute_fixability_with_analysis() { diff --git a/crates/homeboy-refactor/src/audit_fixability_provider.rs b/crates/homeboy-refactor/src/audit_fixability_provider.rs index 420dec4649..fec332cec8 100644 --- a/crates/homeboy-refactor/src/audit_fixability_provider.rs +++ b/crates/homeboy-refactor/src/audit_fixability_provider.rs @@ -77,3 +77,56 @@ impl AuditFixabilityProvider for RefactorFixabilityProvider { pub fn register() { register_audit_fixability_provider(Box::new(RefactorFixabilityProvider)); } + +#[cfg(all(test, feature = "slow-tests"))] +mod tests { + use super::register; + use homeboy_code_audit::{audit_path_with_id, report::compute_fixability}; + use std::fs; + + #[test] + fn computes_fixability_through_the_registered_audit_provider() { + register(); + + homeboy_core::test_support::with_isolated_audit_home(|home| { + homeboy_core::test_support::write_source_extension( + home.path(), + "source-fixture", + "fixture", + ); + let dir = tempfile::tempdir().expect("temp dir"); + let root = dir.path(); + fs::create_dir_all(root.join("commands")).expect("create commands directory"); + fs::write( + root.join("commands/good_one.fixture"), + "pub fn run() {}\npub fn helper() {}\n", + ) + .expect("write first convention fixture"); + fs::write( + root.join("commands/good_two.fixture"), + "pub fn run() {}\npub fn helper() {}\n", + ) + .expect("write second convention fixture"); + fs::write(root.join("commands/bad.fixture"), "pub fn run() {}\n") + .expect("write convention outlier fixture"); + + let result = audit_path_with_id("fixability-test", &root.to_string_lossy()) + .expect("audit should run"); + let fixability = compute_fixability(&result); + + // The compact fixture may not yield enough conventions for a plan, + // but any plan must retain the provider's complete public summary. + if let Some(fix) = fixability { + assert!( + fix.fixable_count > 0, + "expected at least one fixable finding" + ); + assert_eq!( + fix.fixable_count, + fix.automated_count + fix.manual_only_count + ); + assert!(!fix.by_kind.is_empty(), "expected per-kind breakdown"); + } + }); + } +} From 28a23112f371a65a03bc398ab47413bba38db34f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 19:03:35 -0400 Subject: [PATCH 02/17] test(audit): require provider fixability summary Require the relocated provider integration test to receive a planned summary and assert its exact TODO verdict. AI assistance: openai/gpt-5.6-sol via OpenCode strengthened the integration test and ran focused verification. Chris Huber remains responsible for every line. --- .../src/audit_fixability_provider.rs | 89 +++++++++++-------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/crates/homeboy-refactor/src/audit_fixability_provider.rs b/crates/homeboy-refactor/src/audit_fixability_provider.rs index fec332cec8..74d0f8e215 100644 --- a/crates/homeboy-refactor/src/audit_fixability_provider.rs +++ b/crates/homeboy-refactor/src/audit_fixability_provider.rs @@ -81,52 +81,63 @@ pub fn register() { #[cfg(all(test, feature = "slow-tests"))] mod tests { use super::register; - use homeboy_code_audit::{audit_path_with_id, report::compute_fixability}; + use homeboy_code_audit::{ + report::compute_fixability, AuditFinding, AuditSummary, CodeAuditResult, Finding, Severity, + }; use std::fs; #[test] fn computes_fixability_through_the_registered_audit_provider() { register(); - homeboy_core::test_support::with_isolated_audit_home(|home| { - homeboy_core::test_support::write_source_extension( - home.path(), - "source-fixture", - "fixture", - ); - let dir = tempfile::tempdir().expect("temp dir"); - let root = dir.path(); - fs::create_dir_all(root.join("commands")).expect("create commands directory"); - fs::write( - root.join("commands/good_one.fixture"), - "pub fn run() {}\npub fn helper() {}\n", - ) - .expect("write first convention fixture"); - fs::write( - root.join("commands/good_two.fixture"), - "pub fn run() {}\npub fn helper() {}\n", + let dir = tempfile::tempdir().expect("temp dir"); + let root = dir.path(); + fs::write( + root.join("todo.rs"), + "// TODO: add helper\npub fn run() {}\n", + ) + .expect("write TODO fixture"); + let result = CodeAuditResult { + component_id: "fixability-test".to_string(), + source_path: root.to_string_lossy().to_string(), + summary: AuditSummary { + files_scanned: 1, + conventions_detected: 0, + outliers_found: 1, + alignment_score: None, + files_skipped: 0, + warnings: vec![], + }, + conventions: vec![], + directory_conventions: vec![], + findings: vec![Finding { + convention: "comment_hygiene".to_string(), + severity: Severity::Info, + file: "todo.rs".to_string(), + description: "Comment marker 'TODO' found on line 1: TODO: add helper".to_string(), + suggestion: "Resolve the TODO".to_string(), + kind: AuditFinding::TodoMarker, + line: None, + }], + duplicate_groups: vec![], + }; + let fixability = compute_fixability(&result).unwrap_or_else(|| { + panic!( + "registered fixability provider should produce a plan; audit findings: {:#?}", + result.findings ) - .expect("write second convention fixture"); - fs::write(root.join("commands/bad.fixture"), "pub fn run() {}\n") - .expect("write convention outlier fixture"); - - let result = audit_path_with_id("fixability-test", &root.to_string_lossy()) - .expect("audit should run"); - let fixability = compute_fixability(&result); - - // The compact fixture may not yield enough conventions for a plan, - // but any plan must retain the provider's complete public summary. - if let Some(fix) = fixability { - assert!( - fix.fixable_count > 0, - "expected at least one fixable finding" - ); - assert_eq!( - fix.fixable_count, - fix.automated_count + fix.manual_only_count - ); - assert!(!fix.by_kind.is_empty(), "expected per-kind breakdown"); - } }); + + assert_eq!(fixability.fixable_count, 1); + assert_eq!(fixability.automated_count, 0); + assert_eq!(fixability.manual_only_count, 1); + assert_eq!(fixability.by_kind.len(), 1); + let todo_marker = fixability + .by_kind + .get("todo_marker") + .expect("fixability summary should include the TODO marker"); + assert_eq!(todo_marker.total, 1); + assert_eq!(todo_marker.automated, 0); + assert_eq!(todo_marker.manual_only, 1); } } From b1d522651f4026152d5fadff38254fcb0435c18a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 21:44:47 -0400 Subject: [PATCH 03/17] ci: enable differential Test gating [AI: openai/gpt-5.6-sol via OpenCode] --- .github/workflows/ci.yml | 2 +- tests/required_gates_policy_test.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42acb68166..7d9d3c37f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,7 +239,7 @@ jobs: component: homeboy source: . scope: auto - differential-gating: 'false' + differential-gating: 'true' baseline-commands: none test-shards: ${{ needs.ci-capacity-admission.outputs.test-shards }} execution-timeout-seconds: '1800' diff --git a/tests/required_gates_policy_test.rs b/tests/required_gates_policy_test.rs index 582821f428..bb318843f2 100644 --- a/tests/required_gates_policy_test.rs +++ b/tests/required_gates_policy_test.rs @@ -187,7 +187,7 @@ fn required_gate_policy_is_complete_and_emitted_by_every_pr_ci_run() { .nth(1) .expect("reusable Test gate"); assert!(test_gate.contains(" scope: auto")); - assert!(test_gate.contains(" differential-gating: 'false'")); + assert!(test_gate.contains(" differential-gating: 'true'")); assert!(test_gate.contains(" baseline-commands: none")); assert!(test_gate .contains(" test-shards: ${{ needs.ci-capacity-admission.outputs.test-shards }}")); From 57cffd86f2f459d8410ca1e2baa76601d95d08b2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 22:29:17 -0400 Subject: [PATCH 04/17] fix(test): union changed Rust test identities (#11751) Emit a sorted, deduplicated package/target/module selection for mixed and multi-module Rust changes, while retaining safe full fallback for unresolved and support paths.\n\nAI assistance: openai/gpt-5.6-sol via OpenCode implemented and verified the changed-test resolver. Chris Huber remains responsible for every line. --- crates/homeboy-extension/src/test/mod.rs | 434 ++++++++++++++--------- 1 file changed, 261 insertions(+), 173 deletions(-) diff --git a/crates/homeboy-extension/src/test/mod.rs b/crates/homeboy-extension/src/test/mod.rs index abbcbf2866..fff9ca9b12 100644 --- a/crates/homeboy-extension/src/test/mod.rs +++ b/crates/homeboy-extension/src/test/mod.rs @@ -431,8 +431,7 @@ fn changed_test_strategy_name(strategy: &TestChangedFileRoutingStrategy) -> &'st } fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<(String, String)> { - let mut filter_args = Vec::new(); - let mut integration_args = Vec::new(); + let mut candidates = BTreeSet::new(); let mut fallback_message = None; for test_file in files { @@ -444,10 +443,32 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( continue; }; + // Git scopes intentionally omit deletions, but callers may provide a + // precomputed list. A removed test cannot name a current inventory + // identity, so it contributes no candidate rather than a stale filter. + let file_exists = component_source_path(component).join(test_file).is_file(); + if !file_exists && routing_file.starts_with("tests/") { + continue; + } + + let package = owning_cargo_package(component, test_file); + let Some(package) = package else { + fallback_message = Some( + "Changed Rust test path has no owning Cargo package; running the full test command." + .to_string(), + ); + continue; + }; + if routing_file.starts_with("tests/") && routing_file.ends_with(".rs") { let rest = routing_file.trim_start_matches("tests/"); if !rest.contains('/') { - integration_args.push(rest.trim_end_matches(".rs").to_string()); + candidates.insert(( + package, + "test".to_string(), + rest.trim_end_matches(".rs").to_string(), + None, + )); continue; } } @@ -513,13 +534,27 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( // tests". Resolve the real mount point before falling back to the file // name. (#10179) if let Some(mounted) = path_attr_mounted_module_path(component, test_file, &routing_file) { - filter_args.push(mounted); + let Some(target) = cargo_lib_target(component, test_file, &package) else { + fallback_message = Some( + "Changed Rust inline test has no resolvable lib target; running the full test command." + .to_string(), + ); + continue; + }; + candidates.insert((package, "lib".to_string(), target, Some(mounted))); continue; } module_path = module_path.replace('/', "::"); if !module_path.is_empty() { - filter_args.push(module_path); + let Some(target) = cargo_lib_target(component, test_file, &package) else { + fallback_message = Some( + "Changed Rust inline test has no resolvable lib target; running the full test command." + .to_string(), + ); + continue; + }; + candidates.insert((package, "lib".to_string(), target, Some(module_path))); } } @@ -530,81 +565,73 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( ]; } - if !integration_args.is_empty() && !filter_args.is_empty() { - return vec![ - ("HOMEBOY_TEST_SCOPE_KIND".to_string(), "full".to_string()), - ( - "HOMEBOY_TEST_SCOPE_MESSAGE".to_string(), - "Changed files include integration and inline tests; running the full test command." - .to_string(), - ), - ]; - } - - if !integration_args.is_empty() { - let args = integration_args - .iter() - .flat_map(|target| ["--test".to_string(), target.clone()]) - .collect::>(); - return vec![ - ( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - "rust_integration".to_string(), - ), - ("HOMEBOY_TEST_RUNNER_ARGS".to_string(), args.join("\n")), - ( - "HOMEBOY_TEST_SCOPE_MESSAGE".to_string(), - format!( - "Scoped to changed integration tests: {}", - integration_args.join(" ") - ), - ), - ]; + if candidates.is_empty() { + return Vec::new(); } - if filter_args.len() == 1 { - // A module filter (`cargo test -- `) is applied against every - // target cargo builds. At a workspace root that only tests the root - // package (no `--workspace` for filter scopes), a filter naming a - // *member crate's* module matches zero tests in the root package and - // the run fails closed with "0 tests". Bind the filter to the owning - // crate's lib target with `-p --lib` so it executes against the - // package that actually defines the module. (#8758) - let owning_package = files - .first() - .and_then(|file| owning_workspace_member_package(component, file)); - - let runner_args = match &owning_package { - Some(package) => format!("-p\n{package}\n--lib\n--\n{}", filter_args[0]), - None => format!("--\n{}", filter_args[0]), - }; - let message = match &owning_package { - Some(package) => format!("Scoped to changed files in {package}: {}", filter_args[0]), - None => format!("Scoped to changed files: {}", filter_args[0]), - }; + let candidates = candidates + .into_iter() + .map(|(package, target_kind, target, module)| { + serde_json::json!({ + "package": package, + "target_kind": target_kind, + "target": target, + "module": module, + }) + }) + .collect::>(); + let selection = serde_json::json!({ + "schema": "homeboy/rust-changed-test-selection/v1", + "candidates": candidates, + }); + vec![ + ( + "HOMEBOY_TEST_SCOPE_KIND".to_string(), + "rust_changed_union".to_string(), + ), + ( + "HOMEBOY_RUST_CHANGED_TEST_SELECTION".to_string(), + selection.to_string(), + ), + ( + "HOMEBOY_TEST_SCOPE_MESSAGE".to_string(), + "Scoped to the exact union of changed Rust test identities.".to_string(), + ), + ] +} - return vec![ - ( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - "rust_filter".to_string(), - ), - ("HOMEBOY_TEST_RUNNER_ARGS".to_string(), runner_args), - ("HOMEBOY_TEST_SCOPE_MESSAGE".to_string(), message), - ]; +fn owning_cargo_package(component: &Component, test_file: &str) -> Option { + let component_root = component_source_path(component); + let file_path = component_root.join(test_file); + let mut directory = file_path.parent()?; + loop { + let manifest = directory.join("Cargo.toml"); + if manifest.is_file() { + return cargo_manifest_package_name(&manifest); + } + directory = directory.parent()?; + if !directory.starts_with(&component_root) { + return None; + } } +} - if filter_args.len() > 1 { - return vec![ - ("HOMEBOY_TEST_SCOPE_KIND".to_string(), "full".to_string()), - ( - "HOMEBOY_TEST_SCOPE_MESSAGE".to_string(), - "Changed files include multiple inline test modules; running the full test command." - .to_string(), - ), - ]; +fn cargo_lib_target(component: &Component, test_file: &str, package: &str) -> Option { + let component_root = component_source_path(component); + let file_path = component_root.join(test_file); + let mut directory = file_path.parent()?; + loop { + if directory.join("Cargo.toml").is_file() { + return directory + .join("src/lib.rs") + .is_file() + .then(|| package.replace('-', "_")); + } + directory = directory.parent()?; + if !directory.starts_with(&component_root) { + return None; + } } - - Vec::new() } /// Return a changed file path relative to its Cargo package. @@ -637,38 +664,6 @@ fn cargo_relative_test_path(component: &Component, test_file: &str) -> Option Option { - let component_root = component_source_path(component); - let file_path = component_root.join(test_file); - let mut directory = file_path.parent()?; - - loop { - let manifest = directory.join("Cargo.toml"); - if manifest.is_file() { - // The component root's own manifest is the default cargo scope; a - // filter already resolves against it, so it needs no `-p`. - if directory == component_root { - return None; - } - return cargo_manifest_package_name(&manifest); - } - directory = directory.parent()?; - if !directory.starts_with(&component_root) { - return None; - } - } -} - /// Extract the `[package] name` from a `Cargo.toml`. /// /// Returns `None` for virtual-workspace manifests (no `[package]`) or when the @@ -1207,6 +1202,35 @@ mod tests { } } + fn assert_union_candidates(env: &[(String, String)], expected: &[&str]) { + assert!(env.contains(&( + "HOMEBOY_TEST_SCOPE_KIND".to_string(), + "rust_changed_union".to_string() + ))); + let selection = env + .iter() + .find(|(key, _)| key == "HOMEBOY_RUST_CHANGED_TEST_SELECTION") + .map(|(_, value)| value) + .expect("changed Rust selection"); + let candidates = serde_json::from_str::(selection) + .expect("selection JSON")["candidates"] + .as_array() + .expect("selection candidates") + .iter() + .map(|candidate| { + let module = candidate["module"].as_str().unwrap_or_default(); + format!( + "{}::{}::{}::{}", + candidate["package"].as_str().unwrap(), + candidate["target_kind"].as_str().unwrap(), + candidate["target"].as_str().unwrap(), + module + ) + }) + .collect::>(); + assert_eq!(candidates, expected); + } + #[test] fn conditional_secret_projection_composes_static_and_matching_names() { let names = effective_secret_env_names( @@ -1355,6 +1379,22 @@ mod tests { #[test] fn rust_cargo_changed_routing_emits_integration_args() { let dir = TempDir::new().expect("temp dir should be created"); + std::fs::create_dir_all(dir.path().join("tests")).expect("test directory"); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ) + .expect("manifest"); + std::fs::write( + dir.path().join("tests/integration_scope.rs"), + "#[test] fn runs() {}", + ) + .expect("integration test"); + std::fs::write( + dir.path().join("tests/another_scope.rs"), + "#[test] fn runs() {}", + ) + .expect("integration test"); let component = Component::new( "fixture-component".to_string(), dir.path().to_string_lossy().to_string(), @@ -1370,19 +1410,25 @@ mod tests { ], ); - assert!(env.contains(&( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - format!("{}_{}", "rust", "integration") - ))); - assert!(env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "--test\nintegration_scope\n--test\nanother_scope".to_string() - ))); + assert_union_candidates( + &env, + &[ + "fixture::test::another_scope::", + "fixture::test::integration_scope::", + ], + ); } #[test] fn rust_cargo_changed_routing_emits_inline_filter() { let dir = TempDir::new().expect("temp dir should be created"); + std::fs::create_dir_all(dir.path().join("src/core")).expect("source directory"); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ) + .expect("manifest"); + std::fs::write(dir.path().join("src/lib.rs"), "mod core;").expect("lib target"); let component = Component::new( "fixture-component".to_string(), dir.path().to_string_lossy().to_string(), @@ -1392,14 +1438,62 @@ mod tests { let env = rust_cargo_changed_test_env(&component, &["src/core/daemon.rs".to_string()]); - assert!(env.contains(&( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - format!("{}_{}", "rust", "filter") - ))); - assert!(env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "--\ncore::daemon".to_string() - ))); + assert_union_candidates(&env, &["fixture::lib::fixture::core::daemon"]); + } + + #[test] + fn rust_cargo_changed_routing_unions_exact_sorted_identities() { + let dir = TempDir::new().expect("temp dir should be created"); + fs::write( + dir.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"crates/*\"]\n", + ) + .expect("workspace manifest"); + for (name, module) in [("alpha-pkg", "first"), ("beta-pkg", "second")] { + let crate_dir = dir.path().join(format!("crates/{name}")); + fs::create_dir_all(crate_dir.join("src")).expect("source directory"); + fs::write( + crate_dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"), + ) + .expect("member manifest"); + fs::write(crate_dir.join("src/lib.rs"), "pub fn value() {}").expect("lib target"); + fs::write( + crate_dir.join(format!("src/{module}.rs")), + "#[cfg(test)] mod tests { #[test] fn runs() {} }", + ) + .expect("inline tests"); + } + let alpha_tests = dir.path().join("crates/alpha-pkg/tests"); + fs::create_dir_all(&alpha_tests).expect("integration directory"); + fs::write(alpha_tests.join("api.rs"), "#[test] fn api_runs() {}") + .expect("integration test"); + let component = Component::new( + "fixture-component".to_string(), + dir.path().to_string_lossy().to_string(), + "/tmp/remote".to_string(), + None, + ); + + let env = rust_cargo_changed_test_env( + &component, + &[ + "crates/beta-pkg/src/second.rs".to_string(), + "crates/alpha-pkg/tests/api.rs".to_string(), + "crates/alpha-pkg/src/first.rs".to_string(), + "crates/beta-pkg/src/second.rs".to_string(), + "crates/alpha-pkg/tests/deleted.rs".to_string(), + ], + ); + + assert_union_candidates( + &env, + &[ + "alpha-pkg::lib::alpha_pkg::first", + "alpha-pkg::test::api::", + "beta-pkg::lib::beta_pkg::second", + ], + ); } #[test] @@ -1420,21 +1514,9 @@ mod tests { &["crates/homeboy-lab-runner/src/workspace/tests/snapshot.rs".to_string()], ); - assert!(env.contains(&( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - "rust_filter".to_string() - ))); - // The changed file lives in the `homeboy-lab-runner` member crate, not - // the workspace root, so the module filter must bind to that crate's lib - // target (`-p homeboy-lab-runner --lib`). A bare `-- ` filter at - // the workspace root only tests the root package and matches zero tests, - // which fails the release-blocking changed-scope gate. (#8758) - assert!( - env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "-p\nhomeboy-lab-runner\n--lib\n--\nworkspace::tests::snapshot".to_string() - )), - "env: {env:?}" + assert_union_candidates( + &env, + &["homeboy-lab-runner::lib::homeboy_lab_runner::workspace::tests::snapshot"], ); } @@ -1460,6 +1542,7 @@ mod tests { "[package]\nname = \"homeboy-lab-runner\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", ) .expect("member manifest"); + fs::write(member.join("src/lib.rs"), "pub mod lab;").expect("member lib target"); fs::write( member.join("src/lab/offload/hydration.rs"), "pub fn hydrate() {}\n\n#[cfg(test)]\nmod tests {\n #[test]\n fn hydrates() {}\n}\n", @@ -1478,16 +1561,9 @@ mod tests { &["crates/homeboy-lab-runner/src/lab/offload/hydration.rs".to_string()], ); - assert!(env.contains(&( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - "rust_filter".to_string() - ))); - assert!( - env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "-p\nhomeboy-lab-runner\n--lib\n--\nlab::offload::hydration".to_string() - )), - "inline tests beside a change must run against their own crate: {env:?}" + assert_union_candidates( + &env, + &["homeboy-lab-runner::lib::homeboy_lab_runner::lab::offload::hydration"], ); } @@ -1502,6 +1578,8 @@ mod tests { "[package]\nname = \"fixture-crate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", ) .expect("write component Cargo.toml"); + std::fs::create_dir_all(dir.path().join("src")).expect("source directory"); + std::fs::write(dir.path().join("src/lib.rs"), "pub fn fixture() {}").expect("lib target"); let test_rel = "src/core/tests/scope.rs"; let test_path = dir.path().join(test_rel); std::fs::create_dir_all(test_path.parent().expect("parent dir")) @@ -1518,16 +1596,9 @@ mod tests { let env = rust_cargo_changed_test_env(&component, &[test_rel.to_string()]); - assert!(env.contains(&( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - "rust_filter".to_string() - ))); - assert!( - env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "--\ncore::tests::scope".to_string() - )), - "env: {env:?}" + assert_union_candidates( + &env, + &["fixture-crate::lib::fixture_crate::core::tests::scope"], ); } @@ -1582,6 +1653,13 @@ mod tests { #[test] fn rust_cargo_changed_routing_keeps_filter_when_inline_module_declares_tests() { let dir = TempDir::new().expect("temp dir should be created"); + std::fs::create_dir_all(dir.path().join("src")).expect("source directory"); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ) + .expect("manifest"); + std::fs::write(dir.path().join("src/lib.rs"), "pub fn fixture() {}").expect("lib target"); let test_rel = "src/commands/agent_task/tests/lifecycle.rs"; let test_path = dir.path().join(test_rel); std::fs::create_dir_all(test_path.parent().expect("parent dir")) @@ -1601,14 +1679,10 @@ mod tests { let env = rust_cargo_changed_test_env(&component, &[test_rel.to_string()]); - assert!(env.contains(&( - "HOMEBOY_TEST_SCOPE_KIND".to_string(), - format!("{}_{}", "rust", "filter") - ))); - assert!(env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "--\ncommands::agent_task::tests::lifecycle".to_string() - ))); + assert_union_candidates( + &env, + &["fixture::lib::fixture::commands::agent_task::tests::lifecycle"], + ); } #[test] @@ -1617,6 +1691,13 @@ mod tests { // `tests` module, so the filter must target `...::cook::tests` rather // than the `...::cook_tests` the file name implies. let dir = TempDir::new().expect("temp dir should be created"); + std::fs::create_dir_all(dir.path().join("src")).expect("source directory"); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ) + .expect("manifest"); + std::fs::write(dir.path().join("src/lib.rs"), "pub fn fixture() {}").expect("lib target"); let module_dir = dir.path().join("src/agent_task_service"); std::fs::create_dir_all(&module_dir).expect("create module dirs"); std::fs::write( @@ -1642,15 +1723,22 @@ mod tests { &["src/agent_task_service/cook_tests.rs".to_string()], ); - assert!(env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "--\nagent_task_service::cook::tests".to_string() - ))); + assert_union_candidates( + &env, + &["fixture::lib::fixture::agent_task_service::cook::tests"], + ); } #[test] fn rust_cargo_changed_routing_keeps_file_name_when_no_path_attr_mount_exists() { let dir = TempDir::new().expect("temp dir should be created"); + std::fs::create_dir_all(dir.path().join("src")).expect("source directory"); + std::fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n", + ) + .expect("manifest"); + std::fs::write(dir.path().join("src/lib.rs"), "pub fn fixture() {}").expect("lib target"); let module_dir = dir.path().join("src/agent_task_service"); std::fs::create_dir_all(&module_dir).expect("create module dirs"); // A sibling that mounts a *different* file must not capture this one. @@ -1677,10 +1765,10 @@ mod tests { &["src/agent_task_service/cook_tests.rs".to_string()], ); - assert!(env.contains(&( - "HOMEBOY_TEST_RUNNER_ARGS".to_string(), - "--\nagent_task_service::cook_tests".to_string() - ))); + assert_union_candidates( + &env, + &["fixture::lib::fixture::agent_task_service::cook_tests"], + ); } #[test] From c6c10f977b569e572cc0f813b7021bf3e71c63ea Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 22:29:33 -0400 Subject: [PATCH 05/17] test(audit): inventory provider regression (#12000) The relocated provider regression executes in 0.04s after compilation, so keep it in the normal inventory instead of an opt-in slow tier.\n\nAI assistance: openai/gpt-5.6-sol via OpenCode measured the focused test, moved it to normal inventory, and verified it. Chris Huber remains responsible for every line. --- crates/homeboy-refactor/src/audit_fixability_provider.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/homeboy-refactor/src/audit_fixability_provider.rs b/crates/homeboy-refactor/src/audit_fixability_provider.rs index 74d0f8e215..81108e8513 100644 --- a/crates/homeboy-refactor/src/audit_fixability_provider.rs +++ b/crates/homeboy-refactor/src/audit_fixability_provider.rs @@ -78,7 +78,7 @@ pub fn register() { register_audit_fixability_provider(Box::new(RefactorFixabilityProvider)); } -#[cfg(all(test, feature = "slow-tests"))] +#[cfg(test)] mod tests { use super::register; use homeboy_code_audit::{ From 5e73c1ef89b8661f6ccb8f7974bcbffd46e8040d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 22:41:26 -0400 Subject: [PATCH 06/17] fix(test): materialize bounded Rust selection (#11751) Write changed Rust selection into the run artifact instead of the environment so older runners safely fall back to a full suite.\n\nAI assistance: openai/gpt-5.6-sol via OpenCode addressed review blockers and verified the consumer contract. Chris Huber remains responsible for every line. --- crates/homeboy-extension/src/test/mod.rs | 44 ++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/homeboy-extension/src/test/mod.rs b/crates/homeboy-extension/src/test/mod.rs index fff9ca9b12..27b962b311 100644 --- a/crates/homeboy-extension/src/test/mod.rs +++ b/crates/homeboy-extension/src/test/mod.rs @@ -204,7 +204,25 @@ pub fn build_test_runner( if let Some(routing) = test_changed_file_routing(&extension_id)? { for (name, value) in build_changed_test_routing_env(component, files, &routing) { - runner = runner.env(&name, &value); + if name == "HOMEBOY_RUST_CHANGED_TEST_SELECTION_JSON" { + let selection_path = run_dir.step_file("rust-changed-test-selection.json"); + if value.len() > 1024 * 1024 || std::fs::write(&selection_path, value).is_err() + { + runner = runner + .env("HOMEBOY_TEST_SCOPE_KIND", "full") + .env( + "HOMEBOY_TEST_SCOPE_MESSAGE", + "Rust changed-test selection could not be materialized; running the full test command.", + ); + } else { + runner = runner.env( + "HOMEBOY_RUST_CHANGED_TEST_SELECTION_FILE", + selection_path.to_string_lossy().as_ref(), + ); + } + } else { + runner = runner.env(&name, &value); + } } } } @@ -468,6 +486,7 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( "test".to_string(), rest.trim_end_matches(".rs").to_string(), None, + test_file.clone(), )); continue; } @@ -541,7 +560,13 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( ); continue; }; - candidates.insert((package, "lib".to_string(), target, Some(mounted))); + candidates.insert(( + package, + "lib".to_string(), + target, + Some(mounted), + test_file.clone(), + )); continue; } @@ -554,7 +579,13 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( ); continue; }; - candidates.insert((package, "lib".to_string(), target, Some(module_path))); + candidates.insert(( + package, + "lib".to_string(), + target, + Some(module_path), + test_file.clone(), + )); } } @@ -571,12 +602,13 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( let candidates = candidates .into_iter() - .map(|(package, target_kind, target, module)| { + .map(|(package, target_kind, target, module, path)| { serde_json::json!({ "package": package, "target_kind": target_kind, "target": target, "module": module, + "path": path, }) }) .collect::>(); @@ -590,7 +622,7 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( "rust_changed_union".to_string(), ), ( - "HOMEBOY_RUST_CHANGED_TEST_SELECTION".to_string(), + "HOMEBOY_RUST_CHANGED_TEST_SELECTION_JSON".to_string(), selection.to_string(), ), ( @@ -1209,7 +1241,7 @@ mod tests { ))); let selection = env .iter() - .find(|(key, _)| key == "HOMEBOY_RUST_CHANGED_TEST_SELECTION") + .find(|(key, _)| key == "HOMEBOY_RUST_CHANGED_TEST_SELECTION_JSON") .map(|(_, value)| value) .expect("changed Rust selection"); let candidates = serde_json::from_str::(selection) From 468ac53610f4643a472937ea47e4ef10151db467 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 22:42:32 -0400 Subject: [PATCH 07/17] fix(test): version Rust selection artifact contract AI assistance: openai/gpt-5.6-sol via OpenCode synchronized the v2 selection schema. Chris Huber remains responsible for every line. --- crates/homeboy-extension/src/test/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/homeboy-extension/src/test/mod.rs b/crates/homeboy-extension/src/test/mod.rs index 27b962b311..349304a839 100644 --- a/crates/homeboy-extension/src/test/mod.rs +++ b/crates/homeboy-extension/src/test/mod.rs @@ -613,7 +613,7 @@ fn rust_cargo_changed_test_env(component: &Component, files: &[String]) -> Vec<( }) .collect::>(); let selection = serde_json::json!({ - "schema": "homeboy/rust-changed-test-selection/v1", + "schema": "homeboy/rust-changed-test-selection/v2", "candidates": candidates, }); vec![ From fb0db1cdce45bcd3284f9f378381426e0df9a4ef Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 22:49:16 -0400 Subject: [PATCH 08/17] test(rust): cover bounded selection artifact fallback Cover selection artifact creation, oversize rejection, and write failure before runner launch.\n\nAI assistance: openai/gpt-5.6-sol via OpenCode addressed producer review findings and ran focused tests. Chris Huber remains responsible for every line. --- crates/homeboy-extension/src/test/mod.rs | 39 +++++++++++++++++++----- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/homeboy-extension/src/test/mod.rs b/crates/homeboy-extension/src/test/mod.rs index 349304a839..590db8a608 100644 --- a/crates/homeboy-extension/src/test/mod.rs +++ b/crates/homeboy-extension/src/test/mod.rs @@ -205,20 +205,16 @@ pub fn build_test_runner( if let Some(routing) = test_changed_file_routing(&extension_id)? { for (name, value) in build_changed_test_routing_env(component, files, &routing) { if name == "HOMEBOY_RUST_CHANGED_TEST_SELECTION_JSON" { - let selection_path = run_dir.step_file("rust-changed-test-selection.json"); - if value.len() > 1024 * 1024 || std::fs::write(&selection_path, value).is_err() - { + if let Some(selection_path) = write_rust_changed_selection(run_dir, &value) { + runner = + runner.env("HOMEBOY_RUST_CHANGED_TEST_SELECTION_FILE", &selection_path); + } else { runner = runner .env("HOMEBOY_TEST_SCOPE_KIND", "full") .env( "HOMEBOY_TEST_SCOPE_MESSAGE", "Rust changed-test selection could not be materialized; running the full test command.", ); - } else { - runner = runner.env( - "HOMEBOY_RUST_CHANGED_TEST_SELECTION_FILE", - selection_path.to_string_lossy().as_ref(), - ); } } else { runner = runner.env(&name, &value); @@ -230,6 +226,19 @@ pub fn build_test_runner( Ok(runner) } +fn write_rust_changed_selection( + run_dir: &homeboy_core::engine::run_dir::RunDir, + selection: &str, +) -> Option { + if selection.len() > 1024 * 1024 { + return None; + } + let path = run_dir.step_file("rust-changed-test-selection.json"); + std::fs::write(&path, selection) + .ok() + .map(|()| path.to_string_lossy().to_string()) +} + pub(crate) fn effective_secret_env_names( static_names: &[String], projections: &[TestSecretEnvProjection], @@ -1263,6 +1272,20 @@ mod tests { assert_eq!(candidates, expected); } + #[test] + fn rust_changed_selection_is_bounded_and_written_in_the_run_dir() { + let run_dir = homeboy_core::engine::run_dir::RunDir::create().expect("run dir"); + let selection = r#"{"schema":"homeboy/rust-changed-test-selection/v2","candidates":[]}"#; + let path = write_rust_changed_selection(&run_dir, selection).expect("selection artifact"); + assert_eq!( + std::fs::read_to_string(path).expect("selection contents"), + selection + ); + assert!(write_rust_changed_selection(&run_dir, &"x".repeat(1024 * 1024 + 1)).is_none()); + std::fs::remove_dir_all(run_dir.path()).expect("remove run dir"); + assert!(write_rust_changed_selection(&run_dir, selection).is_none()); + } + #[test] fn conditional_secret_projection_composes_static_and_matching_names() { let names = effective_secret_env_names( From 12423a326c2e3b99dbcf02584a0989ca8a8efeee Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 23:04:37 -0400 Subject: [PATCH 09/17] ci: pin Test workflow bootstrap baseline Use the reviewed Homeboy Action bootstrap baseline for the reusable Test workflow while preserving differential gating.\n\nAI assistance: openai/gpt-5.6-sol via OpenCode pinned the reviewed workflow reference. Chris Huber remains responsible for every line. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9d3c37f3..483208d37b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,7 +222,7 @@ jobs: name: homeboy needs: [pr-state, ci-capacity-admission] if: ${{ needs.pr-state.outputs.active == 'true' }} - uses: Extra-Chill/homeboy-action/.github/workflows/ci.yml@v2 + uses: Extra-Chill/homeboy-action/.github/workflows/ci.yml@5103e9d0045da80ad9783eb9ca32f3496911800e with: commands: review test # `review test` defaults to running the extension's pre-test lint, which From 07ac8e407c32286c5682ee608b69f57ced0c6c29 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 8 Aug 2026 23:50:25 -0400 Subject: [PATCH 10/17] fix(test): require validated inventory-only evidence AI assistance: openai/gpt-5.6-sol/OpenCode implemented the fail-closed inventory result contract and ran focused verification. Chris Huber remains responsible for every line. --- .../src/test_results.rs | 17 ++ crates/homeboy-cli/src/commands/test.rs | 7 + crates/homeboy-extension/src/test/report.rs | 35 ++++ crates/homeboy-extension/src/test/run.rs | 193 ++++++++++++++---- crates/homeboy-review/src/review/mod.rs | 1 + crates/homeboy-review/src/review/render.rs | 1 + 6 files changed, 212 insertions(+), 42 deletions(-) diff --git a/crates/contracts/homeboy-extension-contract/src/test_results.rs b/crates/contracts/homeboy-extension-contract/src/test_results.rs index fd86097054..ad8232a6ec 100644 --- a/crates/contracts/homeboy-extension-contract/src/test_results.rs +++ b/crates/contracts/homeboy-extension-contract/src/test_results.rs @@ -34,6 +34,10 @@ pub struct TestCommandOutput { pub failure: Option, #[serde(skip_serializing_if = "Option::is_none")] pub test_counts: Option, + /// Positive evidence emitted by an inventory-only test run. This replaces + /// execution counts only for that explicit mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub test_inventory: Option, /// Duration facts for this phase. Deliberately separate from `findings`: /// those drive failure classification, and a slow test is not a failing /// test. `None` when nothing could be measured — never a zeroed block. @@ -85,6 +89,8 @@ pub struct TestRunWorkflowResult { pub runner_exit_code: Option, pub test_counts: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub test_inventory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub test_durations: Option, pub findings: Option>, #[serde(skip)] @@ -104,6 +110,17 @@ pub struct TestRunWorkflowResult { pub extension_phase_timings: Vec, } +/// Validated inventory-only test evidence from a supervised extension child. +#[derive(Debug, Clone, Serialize)] +pub struct TestInventoryOutput { + pub schema: String, + pub runner: String, + pub runner_fingerprint: String, + pub workspace_fingerprint: String, + pub test_count: usize, + pub inventory_fingerprint: String, +} + #[derive(Debug, Clone, Serialize)] pub struct DriftWorkflowResult { pub component: String, diff --git a/crates/homeboy-cli/src/commands/test.rs b/crates/homeboy-cli/src/commands/test.rs index 7450ae8ab1..670cb1e262 100644 --- a/crates/homeboy-cli/src/commands/test.rs +++ b/crates/homeboy-cli/src/commands/test.rs @@ -1375,6 +1375,7 @@ mod tests { exit_code: 1, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -1465,6 +1466,7 @@ mod tests { exit_code: 1, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: Some(input), @@ -1556,6 +1558,7 @@ mod tests { exit_code: 101, runner_exit_code: None, test_counts: Some(TestCounts::new(0, 0, 0, 0)), + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -1634,6 +1637,7 @@ mod tests { exit_code: 1, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -1782,6 +1786,7 @@ mod tests { }, runner_exit_code: Some(runner_exit_code), test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -1847,6 +1852,7 @@ mod tests { exit_code: 1, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -1935,6 +1941,7 @@ mod tests { exit_code: 143, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, diff --git a/crates/homeboy-extension/src/test/report.rs b/crates/homeboy-extension/src/test/report.rs index 62b5822a22..becab44cf0 100644 --- a/crates/homeboy-extension/src/test/report.rs +++ b/crates/homeboy-extension/src/test/report.rs @@ -88,6 +88,7 @@ pub fn from_main_workflow_with_ci_context( phase, failure, test_counts: result.test_counts, + test_inventory: result.test_inventory, // Carried through untouched. `test_phase_report` and // `test_phase_failure` below never read it: a slow suite must not // be able to change the phase verdict in either direction. (#10655) @@ -123,6 +124,7 @@ pub fn from_drift_workflow(result: DriftWorkflowResult) -> (TestCommandOutput, i phase: None, failure: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, coverage: None, @@ -167,6 +169,7 @@ pub fn from_auto_fix_drift_workflow( phase: None, failure: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, coverage: None, @@ -312,6 +315,7 @@ mod tests { exit_code: 1, runner_exit_code: None, test_counts: Some(TestCounts::new(3, 1, 2, 0)), + test_inventory: None, test_durations: None, findings, failure_analysis_input: None, @@ -334,6 +338,7 @@ mod tests { exit_code, runner_exit_code: None, test_counts: Some(counts), + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -356,6 +361,7 @@ mod tests { exit_code: 0, runner_exit_code: None, test_counts: Some(TestCounts::new(0, 0, 0, 0)), + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -371,6 +377,35 @@ mod tests { } } + #[test] + fn serializes_validated_inventory_mode_success() { + let mut workflow = workflow_result(None); + workflow.status = "passed".to_string(); + workflow.exit_code = 0; + workflow.runner_exit_code = Some(0); + workflow.test_counts = None; + workflow.test_inventory = Some( + homeboy_extension_contract::test_results::TestInventoryOutput { + schema: "homeboy/test-inventory/v1".to_string(), + runner: "nextest".to_string(), + runner_fingerprint: "a".repeat(64), + workspace_fingerprint: "b".repeat(64), + test_count: 4, + inventory_fingerprint: "c".repeat(64), + }, + ); + + let (output, exit_code) = from_main_workflow(workflow); + let rendered = serde_json::to_value(output).expect("inventory output serializes"); + + assert_eq!(exit_code, 0); + assert_eq!( + rendered["test_inventory"]["schema"], + "homeboy/test-inventory/v1" + ); + assert_eq!(rendered["test_inventory"]["test_count"], 4); + } + #[test] fn serializes_findings_when_present() { let (output, exit_code) = diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index b12825acc8..7ee05e47b3 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -22,7 +22,7 @@ use homeboy_engine_primitives::baseline::BaselineFlags; use homeboy_engine_primitives::local_files; use homeboy_engine_primitives::measurement::{Measurement, Verdict}; use homeboy_engine_primitives::output_parse::ParseSpec; -pub use homeboy_extension_contract::test_results::TestRunWorkflowResult; +pub use homeboy_extension_contract::test_results::{TestInventoryOutput, TestRunWorkflowResult}; pub use homeboy_extension_contract::test_workflow::RawTestOutput; use homeboy_refactor_contract::AppliedRefactor; use regex::Regex; @@ -82,7 +82,7 @@ struct NoTestsApplicableEvidence { reason: String, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct TestInventoryEvidence { schema: String, @@ -105,14 +105,17 @@ struct TestInventoryTest { expected_outcome: Option, } +fn test_inventory_mode(ci_env: &[(String, String)]) -> bool { + ci_env + .iter() + .any(|(key, value)| key == TEST_INVENTORY_ONLY_ENV && value == "1") +} + fn requested_test_inventory_file( ci_env: &[(String, String)], source_path: &Path, ) -> Option { - let inventory_only = ci_env - .iter() - .any(|(key, value)| key == TEST_INVENTORY_ONLY_ENV && value == "1"); - if !inventory_only { + if !test_inventory_mode(ci_env) { return None; } @@ -153,18 +156,18 @@ fn prepare_test_inventory(path: &Path) -> bool { /// Inventory planning currently runs on Unix CI. Other platforms lack an /// equivalent no-follow open here, so this optional evidence stays closed. #[cfg(not(unix))] -fn valid_test_inventory(path: &Path) -> bool { +fn valid_test_inventory(path: &Path) -> Option { let _ = path; - false + None } #[cfg(unix)] -fn valid_test_inventory(path: &Path) -> bool { +fn valid_test_inventory(path: &Path) -> Option { let Ok(metadata) = std::fs::symlink_metadata(path) else { - return false; + return None; }; if !metadata.file_type().is_file() || metadata.len() > MAX_TEST_INVENTORY_BYTES { - return false; + return None; } let file = { @@ -176,25 +179,25 @@ fn valid_test_inventory(path: &Path) -> bool { .open(path) }; let Ok(mut file) = file else { - return false; + return None; }; let Ok(opened_metadata) = file.metadata() else { - return false; + return None; }; if !opened_metadata.is_file() || opened_metadata.len() > MAX_TEST_INVENTORY_BYTES { - return false; + return None; } let mut bytes = Vec::with_capacity(opened_metadata.len() as usize); if file.read_to_end(&mut bytes).is_err() || bytes.len() as u64 != opened_metadata.len() { - return false; + return None; } let Ok(inventory) = serde_json::from_slice::(&bytes) else { - return false; + return None; }; valid_test_inventory_payload(&inventory) } -fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> bool { +fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> Option { if inventory.schema != TEST_INVENTORY_SCHEMA || !matches!(inventory.runner.as_str(), "cargo" | "nextest") || !homeboy_engine_primitives::content_hash::is_sha256_hex(&inventory.runner_fingerprint) @@ -203,7 +206,7 @@ fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> bool { || inventory.inventory_fingerprint != inventory.inventory_fingerprint.to_ascii_lowercase() || inventory.tests.is_empty() { - return false; + return None; } if inventory.tests.iter().any(|test| { [ @@ -220,7 +223,7 @@ fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> bool { .as_deref() .is_some_and(|outcome| !matches!(outcome, "executed" | "skipped")) }) { - return false; + return None; } let mut test_ids = std::collections::HashSet::with_capacity(inventory.tests.len()); if inventory @@ -228,11 +231,19 @@ fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> bool { .iter() .any(|test| !test_ids.insert(&test.id)) { - return false; + return None; } - homeboy_engine_primitives::content_hash::sha256_hex(&canonical_inventory_json(inventory)) - == inventory.inventory_fingerprint + (homeboy_engine_primitives::content_hash::sha256_hex(&canonical_inventory_json(inventory)) + == inventory.inventory_fingerprint) + .then(|| TestInventoryOutput { + schema: inventory.schema.clone(), + runner: inventory.runner.clone(), + runner_fingerprint: inventory.runner_fingerprint.clone(), + workspace_fingerprint: inventory.workspace_fingerprint.clone(), + test_count: inventory.tests.len(), + inventory_fingerprint: inventory.inventory_fingerprint.clone(), + }) } /// The Rust inventory producer fingerprints `json.dumps(..., sort_keys=True, @@ -372,13 +383,18 @@ fn test_run_status_with_inventory( runner_success: bool, test_counts: Option<&TestCounts>, no_tests_applicable: bool, - inventory_measured: bool, + inventory_mode: bool, + test_inventory: Option<&TestInventoryOutput>, ) -> &'static str { if !runner_success { return "failed"; } - if inventory_measured { - return "passed"; + if inventory_mode { + return if test_inventory.is_some() { + "passed" + } else { + "failed" + }; } test_run_status(runner_success, test_counts, no_tests_applicable) @@ -389,7 +405,10 @@ fn test_run_status_with_inventory( /// Only a successful runner may be promoted by delayed evidence. A nonzero /// runner exit remains the primary failure even if its eventual counts pass. pub fn finalize_test_result_after_artifact_hydration(workflow: &mut TestRunWorkflowResult) { - if workflow.runner_exit_code != Some(0) || workflow.test_counts.is_none() { + if workflow.test_inventory.is_some() + || workflow.runner_exit_code != Some(0) + || workflow.test_counts.is_none() + { return; } @@ -537,6 +556,7 @@ fn run_main_test_workflow_inner( exit_code: 1, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings, failure_analysis_input: None, @@ -574,6 +594,7 @@ fn run_main_test_workflow_inner( exit_code: 0, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -610,6 +631,7 @@ fn run_main_test_workflow_inner( let no_tests_nonce = uuid::Uuid::new_v4().to_string(); let write_results_helper = write_test_results_helper(run_dir)?; + let inventory_mode = test_inventory_mode(&args.ci_env); let inventory_file = requested_test_inventory_file(&args.ci_env, source_path) .filter(|path| prepare_test_inventory(path)); @@ -735,12 +757,13 @@ fn run_main_test_workflow_inner( // Autofix is owned by `refactor --from test --write`; the test command is read-only. let test_autofix: Option = None; - let inventory_measured = inventory_file.is_some_and(|path| valid_test_inventory(&path)); + let test_inventory = inventory_file.and_then(|path| valid_test_inventory(&path)); let status = test_run_status_with_inventory( output.success, test_counts.as_ref(), no_tests_applicable, - inventory_measured, + inventory_mode, + test_inventory.as_ref(), ); let coverage = coverage_file @@ -972,6 +995,7 @@ fn run_main_test_workflow_inner( exit_code, runner_exit_code: Some(output.exit_code), test_counts, + test_inventory, test_durations, findings, failure_analysis_input, @@ -1183,6 +1207,7 @@ fn failed_test_workflow( exit_code: 2, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -1525,6 +1550,7 @@ pub fn run_self_check_test_workflow_with_progress( exit_code: output.exit_code, runner_exit_code: Some(output.exit_code), test_counts: None, + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -2111,6 +2137,7 @@ mod tests { exit_code: 1, runner_exit_code: None, test_counts: Some(TestCounts::new(1, 0, 1, 0)), + test_inventory: None, test_durations: None, findings: None, failure_analysis_input: None, @@ -2207,6 +2234,7 @@ mod tests { exit_code: 101, runner_exit_code: None, test_counts: None, + test_inventory: None, test_durations: None, findings: Some(findings), failure_analysis_input: Some(input), @@ -2316,6 +2344,32 @@ mod tests { assert_eq!(test_run_status(true, None, false), "failed"); } + #[test] + fn inventory_success_is_not_re_finalized_from_execution_counts() { + let mut workflow = failed_test_workflow( + "fixture".to_string(), + false, + &Error::internal_unexpected("fixture"), + ); + workflow.status = "passed".to_string(); + workflow.exit_code = 0; + workflow.runner_exit_code = Some(0); + workflow.test_counts = Some(TestCounts::new(1, 0, 1, 0)); + workflow.test_inventory = Some(TestInventoryOutput { + schema: TEST_INVENTORY_SCHEMA.to_string(), + runner: "nextest".to_string(), + runner_fingerprint: "a".repeat(64), + workspace_fingerprint: "b".repeat(64), + test_count: 1, + inventory_fingerprint: "c".repeat(64), + }); + + finalize_test_result_after_artifact_hydration(&mut workflow); + + assert_eq!(workflow.status, "passed"); + assert_eq!(workflow.exit_code, 0); + } + #[test] fn inventory_mode_requires_explicit_valid_inventory_evidence() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2330,42 +2384,97 @@ mod tests { assert!(prepare_test_inventory(&inventory)); assert_eq!( - test_run_status_with_inventory(true, None, false, false), + test_run_status_with_inventory(true, None, false, true, None), "failed", "requesting inventory mode without evidence must remain unmeasured" ); assert!( !requested_test_inventory_file(&ci_env, temp.path()) - .is_some_and(|path| valid_test_inventory(&path)), + .is_some_and(|path| valid_test_inventory(&path).is_some()), "a missing inventory must remain fail-closed" ); std::fs::write(&inventory, "not json").expect("write malformed inventory"); assert!( - !valid_test_inventory(&inventory), + valid_test_inventory(&inventory).is_none(), "a malformed inventory must remain fail-closed" ); std::fs::write(&inventory, valid_inventory_document()).expect("write inventory"); let measured = requested_test_inventory_file(&ci_env, temp.path()) - .is_some_and(|path| valid_test_inventory(&path)); - assert!(measured); + .and_then(|path| valid_test_inventory(&path)); + let evidence = measured.as_ref().expect("validated inventory"); + assert_eq!(evidence.schema, TEST_INVENTORY_SCHEMA); + assert_eq!(evidence.test_count, 1); assert_eq!( - test_run_status_with_inventory(true, None, false, measured), + test_run_status_with_inventory(true, None, false, true, Some(evidence)), "passed" ); assert_eq!( - test_run_status_with_inventory(false, None, false, measured), + test_run_status_with_inventory(false, None, false, true, Some(evidence)), "failed", "inventory evidence cannot override a failed runner" ); + assert_eq!( + test_run_status_with_inventory( + true, + Some(&TestCounts::new(3, 3, 0, 0)), + false, + true, + None + ), + "failed", + "inventory mode cannot fall back to normal execution counts" + ); + assert_eq!( + test_run_status_with_inventory( + true, + Some(&TestCounts::new(3, 3, 0, 0)), + false, + false, + None, + ), + "passed", + "normal mode must retain its execution-count finalization" + ); + for (case, document) in [ + ( + "wrong schema", + valid_inventory_document().replacen(TEST_INVENTORY_SCHEMA, "other/schema/v1", 1), + ), + ("incomplete document", "{\"schema\":\"homeboy/test-inventory/v1\"}".to_string()), + ( + "invalid provenance", + valid_inventory_document().replacen("\"runner\":\"nextest\"", "\"runner\":\"unknown\"", 1), + ), + ( + "empty inventory", + r#"{"schema":"homeboy/test-inventory/v1","runner":"nextest","runner_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541","workspace_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541","tests":[],"inventory_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541"}"#.to_string(), + ), + ] { + std::fs::write(&inventory, document).expect("write invalid inventory"); + assert!( + valid_test_inventory(&inventory).is_none(), + "{case} must remain fail-closed" + ); + } + + let mut duplicate: TestInventoryEvidence = + serde_json::from_str(&valid_inventory_document()).expect("parse valid inventory"); + duplicate.tests.push(duplicate.tests[0].clone()); + duplicate.inventory_fingerprint = homeboy_engine_primitives::content_hash::sha256_hex( + &canonical_inventory_json(&duplicate), + ); std::fs::write( &inventory, - r#"{"schema":"homeboy/test-inventory/v1","runner":"nextest","runner_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541","workspace_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541","tests":[],"inventory_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541"}"#, + serde_json::to_vec(&duplicate).expect("serialize duplicate inventory"), ) - .expect("write empty inventory"); - assert!(!valid_test_inventory(&inventory)); + .expect("write duplicate inventory"); + assert!( + valid_test_inventory(&inventory).is_none(), + "duplicate identities must remain fail-closed even with a matching fingerprint" + ); } #[test] @@ -2399,17 +2508,17 @@ mod tests { assert!(requested_test_inventory_file(&escaped_env, temp.path()).is_none()); std::fs::write(&inventory, valid_inventory_document()).expect("write inventory"); - assert!(valid_test_inventory(&inventory)); + assert!(valid_test_inventory(&inventory).is_some()); std::fs::write(&inventory, valid_inventory_document_with_unicode_name()) .expect("write unicode inventory"); assert!( - valid_test_inventory(&inventory), + valid_test_inventory(&inventory).is_some(), "Python's ASCII-escaped fingerprint must accept Unicode test names" ); let tampered = valid_inventory_document().replacen("suite::test", "suite::other", 1); std::fs::write(&inventory, tampered).expect("write tampered inventory"); assert!( - !valid_test_inventory(&inventory), + valid_test_inventory(&inventory).is_none(), "the inventory fingerprint must bind the listed tests" ); } @@ -2425,7 +2534,7 @@ mod tests { std::fs::write(&target, valid_inventory_document()).expect("write target"); symlink(&target, &inventory).expect("link inventory"); - assert!(!valid_test_inventory(&inventory)); + assert!(valid_test_inventory(&inventory).is_none()); assert!(prepare_test_inventory(&inventory)); assert!(target.exists(), "cleanup must remove only the link"); } diff --git a/crates/homeboy-review/src/review/mod.rs b/crates/homeboy-review/src/review/mod.rs index 82401ba311..c3c793b4d8 100644 --- a/crates/homeboy-review/src/review/mod.rs +++ b/crates/homeboy-review/src/review/mod.rs @@ -394,6 +394,7 @@ mod tests { phase: None, failure: None, test_counts: None, + test_inventory: None, test_durations: None, findings: None, coverage: None, diff --git a/crates/homeboy-review/src/review/render.rs b/crates/homeboy-review/src/review/render.rs index 8ed2d25d93..d038593a2a 100644 --- a/crates/homeboy-review/src/review/render.rs +++ b/crates/homeboy-review/src/review/render.rs @@ -713,6 +713,7 @@ mod tests { failed, skipped, }), + test_inventory: None, test_durations: None, findings: None, coverage: None, From e8df7c21dcf9f9fd8f55bc64a6715ecd37ce968b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 00:01:35 -0400 Subject: [PATCH 11/17] fix(test): bind inventory evidence provenance [AI: openai/gpt-5.6-terra via OpenCode; implemented reviewed inventory evidence hardening] --- crates/homeboy-extension/src/test/run.rs | 362 +++++++++++++++-------- 1 file changed, 238 insertions(+), 124 deletions(-) diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index 7ee05e47b3..2ca638078a 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -28,7 +28,7 @@ use homeboy_refactor_contract::AppliedRefactor; use regex::Regex; use serde::{Deserialize, Serialize}; use std::io::Read; -use std::path::{Component as PathComponent, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::time::Duration; #[derive(Debug, Clone)] @@ -61,6 +61,7 @@ const NO_TESTS_APPLICABLE_STEP: &str = "test"; const TEST_INVENTORY_ONLY_ENV: &str = "HOMEBOY_TEST_INVENTORY_ONLY"; const TEST_INVENTORY_FILE_ENV: &str = "HOMEBOY_TEST_INVENTORY_FILE"; const TEST_INVENTORY_SCHEMA: &str = "homeboy/test-inventory/v1"; +const TEST_INVENTORY_FILE: &str = "test-inventory.json"; const MAX_TEST_INVENTORY_BYTES: u64 = 64 * 1024 * 1024; const DEFAULT_TEST_TIMEOUT_SECONDS: u64 = 25 * 60; @@ -111,41 +112,86 @@ fn test_inventory_mode(ci_env: &[(String, String)]) -> bool { .any(|(key, value)| key == TEST_INVENTORY_ONLY_ENV && value == "1") } -fn requested_test_inventory_file( - ci_env: &[(String, String)], +#[derive(Clone, Debug)] +struct TestInventoryBinding { + run_dir: PathBuf, + path: PathBuf, + runner_fingerprint: String, + workspace_fingerprint: String, +} + +fn test_inventory_binding( + context: &crate::ExtensionExecutionContext, source_path: &Path, -) -> Option { - if !test_inventory_mode(ci_env) { + run_dir: &RunDir, +) -> Option { + let run_dir_metadata = std::fs::symlink_metadata(run_dir.path()).ok()?; + if !run_dir_metadata.file_type().is_dir() || run_dir_metadata.file_type().is_symlink() { return None; } - - let requested = ci_env - .iter() - .find(|(key, value)| key == TEST_INVENTORY_FILE_ENV && !value.is_empty()) - .map(|(_, value)| PathBuf::from(value))?; - let source_root = source_path.canonicalize().ok()?; - let requested = if requested.is_absolute() { - requested - } else { - source_root.join(requested) - }; - let name = requested.file_name()?.to_owned(); - if !Path::new(&name) - .components() - .all(|component| matches!(component, PathComponent::Normal(_))) - { + let run_dir = run_dir.path().canonicalize().ok()?; + let extension_path = context.extension_path.canonicalize().ok()?; + let script_path = extension_path + .join(&context.script_path) + .canonicalize() + .ok()?; + if !script_path.starts_with(&extension_path) || !script_path.is_file() { return None; } - let parent = requested.parent()?.canonicalize().ok()?; - parent.starts_with(&source_root).then(|| parent.join(name)) + let runner_fingerprint = + homeboy_engine_primitives::content_hash::sha256_file(&script_path).ok()?; + let snapshot = homeboy_core::source_snapshot::collect_local( + "homeboy-test-inventory", + source_path, + None, + "local", + ); + if snapshot.git_sha.is_none() { + return None; + } + let workspace_fingerprint = snapshot.snapshot_hash.strip_prefix("sha256:")?.to_string(); + homeboy_engine_primitives::content_hash::is_sha256_hex(&workspace_fingerprint).then(|| { + TestInventoryBinding { + path: run_dir.join(TEST_INVENTORY_FILE), + run_dir, + runner_fingerprint, + workspace_fingerprint, + } + }) +} + +fn revalidate_test_inventory_binding( + binding: &TestInventoryBinding, + context: &crate::ExtensionExecutionContext, + source_path: &Path, +) -> bool { + let Ok(run_dir) = RunDir::from_existing(binding.run_dir.clone()) else { + return false; + }; + let Some(current) = test_inventory_binding(context, source_path, &run_dir) else { + return false; + }; + current.path == binding.path + && current.runner_fingerprint == binding.runner_fingerprint + && current.workspace_fingerprint == binding.workspace_fingerprint } /// Delete stale evidence before the child starts. The child may only write a -/// regular file below its source tree, so a prior run cannot satisfy this run. -fn prepare_test_inventory(path: &Path) -> bool { - match std::fs::symlink_metadata(path) { +/// regular file directly below its trusted run directory, so a prior run cannot +/// satisfy this run. +fn prepare_test_inventory(binding: &TestInventoryBinding) -> bool { + let Some(path_parent) = binding.path.parent() else { + return false; + }; + let Ok(parent) = path_parent.canonicalize() else { + return false; + }; + if parent != binding.run_dir || binding.path.file_name() != Some(TEST_INVENTORY_FILE.as_ref()) { + return false; + } + match std::fs::symlink_metadata(&binding.path) { Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => { - std::fs::remove_file(path).is_ok() + std::fs::remove_file(&binding.path).is_ok() } Ok(_) => false, Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, @@ -156,14 +202,18 @@ fn prepare_test_inventory(path: &Path) -> bool { /// Inventory planning currently runs on Unix CI. Other platforms lack an /// equivalent no-follow open here, so this optional evidence stays closed. #[cfg(not(unix))] -fn valid_test_inventory(path: &Path) -> Option { - let _ = path; +fn valid_test_inventory(binding: &TestInventoryBinding) -> Option { + let _ = binding; None } #[cfg(unix)] -fn valid_test_inventory(path: &Path) -> Option { - let Ok(metadata) = std::fs::symlink_metadata(path) else { +fn valid_test_inventory(binding: &TestInventoryBinding) -> Option { + let parent = binding.path.parent()?.canonicalize().ok()?; + if parent != binding.run_dir || binding.path.file_name() != Some(TEST_INVENTORY_FILE.as_ref()) { + return None; + } + let Ok(metadata) = std::fs::symlink_metadata(&binding.path) else { return None; }; if !metadata.file_type().is_file() || metadata.len() > MAX_TEST_INVENTORY_BYTES { @@ -176,7 +226,7 @@ fn valid_test_inventory(path: &Path) -> Option { std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_NOFOLLOW) - .open(path) + .open(&binding.path) }; let Ok(mut file) = file else { return None; @@ -194,10 +244,13 @@ fn valid_test_inventory(path: &Path) -> Option { let Ok(inventory) = serde_json::from_slice::(&bytes) else { return None; }; - valid_test_inventory_payload(&inventory) + valid_test_inventory_payload(&inventory, binding) } -fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> Option { +fn valid_test_inventory_payload( + inventory: &TestInventoryEvidence, + binding: &TestInventoryBinding, +) -> Option { if inventory.schema != TEST_INVENTORY_SCHEMA || !matches!(inventory.runner.as_str(), "cargo" | "nextest") || !homeboy_engine_primitives::content_hash::is_sha256_hex(&inventory.runner_fingerprint) @@ -205,6 +258,12 @@ fn valid_test_inventory_payload(inventory: &TestInventoryEvidence) -> Option Option = None; - let test_inventory = inventory_file.and_then(|path| valid_test_inventory(&path)); + let test_inventory = inventory_binding.as_ref().and_then(|binding| { + revalidate_test_inventory_binding(binding, test_context.as_ref()?, source_path) + .then(|| valid_test_inventory(binding)) + .flatten() + }); let status = test_run_status_with_inventory( output.success, test_counts.as_ref(), @@ -2373,36 +2458,27 @@ mod tests { #[test] fn inventory_mode_requires_explicit_valid_inventory_evidence() { let temp = tempfile::tempdir().expect("temp dir"); - let inventory = temp.path().join("inventory.json"); - let ci_env = vec![ - (TEST_INVENTORY_ONLY_ENV.to_string(), "1".to_string()), - ( - TEST_INVENTORY_FILE_ENV.to_string(), - inventory.to_string_lossy().into_owned(), - ), - ]; + let binding = test_inventory_binding_for_test(temp.path()); - assert!(prepare_test_inventory(&inventory)); + assert!(prepare_test_inventory(&binding)); assert_eq!( test_run_status_with_inventory(true, None, false, true, None), "failed", "requesting inventory mode without evidence must remain unmeasured" ); - assert!( - !requested_test_inventory_file(&ci_env, temp.path()) - .is_some_and(|path| valid_test_inventory(&path).is_some()), - "a missing inventory must remain fail-closed" - ); - std::fs::write(&inventory, "not json").expect("write malformed inventory"); + std::fs::write(&binding.path, "not json").expect("write malformed inventory"); assert!( - valid_test_inventory(&inventory).is_none(), + valid_test_inventory(&binding).is_none(), "a malformed inventory must remain fail-closed" ); - std::fs::write(&inventory, valid_inventory_document()).expect("write inventory"); - let measured = requested_test_inventory_file(&ci_env, temp.path()) - .and_then(|path| valid_test_inventory(&path)); + std::fs::write( + &binding.path, + valid_inventory_document(&binding, "test", "executed"), + ) + .expect("write inventory"); + let measured = valid_test_inventory(&binding); let evidence = measured.as_ref().expect("validated inventory"); assert_eq!(evidence.schema, TEST_INVENTORY_SCHEMA); assert_eq!(evidence.test_count, 1); @@ -2441,38 +2517,59 @@ mod tests { for (case, document) in [ ( "wrong schema", - valid_inventory_document().replacen(TEST_INVENTORY_SCHEMA, "other/schema/v1", 1), + valid_inventory_document(&binding, "test", "executed").replacen( + TEST_INVENTORY_SCHEMA, + "other/schema/v1", + 1, + ), ), - ("incomplete document", "{\"schema\":\"homeboy/test-inventory/v1\"}".to_string()), ( - "invalid provenance", - valid_inventory_document().replacen("\"runner\":\"nextest\"", "\"runner\":\"unknown\"", 1), + "incomplete document", + "{\"schema\":\"homeboy/test-inventory/v1\"}".to_string(), ), ( - "empty inventory", - r#"{"schema":"homeboy/test-inventory/v1","runner":"nextest","runner_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541","workspace_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541","tests":[],"inventory_fingerprint":"4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541"}"#.to_string(), + "arbitrary provenance", + valid_inventory_document(&binding, "test", "executed").replacen( + &binding.runner_fingerprint, + &"c".repeat(64), + 1, + ), + ), + ( + "stale workspace provenance", + valid_inventory_document(&binding, "test", "executed").replacen( + &binding.workspace_fingerprint, + &"d".repeat(64), + 1, + ), + ), + ("empty inventory", inventory_document(&binding, Vec::new())), + ( + "all skipped inventory", + valid_inventory_document(&binding, "test", "skipped"), ), ] { - std::fs::write(&inventory, document).expect("write invalid inventory"); + std::fs::write(&binding.path, document).expect("write invalid inventory"); assert!( - valid_test_inventory(&inventory).is_none(), + valid_test_inventory(&binding).is_none(), "{case} must remain fail-closed" ); } let mut duplicate: TestInventoryEvidence = - serde_json::from_str(&valid_inventory_document()).expect("parse valid inventory"); + serde_json::from_str(&valid_inventory_document(&binding, "test", "executed")) + .expect("parse valid inventory"); duplicate.tests.push(duplicate.tests[0].clone()); duplicate.inventory_fingerprint = homeboy_engine_primitives::content_hash::sha256_hex( &canonical_inventory_json(&duplicate), ); std::fs::write( - &inventory, + &binding.path, serde_json::to_vec(&duplicate).expect("serialize duplicate inventory"), ) .expect("write duplicate inventory"); assert!( - valid_test_inventory(&inventory).is_none(), + valid_test_inventory(&binding).is_none(), "duplicate identities must remain fail-closed even with a matching fingerprint" ); } @@ -2480,45 +2577,42 @@ mod tests { #[test] fn inventory_evidence_is_fresh_confined_and_fingerprint_bound() { let temp = tempfile::tempdir().expect("temp dir"); - let inventory = temp.path().join("inventory.json"); - let outside = tempfile::NamedTempFile::new().expect("outside file"); - let ci_env = vec![ - (TEST_INVENTORY_ONLY_ENV.to_string(), "1".to_string()), - ( - TEST_INVENTORY_FILE_ENV.to_string(), - inventory.to_string_lossy().into_owned(), - ), - ]; + let binding = test_inventory_binding_for_test(temp.path()); - std::fs::write(&inventory, valid_inventory_document()).expect("write stale inventory"); - assert!(prepare_test_inventory(&inventory)); + std::fs::write( + &binding.path, + valid_inventory_document(&binding, "test", "executed"), + ) + .expect("write stale inventory"); + assert!(prepare_test_inventory(&binding)); assert!( - !inventory.exists(), + !binding.path.exists(), "pre-existing evidence must not satisfy a new invocation" ); - assert!(requested_test_inventory_file(&ci_env, temp.path()).is_some()); - - let escaped_env = vec![ - (TEST_INVENTORY_ONLY_ENV.to_string(), "1".to_string()), - ( - TEST_INVENTORY_FILE_ENV.to_string(), - outside.path().to_string_lossy().into_owned(), - ), - ]; - assert!(requested_test_inventory_file(&escaped_env, temp.path()).is_none()); - std::fs::write(&inventory, valid_inventory_document()).expect("write inventory"); - assert!(valid_test_inventory(&inventory).is_some()); - std::fs::write(&inventory, valid_inventory_document_with_unicode_name()) - .expect("write unicode inventory"); + std::fs::write( + &binding.path, + valid_inventory_document(&binding, "test", "executed"), + ) + .expect("write inventory"); + assert!(valid_test_inventory(&binding).is_some()); + std::fs::write( + &binding.path, + valid_inventory_document(&binding, "tést", "executed"), + ) + .expect("write unicode inventory"); assert!( - valid_test_inventory(&inventory).is_some(), + valid_test_inventory(&binding).is_some(), "Python's ASCII-escaped fingerprint must accept Unicode test names" ); - let tampered = valid_inventory_document().replacen("suite::test", "suite::other", 1); - std::fs::write(&inventory, tampered).expect("write tampered inventory"); + let tampered = valid_inventory_document(&binding, "test", "executed").replacen( + "suite::test", + "suite::other", + 1, + ); + std::fs::write(&binding.path, tampered).expect("write tampered inventory"); assert!( - valid_test_inventory(&inventory).is_none(), + valid_test_inventory(&binding).is_none(), "the inventory fingerprint must bind the listed tests" ); } @@ -2530,54 +2624,74 @@ mod tests { let temp = tempfile::tempdir().expect("temp dir"); let target = temp.path().join("target.json"); - let inventory = temp.path().join("inventory.json"); - std::fs::write(&target, valid_inventory_document()).expect("write target"); - symlink(&target, &inventory).expect("link inventory"); + let binding = test_inventory_binding_for_test(temp.path()); + std::fs::write( + &target, + valid_inventory_document(&binding, "test", "executed"), + ) + .expect("write target"); + symlink(&target, &binding.path).expect("link inventory"); - assert!(valid_test_inventory(&inventory).is_none()); - assert!(prepare_test_inventory(&inventory)); + assert!(valid_test_inventory(&binding).is_none()); + assert!(prepare_test_inventory(&binding)); assert!(target.exists(), "cleanup must remove only the link"); } - fn valid_inventory_document() -> String { - inventory_document("test") + #[cfg(unix)] + #[test] + fn inventory_evidence_rejects_intermediate_symlink_swap() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temp dir"); + let mut binding = test_inventory_binding_for_test(temp.path()); + let outside = tempfile::tempdir().expect("outside dir"); + let nested = temp.path().join("child-controlled"); + symlink(outside.path(), &nested).expect("link intermediate directory"); + binding.path = nested.join(TEST_INVENTORY_FILE); + + assert!(!prepare_test_inventory(&binding)); + assert!(valid_test_inventory(&binding).is_none()); } - fn valid_inventory_document_with_unicode_name() -> String { - inventory_document("tést") + fn test_inventory_binding_for_test(run_dir: &Path) -> TestInventoryBinding { + let run_dir = run_dir.canonicalize().expect("canonical run dir"); + TestInventoryBinding { + path: run_dir.join(TEST_INVENTORY_FILE), + run_dir, + runner_fingerprint: "a".repeat(64), + workspace_fingerprint: "b".repeat(64), + } } - fn inventory_document(name: &str) -> String { + fn valid_inventory_document( + binding: &TestInventoryBinding, + name: &str, + outcome: &str, + ) -> String { let test = TestInventoryTest { id: format!("suite::{name}"), package: "suite".to_string(), target: "suite-tests".to_string(), target_kind: "test".to_string(), name: name.to_string(), - expected_outcome: Some("executed".to_string()), + expected_outcome: Some(outcome.to_string()), }; + inventory_document(binding, vec![test]) + } + + fn inventory_document(binding: &TestInventoryBinding, tests: Vec) -> String { let mut inventory = TestInventoryEvidence { schema: TEST_INVENTORY_SCHEMA.to_string(), runner: "nextest".to_string(), - runner_fingerprint: "4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541" - .to_string(), - workspace_fingerprint: - "4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541".to_string(), - tests: vec![test], + runner_fingerprint: binding.runner_fingerprint.clone(), + workspace_fingerprint: binding.workspace_fingerprint.clone(), + tests, inventory_fingerprint: String::new(), }; inventory.inventory_fingerprint = homeboy_engine_primitives::content_hash::sha256_hex( &canonical_inventory_json(&inventory), ); - serde_json::json!({ - "schema": TEST_INVENTORY_SCHEMA, - "runner": "nextest", - "runner_fingerprint": "4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541", - "workspace_fingerprint": "4bc8f808e3961a908e60dd93cf7d81885e52135e1f44a411fbfb18ef5ce63541", - "tests": inventory.tests, - "inventory_fingerprint": inventory.inventory_fingerprint, - }) - .to_string() + serde_json::to_string(&inventory).expect("serialize inventory") } #[test] From fd96cb3645d608e81a6c35c6588a111df8056420 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 00:25:10 -0400 Subject: [PATCH 12/17] fix(test): verify inventory provenance by dirfd [AI: openai/gpt-5.6-terra via OpenCode; implemented Rust producer parity and race hardening] --- crates/homeboy-extension/src/test/run.rs | 333 +++++++++++++----- .../test_inventory_fingerprint/Cargo.lock | 2 + .../test_inventory_fingerprint/Cargo.toml | 3 + .../crate-a/Cargo.toml | 4 + .../crate-a/src/lib.rs | 3 + 5 files changed, 248 insertions(+), 97 deletions(-) create mode 100644 crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.lock create mode 100644 crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.toml create mode 100644 crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/Cargo.toml create mode 100644 crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/src/lib.rs diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index 2ca638078a..7ca444c06d 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -29,6 +29,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use std::io::Read; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::Duration; #[derive(Debug, Clone)] @@ -112,91 +113,170 @@ fn test_inventory_mode(ci_env: &[(String, String)]) -> bool { .any(|(key, value)| key == TEST_INVENTORY_ONLY_ENV && value == "1") } -#[derive(Clone, Debug)] +#[derive(Debug)] struct TestInventoryBinding { - run_dir: PathBuf, path: PathBuf, - runner_fingerprint: String, workspace_fingerprint: String, + cargo_runner_fingerprint: String, + nextest_runner_fingerprint: String, + #[cfg(unix)] + run_dir: std::fs::File, + #[cfg(unix)] + run_dir_device: u64, + #[cfg(unix)] + run_dir_inode: u64, } -fn test_inventory_binding( - context: &crate::ExtensionExecutionContext, - source_path: &Path, - run_dir: &RunDir, -) -> Option { - let run_dir_metadata = std::fs::symlink_metadata(run_dir.path()).ok()?; - if !run_dir_metadata.file_type().is_dir() || run_dir_metadata.file_type().is_symlink() { - return None; - } - let run_dir = run_dir.path().canonicalize().ok()?; - let extension_path = context.extension_path.canonicalize().ok()?; - let script_path = extension_path - .join(&context.script_path) - .canonicalize() +#[cfg(not(unix))] +fn test_inventory_binding(_source_path: &Path, _run_dir: &RunDir) -> Option { + None +} + +#[cfg(unix)] +fn test_inventory_binding(source_path: &Path, run_dir: &RunDir) -> Option { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let path = run_dir.path().join(TEST_INVENTORY_FILE); + let run_dir = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(run_dir.path()) .ok()?; - if !script_path.starts_with(&extension_path) || !script_path.is_file() { + let metadata = run_dir.metadata().ok()?; + if !metadata.is_dir() { return None; } - let runner_fingerprint = - homeboy_engine_primitives::content_hash::sha256_file(&script_path).ok()?; - let snapshot = homeboy_core::source_snapshot::collect_local( - "homeboy-test-inventory", - source_path, - None, - "local", - ); - if snapshot.git_sha.is_none() { + let workspace_root = cargo_workspace_root(source_path)?; + Some(TestInventoryBinding { + path, + workspace_fingerprint: workspace_fingerprint(&workspace_root)?, + cargo_runner_fingerprint: runner_fingerprint(&workspace_root, "cargo")?, + nextest_runner_fingerprint: runner_fingerprint(&workspace_root, "nextest")?, + run_dir_device: metadata.dev(), + run_dir_inode: metadata.ino(), + run_dir, + }) +} + +fn cargo_workspace_root(source_path: &Path) -> Option { + let output = Command::new("cargo") + .args(["metadata", "--no-deps", "--format-version=1"]) + .current_dir(source_path) + .output() + .ok()?; + if !output.status.success() { return None; } - let workspace_fingerprint = snapshot.snapshot_hash.strip_prefix("sha256:")?.to_string(); - homeboy_engine_primitives::content_hash::is_sha256_hex(&workspace_fingerprint).then(|| { - TestInventoryBinding { - path: run_dir.join(TEST_INVENTORY_FILE), - run_dir, - runner_fingerprint, - workspace_fingerprint, + let metadata = serde_json::from_slice::(&output.stdout).ok()?; + let workspace_root = metadata.get("workspace_root")?.as_str()?; + PathBuf::from(workspace_root).canonicalize().ok() +} + +fn runner_fingerprint(workspace_root: &Path, runner: &str) -> Option { + let args = if runner == "nextest" { + vec!["nextest", "--version"] + } else { + vec!["--version"] + }; + let output = Command::new("cargo") + .args(args) + .current_dir(workspace_root) + .output() + .ok()?; + output.status.success().then(|| { + let version = String::from_utf8(output.stdout).ok()?; + Some(runner_fingerprint_from_version(runner, version.trim())) + })? +} + +fn runner_fingerprint_from_version(runner: &str, version: &str) -> String { + homeboy_engine_primitives::content_hash::sha256_hex(format!("{runner}\0{version}").as_bytes()) +} + +fn workspace_fingerprint(root: &Path) -> Option { + fn collect(root: &Path, directory: &Path, files: &mut Vec) -> Option<()> { + for entry in std::fs::read_dir(directory).ok()? { + let entry = entry.ok()?; + let path = entry.path(); + let file_type = entry.file_type().ok()?; + if file_type.is_dir() { + if !matches!(entry.file_name().to_str(), Some(".git" | "target")) { + collect(root, &path, files)?; + } + } else if path.is_file() { + let name = entry.file_name(); + if matches!(name.to_str(), Some("Cargo.toml" | "Cargo.lock")) + || path.extension().is_some_and(|extension| extension == "rs") + { + files.push(path.strip_prefix(root).ok()?.to_path_buf()); + } + } } - }) + Some(()) + } + + let mut files = Vec::new(); + collect(root, root, &mut files)?; + files.sort(); + let mut content = String::new(); + for relative in files { + let path = root.join(&relative); + content.push_str(relative.to_str()?); + content.push('\0'); + content.push_str(&std::fs::read_to_string(path).ok()?); + content.push('\0'); + } + Some(homeboy_engine_primitives::content_hash::sha256_hex( + content.as_bytes(), + )) } -fn revalidate_test_inventory_binding( - binding: &TestInventoryBinding, - context: &crate::ExtensionExecutionContext, - source_path: &Path, -) -> bool { - let Ok(run_dir) = RunDir::from_existing(binding.run_dir.clone()) else { +#[cfg(unix)] +fn revalidate_test_inventory_binding(binding: &TestInventoryBinding, source_path: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + + let Ok(metadata) = binding.run_dir.metadata() else { return false; }; - let Some(current) = test_inventory_binding(context, source_path, &run_dir) else { + // This detects replacement for diagnostics, but the held descriptor remains + // the authority even when its original name has been renamed by the child. + if metadata.dev() != binding.run_dir_device || metadata.ino() != binding.run_dir_inode { + return false; + } + let Some(workspace_root) = cargo_workspace_root(source_path) else { return false; }; - current.path == binding.path - && current.runner_fingerprint == binding.runner_fingerprint - && current.workspace_fingerprint == binding.workspace_fingerprint + workspace_fingerprint(&workspace_root) == Some(binding.workspace_fingerprint.clone()) + && runner_fingerprint(&workspace_root, "cargo") + == Some(binding.cargo_runner_fingerprint.clone()) + && runner_fingerprint(&workspace_root, "nextest") + == Some(binding.nextest_runner_fingerprint.clone()) } /// Delete stale evidence before the child starts. The child may only write a /// regular file directly below its trusted run directory, so a prior run cannot /// satisfy this run. -fn prepare_test_inventory(binding: &TestInventoryBinding) -> bool { - let Some(path_parent) = binding.path.parent() else { - return false; - }; - let Ok(parent) = path_parent.canonicalize() else { - return false; +#[cfg(not(unix))] +fn prepare_test_inventory(_binding: &TestInventoryBinding) -> bool { + false +} + +#[cfg(unix)] +fn unlink_test_inventory(binding: &TestInventoryBinding) -> bool { + use std::os::fd::AsRawFd; + let result = unsafe { + libc::unlinkat( + binding.run_dir.as_raw_fd(), + c"test-inventory.json".as_ptr(), + 0, + ) }; - if parent != binding.run_dir || binding.path.file_name() != Some(TEST_INVENTORY_FILE.as_ref()) { - return false; - } - match std::fs::symlink_metadata(&binding.path) { - Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => { - std::fs::remove_file(&binding.path).is_ok() - } - Ok(_) => false, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(_) => false, - } + result == 0 || std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound +} + +#[cfg(unix)] +fn prepare_test_inventory(binding: &TestInventoryBinding) -> bool { + unlink_test_inventory(binding) } /// Inventory planning currently runs on Unix CI. Other platforms lack an @@ -209,36 +289,31 @@ fn valid_test_inventory(binding: &TestInventoryBinding) -> Option Option { - let parent = binding.path.parent()?.canonicalize().ok()?; - if parent != binding.run_dir || binding.path.file_name() != Some(TEST_INVENTORY_FILE.as_ref()) { - return None; - } - let Ok(metadata) = std::fs::symlink_metadata(&binding.path) else { - return None; - }; - if !metadata.file_type().is_file() || metadata.len() > MAX_TEST_INVENTORY_BYTES { - return None; - } - - let file = { - use std::os::unix::fs::OpenOptionsExt; - - std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW) - .open(&binding.path) + use std::os::fd::{AsRawFd, FromRawFd}; + let file = unsafe { + let fd = libc::openat( + binding.run_dir.as_raw_fd(), + c"test-inventory.json".as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ); + (fd >= 0).then(|| std::fs::File::from_raw_fd(fd)) }; - let Ok(mut file) = file else { + let Some(mut file) = file else { + let _ = unlink_test_inventory(binding); return None; }; let Ok(opened_metadata) = file.metadata() else { + let _ = unlink_test_inventory(binding); return None; }; if !opened_metadata.is_file() || opened_metadata.len() > MAX_TEST_INVENTORY_BYTES { + let _ = unlink_test_inventory(binding); return None; } let mut bytes = Vec::with_capacity(opened_metadata.len() as usize); - if file.read_to_end(&mut bytes).is_err() || bytes.len() as u64 != opened_metadata.len() { + let read = file.read_to_end(&mut bytes).is_ok() && bytes.len() as u64 == opened_metadata.len(); + let unlinked = unlink_test_inventory(binding); + if !read || !unlinked { return None; } let Ok(inventory) = serde_json::from_slice::(&bytes) else { @@ -262,7 +337,12 @@ fn valid_test_inventory_payload( .tests .iter() .all(|test| test.expected_outcome.as_deref() == Some("skipped")) - || inventory.runner_fingerprint != binding.runner_fingerprint + || inventory.runner_fingerprint.as_str() + != match inventory.runner.as_str() { + "cargo" => binding.cargo_runner_fingerprint.as_str(), + "nextest" => binding.nextest_runner_fingerprint.as_str(), + _ => return None, + } || inventory.workspace_fingerprint != binding.workspace_fingerprint { return None; @@ -296,7 +376,11 @@ fn valid_test_inventory_payload( // Rebuild the signed shape from parent-bound provenance rather than accepting // the child fields as the material that defines the schema fingerprint. let mut bound_inventory = inventory.clone(); - bound_inventory.runner_fingerprint = binding.runner_fingerprint.clone(); + bound_inventory.runner_fingerprint = match inventory.runner.as_str() { + "cargo" => binding.cargo_runner_fingerprint.clone(), + "nextest" => binding.nextest_runner_fingerprint.clone(), + _ => return None, + }; bound_inventory.workspace_fingerprint = binding.workspace_fingerprint.clone(); (homeboy_engine_primitives::content_hash::sha256_hex(&canonical_inventory_json( &bound_inventory, @@ -701,7 +785,7 @@ fn run_main_test_workflow_inner( .then(|| { test_context .as_ref() - .and_then(|context| test_inventory_binding(context, source_path, run_dir)) + .and_then(|_| test_inventory_binding(source_path, run_dir)) }) .flatten() .filter(prepare_test_inventory); @@ -839,7 +923,7 @@ fn run_main_test_workflow_inner( let test_autofix: Option = None; let test_inventory = inventory_binding.as_ref().and_then(|binding| { - revalidate_test_inventory_binding(binding, test_context.as_ref()?, source_path) + revalidate_test_inventory_binding(binding, source_path) .then(|| valid_test_inventory(binding)) .flatten() }); @@ -2530,7 +2614,7 @@ mod tests { ( "arbitrary provenance", valid_inventory_document(&binding, "test", "executed").replacen( - &binding.runner_fingerprint, + &binding.nextest_runner_fingerprint, &"c".repeat(64), 1, ), @@ -2617,6 +2701,26 @@ mod tests { ); } + /// Golden values produced by `homeboy-extensions/rust/scripts/test-shard-inventory.py`. + /// Keep these byte-for-byte values aligned with the producer's v1 contract. + #[test] + fn inventory_provenance_fingerprints_match_producer_golden_fixture() { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test_inventory_fingerprint"); + assert_eq!( + workspace_fingerprint(&root).expect("fingerprint fixture"), + "3ff128fc5701066e7fc0324c88cd18ec1bc6b1ea5aa8390b1661da891e106712" + ); + assert_eq!( + runner_fingerprint_from_version("cargo", "cargo 1.85.0 (fixture)"), + "75505895481f59e56262ce8b0cd07ac303f136fca4dc7cfeafd7dd3b1fcfc66a" + ); + assert_eq!( + runner_fingerprint_from_version("nextest", "cargo-nextest 0.9.99 (fixture)"), + "09c443d61494d183c1a8441ca0f568decd4130b51a0a5c3a66c846efc6991f78" + ); + } + #[cfg(unix)] #[test] fn inventory_evidence_rejects_symlinks() { @@ -2639,27 +2743,62 @@ mod tests { #[cfg(unix)] #[test] - fn inventory_evidence_rejects_intermediate_symlink_swap() { + fn inventory_evidence_uses_held_directory_descriptor_across_rename_symlink_race() { use std::os::unix::fs::symlink; let temp = tempfile::tempdir().expect("temp dir"); - let mut binding = test_inventory_binding_for_test(temp.path()); + let run_dir = temp.path().join("run"); + std::fs::create_dir(&run_dir).expect("create run dir"); + let binding = test_inventory_binding_for_test(&run_dir); let outside = tempfile::tempdir().expect("outside dir"); - let nested = temp.path().join("child-controlled"); - symlink(outside.path(), &nested).expect("link intermediate directory"); - binding.path = nested.join(TEST_INVENTORY_FILE); + let moved = temp.path().join("run-renamed"); + std::fs::rename(&run_dir, &moved).expect("rename held run directory"); + symlink(outside.path(), &run_dir).expect("replace run directory with outside link"); + std::fs::write( + moved.join(TEST_INVENTORY_FILE), + valid_inventory_document(&binding, "held", "executed"), + ) + .expect("write evidence into held directory"); + let outside_inventory = outside.path().join(TEST_INVENTORY_FILE); + std::fs::write(&outside_inventory, "outside evidence must survive") + .expect("write outside evidence"); - assert!(!prepare_test_inventory(&binding)); - assert!(valid_test_inventory(&binding).is_none()); + assert!(valid_test_inventory(&binding).is_some()); + assert!( + !moved.join(TEST_INVENTORY_FILE).exists(), + "the consumed evidence must be unlinked relative to the held descriptor" + ); + assert_eq!( + std::fs::read_to_string(&outside_inventory).expect("read outside evidence"), + "outside evidence must survive", + "the replacement pathname must neither be read nor unlinked" + ); } fn test_inventory_binding_for_test(run_dir: &Path) -> TestInventoryBinding { + #[cfg(unix)] + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + let run_dir = run_dir.canonicalize().expect("canonical run dir"); + #[cfg(unix)] + let descriptor = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(&run_dir) + .expect("open run directory"); + #[cfg(unix)] + let metadata = descriptor.metadata().expect("run directory metadata"); TestInventoryBinding { path: run_dir.join(TEST_INVENTORY_FILE), - run_dir, - runner_fingerprint: "a".repeat(64), workspace_fingerprint: "b".repeat(64), + cargo_runner_fingerprint: "a".repeat(64), + nextest_runner_fingerprint: "a".repeat(64), + #[cfg(unix)] + run_dir: descriptor, + #[cfg(unix)] + run_dir_device: metadata.dev(), + #[cfg(unix)] + run_dir_inode: metadata.ino(), } } @@ -2683,7 +2822,7 @@ mod tests { let mut inventory = TestInventoryEvidence { schema: TEST_INVENTORY_SCHEMA.to_string(), runner: "nextest".to_string(), - runner_fingerprint: binding.runner_fingerprint.clone(), + runner_fingerprint: binding.nextest_runner_fingerprint.clone(), workspace_fingerprint: binding.workspace_fingerprint.clone(), tests, inventory_fingerprint: String::new(), diff --git a/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.lock b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.lock new file mode 100644 index 0000000000..fd9407453c --- /dev/null +++ b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.lock @@ -0,0 +1,2 @@ +# This lockfile is deliberately part of the v1 fingerprint fixture. +version = 4 diff --git a/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.toml b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.toml new file mode 100644 index 0000000000..db5a31e67e --- /dev/null +++ b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["crate-a"] +resolver = "2" diff --git a/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/Cargo.toml b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/Cargo.toml new file mode 100644 index 0000000000..5a2fa815b0 --- /dev/null +++ b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate-a" +version = "0.1.0" +edition = "2021" diff --git a/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/src/lib.rs b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/src/lib.rs new file mode 100644 index 0000000000..9a07dc1f16 --- /dev/null +++ b/crates/homeboy-extension/tests/fixtures/test_inventory_fingerprint/crate-a/src/lib.rs @@ -0,0 +1,3 @@ +pub fn cafe() -> &'static str { + "cafe" +} From 1fd06341e090c211078d3a9c9fae0de0c8e94a67 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 08:48:03 -0400 Subject: [PATCH 13/17] fix(test): bind inventory to selected runner References #11751 and #12000. AI assistance: openai/gpt-5.6-terra via OpenCode implemented runner-aware inventory verification and focused tests. Chris Huber remains responsible for every line. --- crates/homeboy-extension/src/test/run.rs | 119 +++++++++++++++++------ 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index 7ca444c06d..4fd86f4775 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -117,8 +117,8 @@ fn test_inventory_mode(ci_env: &[(String, String)]) -> bool { struct TestInventoryBinding { path: PathBuf, workspace_fingerprint: String, - cargo_runner_fingerprint: String, - nextest_runner_fingerprint: String, + cargo_runner_fingerprint: Option, + nextest_runner_fingerprint: Option, #[cfg(unix)] run_dir: std::fs::File, #[cfg(unix)] @@ -150,8 +150,10 @@ fn test_inventory_binding(source_path: &Path, run_dir: &RunDir) -> Option Option { } #[cfg(unix)] -fn revalidate_test_inventory_binding(binding: &TestInventoryBinding, source_path: &Path) -> bool { +fn revalidate_test_inventory_binding( + binding: &TestInventoryBinding, + source_path: &Path, + runner: &str, +) -> bool { use std::os::unix::fs::MetadataExt; let Ok(metadata) = binding.run_dir.metadata() else { @@ -247,10 +253,16 @@ fn revalidate_test_inventory_binding(binding: &TestInventoryBinding, source_path return false; }; workspace_fingerprint(&workspace_root) == Some(binding.workspace_fingerprint.clone()) - && runner_fingerprint(&workspace_root, "cargo") - == Some(binding.cargo_runner_fingerprint.clone()) - && runner_fingerprint(&workspace_root, "nextest") - == Some(binding.nextest_runner_fingerprint.clone()) + && runner_fingerprint(&workspace_root, runner) + == expected_runner_fingerprint(binding, runner) +} + +fn expected_runner_fingerprint(binding: &TestInventoryBinding, runner: &str) -> Option { + match runner { + "cargo" => binding.cargo_runner_fingerprint.clone(), + "nextest" => binding.nextest_runner_fingerprint.clone(), + _ => None, + } } /// Delete stale evidence before the child starts. The child may only write a @@ -337,12 +349,8 @@ fn valid_test_inventory_payload( .tests .iter() .all(|test| test.expected_outcome.as_deref() == Some("skipped")) - || inventory.runner_fingerprint.as_str() - != match inventory.runner.as_str() { - "cargo" => binding.cargo_runner_fingerprint.as_str(), - "nextest" => binding.nextest_runner_fingerprint.as_str(), - _ => return None, - } + || expected_runner_fingerprint(binding, &inventory.runner).as_deref() + != Some(inventory.runner_fingerprint.as_str()) || inventory.workspace_fingerprint != binding.workspace_fingerprint { return None; @@ -360,7 +368,7 @@ fn valid_test_inventory_payload( || test .expected_outcome .as_deref() - .is_some_and(|outcome| !matches!(outcome, "executed" | "skipped")) + .is_none_or(|outcome| !matches!(outcome, "executed" | "skipped")) }) { return None; } @@ -376,11 +384,7 @@ fn valid_test_inventory_payload( // Rebuild the signed shape from parent-bound provenance rather than accepting // the child fields as the material that defines the schema fingerprint. let mut bound_inventory = inventory.clone(); - bound_inventory.runner_fingerprint = match inventory.runner.as_str() { - "cargo" => binding.cargo_runner_fingerprint.clone(), - "nextest" => binding.nextest_runner_fingerprint.clone(), - _ => return None, - }; + bound_inventory.runner_fingerprint = expected_runner_fingerprint(binding, &inventory.runner)?; bound_inventory.workspace_fingerprint = binding.workspace_fingerprint.clone(); (homeboy_engine_primitives::content_hash::sha256_hex(&canonical_inventory_json( &bound_inventory, @@ -923,9 +927,9 @@ fn run_main_test_workflow_inner( let test_autofix: Option = None; let test_inventory = inventory_binding.as_ref().and_then(|binding| { - revalidate_test_inventory_binding(binding, source_path) - .then(|| valid_test_inventory(binding)) - .flatten() + valid_test_inventory(binding).filter(|inventory| { + revalidate_test_inventory_binding(binding, source_path, &inventory.runner) + }) }); let status = test_run_status_with_inventory( output.success, @@ -2614,7 +2618,10 @@ mod tests { ( "arbitrary provenance", valid_inventory_document(&binding, "test", "executed").replacen( - &binding.nextest_runner_fingerprint, + binding + .nextest_runner_fingerprint + .as_deref() + .expect("nextest fingerprint"), &"c".repeat(64), 1, ), @@ -2656,6 +2663,24 @@ mod tests { valid_test_inventory(&binding).is_none(), "duplicate identities must remain fail-closed even with a matching fingerprint" ); + + let missing_outcome = TestInventoryTest { + id: "suite::missing-outcome".to_string(), + package: "suite".to_string(), + target: "suite-tests".to_string(), + target_kind: "test".to_string(), + name: "missing-outcome".to_string(), + expected_outcome: None, + }; + std::fs::write( + &binding.path, + inventory_document(&binding, vec![missing_outcome]), + ) + .expect("write missing outcome inventory"); + assert!( + valid_test_inventory(&binding).is_none(), + "every inventory identity must declare its expected outcome" + ); } #[test] @@ -2701,6 +2726,41 @@ mod tests { ); } + #[test] + fn cargo_inventory_binds_without_cargo_nextest() { + let temp = tempfile::tempdir().expect("temp dir"); + let mut binding = test_inventory_binding_for_test(temp.path()); + let test = TestInventoryTest { + id: "suite::cargo".to_string(), + package: "suite".to_string(), + target: "suite-tests".to_string(), + target_kind: "test".to_string(), + name: "cargo".to_string(), + expected_outcome: Some("executed".to_string()), + }; + let mut inventory: TestInventoryEvidence = + serde_json::from_str(&inventory_document(&binding, vec![test])) + .expect("parse inventory"); + binding.nextest_runner_fingerprint = None; + inventory.runner = "cargo".to_string(); + inventory.runner_fingerprint = binding + .cargo_runner_fingerprint + .clone() + .expect("cargo fingerprint"); + inventory.inventory_fingerprint = homeboy_engine_primitives::content_hash::sha256_hex( + &canonical_inventory_json(&inventory), + ); + std::fs::write( + &binding.path, + serde_json::to_vec(&inventory).expect("serialize cargo inventory"), + ) + .expect("write cargo inventory"); + assert!( + valid_test_inventory(&binding).is_some(), + "Cargo inventory must not require cargo-nextest" + ); + } + /// Golden values produced by `homeboy-extensions/rust/scripts/test-shard-inventory.py`. /// Keep these byte-for-byte values aligned with the producer's v1 contract. #[test] @@ -2791,8 +2851,8 @@ mod tests { TestInventoryBinding { path: run_dir.join(TEST_INVENTORY_FILE), workspace_fingerprint: "b".repeat(64), - cargo_runner_fingerprint: "a".repeat(64), - nextest_runner_fingerprint: "a".repeat(64), + cargo_runner_fingerprint: Some("a".repeat(64)), + nextest_runner_fingerprint: Some("a".repeat(64)), #[cfg(unix)] run_dir: descriptor, #[cfg(unix)] @@ -2822,7 +2882,10 @@ mod tests { let mut inventory = TestInventoryEvidence { schema: TEST_INVENTORY_SCHEMA.to_string(), runner: "nextest".to_string(), - runner_fingerprint: binding.nextest_runner_fingerprint.clone(), + runner_fingerprint: binding + .nextest_runner_fingerprint + .clone() + .expect("nextest fingerprint"), workspace_fingerprint: binding.workspace_fingerprint.clone(), tests, inventory_fingerprint: String::new(), From f9fb0c09ce162f1c5b154e8dcb1738743ee0646a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 08:58:44 -0400 Subject: [PATCH 14/17] fix(test): normalize workspace fingerprint newlines [AI: openai/gpt-5.6-terra via OpenCode; matched Python universal-newline hashing] --- crates/homeboy-extension/src/test/run.rs | 99 +++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index 4fd86f4775..8ec2220c9f 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -225,7 +225,14 @@ fn workspace_fingerprint(root: &Path) -> Option { let path = root.join(&relative); content.push_str(relative.to_str()?); content.push('\0'); - content.push_str(&std::fs::read_to_string(path).ok()?); + // Path.read_text() translates CRLF and lone CR to LF before the Python + // inventory producer concatenates its fingerprint input. + content.push_str( + &std::fs::read_to_string(path) + .ok()? + .replace("\r\n", "\n") + .replace('\r', "\n"), + ); content.push('\0'); } Some(homeboy_engine_primitives::content_hash::sha256_hex( @@ -2781,6 +2788,96 @@ mod tests { ); } + #[test] + fn workspace_fingerprint_matches_python_producer_for_universal_newlines() { + let temp = tempfile::tempdir().expect("temp workspace"); + let root = temp.path(); + std::fs::create_dir(root.join("src")).expect("create source directory"); + std::fs::write( + root.join("Cargo.toml"), + b"[package]\nname = \"newline-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .expect("write LF manifest"); + std::fs::write( + root.join("Cargo.lock"), + b"# This file is automatically @generated by Cargo.\r\nversion = 4\r\n\r\n[[package]]\r\nname = \"newline-fixture\"\r\nversion = \"0.1.0\"\r\n", + ) + .expect("write valid CRLF lockfile"); + std::fs::write( + root.join("src/lib.rs"), + b"pub fn newline_fixture() -> &'static str {\r \"lone CR\"\r}\r", + ) + .expect("write lone-CR source"); + let metadata = Command::new("cargo") + .args(["metadata", "--locked", "--no-deps", "--format-version=1"]) + .current_dir(root) + .output() + .expect("validate fixture lockfile"); + assert!( + metadata.status.success(), + "fixture lockfile must be valid: {}", + String::from_utf8_lossy(&metadata.stderr) + ); + + let python = r#" +import hashlib +import sys +from pathlib import Path + +root = Path(sys.argv[1]).resolve() +files = sorted( + path for path in root.rglob("*") + if path.is_file() + and ".git" not in path.parts + and "target" not in path.parts + and (path.name in {"Cargo.toml", "Cargo.lock"} or path.suffix == ".rs") +) +content = "".join(f"{path.relative_to(root)}\0{path.read_text()}\0" for path in files) +print(hashlib.sha256(content.encode()).hexdigest()) +"#; + let output = Command::new("python3") + .args(["-c", python]) + .arg(root) + .output() + .expect("run Python inventory producer"); + assert!( + output.status.success(), + "Python inventory producer failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let producer_fingerprint = String::from_utf8(output.stdout) + .expect("Python producer output is UTF-8") + .trim() + .to_string(); + + assert_eq!( + producer_fingerprint, + "3db8dc90de016e27318c257eb17536777a3770e36384f8c9799f300bcbd1abc0", + "the Python producer golden fingerprint must remain stable" + ); + assert_eq!( + workspace_fingerprint(root), + Some(producer_fingerprint), + "the Rust verifier must match Path.read_text() universal-newline semantics" + ); + + std::fs::write(root.join("src/invalid.rs"), b"\xff").expect("write invalid UTF-8 source"); + let invalid_utf8 = Command::new("python3") + .args(["-c", python]) + .arg(root) + .output() + .expect("run Python inventory producer with invalid UTF-8"); + assert!( + !invalid_utf8.status.success(), + "Path.read_text() must reject invalid UTF-8 fingerprint input" + ); + assert_eq!( + workspace_fingerprint(root), + None, + "the Rust verifier must fail closed when the Python producer cannot decode a file" + ); + } + #[cfg(unix)] #[test] fn inventory_evidence_rejects_symlinks() { From ee2220d33a8dd614b208374933116ee92a10b932 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 09:20:39 -0400 Subject: [PATCH 15/17] fix(test): cfg-gate Unix inventory evidence [AI: openai/gpt-5.6-terra via OpenCode] --- crates/homeboy-extension/src/test/run.rs | 89 +++++++++++++++--------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index 8ec2220c9f..985629a3ca 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -27,10 +27,15 @@ pub use homeboy_extension_contract::test_workflow::RawTestOutput; use homeboy_refactor_contract::AppliedRefactor; use regex::Regex; use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::Duration; + +#[cfg(unix)] use std::io::Read; -use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::path::PathBuf; +#[cfg(unix)] use std::process::Command; -use std::time::Duration; #[derive(Debug, Clone)] pub struct TestRunWorkflowArgs { @@ -61,8 +66,11 @@ const NO_TESTS_APPLICABLE_EXTENSION_ENV: &str = "HOMEBOY_NO_TESTS_APPLICABLE_EXT const NO_TESTS_APPLICABLE_STEP: &str = "test"; const TEST_INVENTORY_ONLY_ENV: &str = "HOMEBOY_TEST_INVENTORY_ONLY"; const TEST_INVENTORY_FILE_ENV: &str = "HOMEBOY_TEST_INVENTORY_FILE"; +#[cfg(unix)] const TEST_INVENTORY_SCHEMA: &str = "homeboy/test-inventory/v1"; +#[cfg(unix)] const TEST_INVENTORY_FILE: &str = "test-inventory.json"; +#[cfg(unix)] const MAX_TEST_INVENTORY_BYTES: u64 = 64 * 1024 * 1024; const DEFAULT_TEST_TIMEOUT_SECONDS: u64 = 25 * 60; @@ -84,6 +92,7 @@ struct NoTestsApplicableEvidence { reason: String, } +#[cfg(unix)] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct TestInventoryEvidence { @@ -95,6 +104,7 @@ struct TestInventoryEvidence { inventory_fingerprint: String, } +#[cfg(unix)] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct TestInventoryTest { @@ -113,25 +123,18 @@ fn test_inventory_mode(ci_env: &[(String, String)]) -> bool { .any(|(key, value)| key == TEST_INVENTORY_ONLY_ENV && value == "1") } +#[cfg(unix)] #[derive(Debug)] struct TestInventoryBinding { path: PathBuf, workspace_fingerprint: String, cargo_runner_fingerprint: Option, nextest_runner_fingerprint: Option, - #[cfg(unix)] run_dir: std::fs::File, - #[cfg(unix)] run_dir_device: u64, - #[cfg(unix)] run_dir_inode: u64, } -#[cfg(not(unix))] -fn test_inventory_binding(_source_path: &Path, _run_dir: &RunDir) -> Option { - None -} - #[cfg(unix)] fn test_inventory_binding(source_path: &Path, run_dir: &RunDir) -> Option { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; @@ -160,6 +163,7 @@ fn test_inventory_binding(source_path: &Path, run_dir: &RunDir) -> Option Option { let output = Command::new("cargo") .args(["metadata", "--no-deps", "--format-version=1"]) @@ -174,6 +178,7 @@ fn cargo_workspace_root(source_path: &Path) -> Option { PathBuf::from(workspace_root).canonicalize().ok() } +#[cfg(unix)] fn runner_fingerprint(workspace_root: &Path, runner: &str) -> Option { let args = if runner == "nextest" { vec!["nextest", "--version"] @@ -191,10 +196,12 @@ fn runner_fingerprint(workspace_root: &Path, runner: &str) -> Option { })? } +#[cfg(unix)] fn runner_fingerprint_from_version(runner: &str, version: &str) -> String { homeboy_engine_primitives::content_hash::sha256_hex(format!("{runner}\0{version}").as_bytes()) } +#[cfg(unix)] fn workspace_fingerprint(root: &Path) -> Option { fn collect(root: &Path, directory: &Path, files: &mut Vec) -> Option<()> { for entry in std::fs::read_dir(directory).ok()? { @@ -264,6 +271,7 @@ fn revalidate_test_inventory_binding( == expected_runner_fingerprint(binding, runner) } +#[cfg(unix)] fn expected_runner_fingerprint(binding: &TestInventoryBinding, runner: &str) -> Option { match runner { "cargo" => binding.cargo_runner_fingerprint.clone(), @@ -272,14 +280,6 @@ fn expected_runner_fingerprint(binding: &TestInventoryBinding, runner: &str) -> } } -/// Delete stale evidence before the child starts. The child may only write a -/// regular file directly below its trusted run directory, so a prior run cannot -/// satisfy this run. -#[cfg(not(unix))] -fn prepare_test_inventory(_binding: &TestInventoryBinding) -> bool { - false -} - #[cfg(unix)] fn unlink_test_inventory(binding: &TestInventoryBinding) -> bool { use std::os::fd::AsRawFd; @@ -298,14 +298,6 @@ fn prepare_test_inventory(binding: &TestInventoryBinding) -> bool { unlink_test_inventory(binding) } -/// Inventory planning currently runs on Unix CI. Other platforms lack an -/// equivalent no-follow open here, so this optional evidence stays closed. -#[cfg(not(unix))] -fn valid_test_inventory(binding: &TestInventoryBinding) -> Option { - let _ = binding; - None -} - #[cfg(unix)] fn valid_test_inventory(binding: &TestInventoryBinding) -> Option { use std::os::fd::{AsRawFd, FromRawFd}; @@ -341,6 +333,7 @@ fn valid_test_inventory(binding: &TestInventoryBinding) -> Option Vec { let mut json = String::from("{\"runner\":"); append_python_json_string(&mut json, &inventory.runner); @@ -445,6 +439,7 @@ fn canonical_inventory_json(inventory: &TestInventoryEvidence) -> Vec { json.into_bytes() } +#[cfg(unix)] fn append_python_json_string(json: &mut String, value: &str) { json.push('"'); for character in value.chars() { @@ -792,6 +787,7 @@ fn run_main_test_workflow_inner( let write_results_helper = write_test_results_helper(run_dir)?; let inventory_mode = test_inventory_mode(&args.ci_env); + #[cfg(unix)] let inventory_binding = inventory_mode .then(|| { test_context @@ -933,11 +929,16 @@ fn run_main_test_workflow_inner( // Autofix is owned by `refactor --from test --write`; the test command is read-only. let test_autofix: Option = None; + #[cfg(unix)] let test_inventory = inventory_binding.as_ref().and_then(|binding| { valid_test_inventory(binding).filter(|inventory| { revalidate_test_inventory_binding(binding, source_path, &inventory.runner) }) }); + // Descriptor-bound inventory evidence is Unix-only. Other platforms retain + // normal test execution, but inventory-only mode cannot manufacture a pass. + #[cfg(not(unix))] + let test_inventory: Option = None; let status = test_run_status_with_inventory( output.success, test_counts.as_ref(), @@ -2524,6 +2525,28 @@ mod tests { assert_eq!(test_run_status(true, None, false), "failed"); } + #[cfg(not(unix))] + #[test] + fn non_unix_inventory_mode_stays_fail_closed_without_disabling_normal_tests() { + assert_eq!( + test_run_status_with_inventory(true, None, false, true, None), + "failed", + "inventory-only mode requires descriptor-bound evidence unavailable on this platform" + ); + assert_eq!( + test_run_status_with_inventory( + true, + Some(&TestCounts::new(3, 3, 0, 0)), + false, + false, + None, + ), + "passed", + "normal test execution must continue to use parsed test counts" + ); + } + + #[cfg(unix)] #[test] fn inventory_success_is_not_re_finalized_from_execution_counts() { let mut workflow = failed_test_workflow( @@ -2550,6 +2573,7 @@ mod tests { assert_eq!(workflow.exit_code, 0); } + #[cfg(unix)] #[test] fn inventory_mode_requires_explicit_valid_inventory_evidence() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2690,6 +2714,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn inventory_evidence_is_fresh_confined_and_fingerprint_bound() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2733,6 +2758,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn cargo_inventory_binds_without_cargo_nextest() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2770,6 +2796,7 @@ mod tests { /// Golden values produced by `homeboy-extensions/rust/scripts/test-shard-inventory.py`. /// Keep these byte-for-byte values aligned with the producer's v1 contract. + #[cfg(unix)] #[test] fn inventory_provenance_fingerprints_match_producer_golden_fixture() { let root = @@ -2788,6 +2815,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn workspace_fingerprint_matches_python_producer_for_universal_newlines() { let temp = tempfile::tempdir().expect("temp workspace"); @@ -2932,33 +2960,29 @@ print(hashlib.sha256(content.encode()).hexdigest()) ); } + #[cfg(unix)] fn test_inventory_binding_for_test(run_dir: &Path) -> TestInventoryBinding { - #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; let run_dir = run_dir.canonicalize().expect("canonical run dir"); - #[cfg(unix)] let descriptor = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC) .open(&run_dir) .expect("open run directory"); - #[cfg(unix)] let metadata = descriptor.metadata().expect("run directory metadata"); TestInventoryBinding { path: run_dir.join(TEST_INVENTORY_FILE), workspace_fingerprint: "b".repeat(64), cargo_runner_fingerprint: Some("a".repeat(64)), nextest_runner_fingerprint: Some("a".repeat(64)), - #[cfg(unix)] run_dir: descriptor, - #[cfg(unix)] run_dir_device: metadata.dev(), - #[cfg(unix)] run_dir_inode: metadata.ino(), } } + #[cfg(unix)] fn valid_inventory_document( binding: &TestInventoryBinding, name: &str, @@ -2975,6 +2999,7 @@ print(hashlib.sha256(content.encode()).hexdigest()) inventory_document(binding, vec![test]) } + #[cfg(unix)] fn inventory_document(binding: &TestInventoryBinding, tests: Vec) -> String { let mut inventory = TestInventoryEvidence { schema: TEST_INVENTORY_SCHEMA.to_string(), From e41c3baeb3da7f7ab6770b617b1554bca5be9ef4 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 09:32:34 -0400 Subject: [PATCH 16/17] fix(test): gate inventory producer path on Unix [AI: openai/gpt-5.6-terra via OpenCode] --- crates/homeboy-extension/src/test/run.rs | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index 985629a3ca..d00cff1479 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -26,7 +26,9 @@ pub use homeboy_extension_contract::test_results::{TestInventoryOutput, TestRunW pub use homeboy_extension_contract::test_workflow::RawTestOutput; use homeboy_refactor_contract::AppliedRefactor; use regex::Regex; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +#[cfg(unix)] +use serde::Serialize; use std::path::Path; use std::time::Duration; @@ -838,17 +840,19 @@ fn run_main_test_workflow_inner( .as_ref() .map(|context| context.extension_id.as_str()) .unwrap_or_default(), - ) - // The child receives a fixed run-dir output path; CI input cannot select it. - .env_if( - inventory_mode, - TEST_INVENTORY_FILE_ENV, - inventory_binding - .as_ref() - .map(|binding| binding.path.to_string_lossy()) - .as_deref() - .unwrap_or_default(), ); + // The child receives a fixed descriptor-bound output path; CI input cannot + // select it. Non-Unix inventory mode deliberately receives no producer path. + #[cfg(unix)] + let runner = runner.env_if( + inventory_mode, + TEST_INVENTORY_FILE_ENV, + inventory_binding + .as_ref() + .map(|binding| binding.path.to_string_lossy()) + .as_deref() + .unwrap_or_default(), + ); // In summary mode, capture the child's stdout/stderr into run evidence // instead of tee-ing the full compiler/test stream to the terminal. The // output is still persisted to artifacts below and a bounded failure tail From 61360f3202b17dd0e067ac79f7207e697fe367b3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 9 Aug 2026 15:51:36 -0400 Subject: [PATCH 17/17] fix(test): publish validated inventory safely [AI: openai/gpt-5.6-sol via OpenCode] --- crates/homeboy-extension/src/test/run.rs | 318 +++++++++++++++++++++-- 1 file changed, 293 insertions(+), 25 deletions(-) diff --git a/crates/homeboy-extension/src/test/run.rs b/crates/homeboy-extension/src/test/run.rs index d00cff1479..a803a3947c 100644 --- a/crates/homeboy-extension/src/test/run.rs +++ b/crates/homeboy-extension/src/test/run.rs @@ -33,7 +33,7 @@ use std::path::Path; use std::time::Duration; #[cfg(unix)] -use std::io::Read; +use std::io::{Read, Write}; #[cfg(unix)] use std::path::PathBuf; #[cfg(unix)] @@ -73,6 +73,8 @@ const TEST_INVENTORY_SCHEMA: &str = "homeboy/test-inventory/v1"; #[cfg(unix)] const TEST_INVENTORY_FILE: &str = "test-inventory.json"; #[cfg(unix)] +const TEST_INVENTORY_PUBLIC_FILE: &str = "homeboy-test-inventory.json"; +#[cfg(unix)] const MAX_TEST_INVENTORY_BYTES: u64 = 64 * 1024 * 1024; const DEFAULT_TEST_TIMEOUT_SECONDS: u64 = 25 * 60; @@ -128,20 +130,32 @@ fn test_inventory_mode(ci_env: &[(String, String)]) -> bool { #[cfg(unix)] #[derive(Debug)] struct TestInventoryBinding { - path: PathBuf, + child_path: PathBuf, workspace_fingerprint: String, cargo_runner_fingerprint: Option, nextest_runner_fingerprint: Option, + project_root: std::fs::File, run_dir: std::fs::File, run_dir_device: u64, run_dir_inode: u64, } #[cfg(unix)] -fn test_inventory_binding(source_path: &Path, run_dir: &RunDir) -> Option { +fn test_inventory_binding( + ci_env: &[(String, String)], + source_path: &Path, + run_dir: &RunDir, +) -> Option { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; - let path = run_dir.path().join(TEST_INVENTORY_FILE); + let child_path = run_dir.path().join(TEST_INVENTORY_FILE); + let workspace_root = cargo_workspace_root(source_path)?; + requested_test_inventory_path(ci_env, &workspace_root)?; + let project_root = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&workspace_root) + .ok()?; let run_dir = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC) @@ -151,9 +165,8 @@ fn test_inventory_binding(source_path: &Path, run_dir: &RunDir) -> Option Option Option<()> { + let requested = ci_env + .iter() + .find_map(|(key, value)| (key == TEST_INVENTORY_FILE_ENV).then_some(value))?; + let requested = Path::new(requested); + let requested = if requested.is_absolute() { + requested.to_path_buf() + } else { + workspace_root.join(requested) + }; + // The Action contract permits one output, immediately below the canonical + // Cargo project root. Lexical equality rejects aliases such as nested paths. + if requested != workspace_root.join(TEST_INVENTORY_PUBLIC_FILE) { + return None; + } + Some(()) +} + #[cfg(unix)] fn cargo_workspace_root(source_path: &Path) -> Option { let output = Command::new("cargo") @@ -301,7 +334,7 @@ fn prepare_test_inventory(binding: &TestInventoryBinding) -> bool { } #[cfg(unix)] -fn valid_test_inventory(binding: &TestInventoryBinding) -> Option { +fn valid_test_inventory(binding: &TestInventoryBinding) -> Option<(TestInventoryOutput, Vec)> { use std::os::fd::{AsRawFd, FromRawFd}; let file = unsafe { let fd = libc::openat( @@ -332,7 +365,103 @@ fn valid_test_inventory(binding: &TestInventoryBinding) -> Option(&bytes) else { return None; }; - valid_test_inventory_payload(&inventory, binding) + valid_test_inventory_payload(&inventory, binding).map(|inventory| (inventory, bytes)) +} + +#[cfg(unix)] +fn remove_published_test_inventory(binding: &TestInventoryBinding, file: &std::fs::File) { + use std::os::fd::AsRawFd; + use std::os::unix::fs::MetadataExt; + + let Ok(created) = file.metadata() else { + return; + }; + let mut entry = unsafe { std::mem::zeroed::() }; + let matched = unsafe { + libc::fstatat( + binding.project_root.as_raw_fd(), + c"homeboy-test-inventory.json".as_ptr(), + &mut entry, + libc::AT_SYMLINK_NOFOLLOW, + ) == 0 + && (entry.st_mode & libc::S_IFMT) == libc::S_IFREG + && entry.st_dev as u64 == created.dev() + && entry.st_ino as u64 == created.ino() + }; + if matched { + let _ = unsafe { + libc::unlinkat( + binding.project_root.as_raw_fd(), + c"homeboy-test-inventory.json".as_ptr(), + 0, + ) + }; + } +} + +#[cfg(unix)] +fn publish_test_inventory_with( + binding: &TestInventoryBinding, + write: W, + sync: S, + metadata: M, +) -> bool +where + W: FnOnce(&mut std::fs::File) -> std::io::Result<()>, + S: FnOnce(&std::fs::File) -> std::io::Result<()>, + M: FnOnce(&std::fs::File) -> std::io::Result, +{ + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::MetadataExt; + + let fd = unsafe { + libc::openat( + binding.project_root.as_raw_fd(), + c"homeboy-test-inventory.json".as_ptr(), + libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + return false; + } + let mut file = unsafe { std::fs::File::from_raw_fd(fd) }; + if write(&mut file).is_err() || sync(&file).is_err() { + remove_published_test_inventory(binding, &file); + return false; + } + let Ok(created) = metadata(&file) else { + remove_published_test_inventory(binding, &file); + return false; + }; + let mut published = unsafe { std::mem::zeroed::() }; + let verified = unsafe { + libc::fstatat( + binding.project_root.as_raw_fd(), + c"homeboy-test-inventory.json".as_ptr(), + &mut published, + libc::AT_SYMLINK_NOFOLLOW, + ) == 0 + && (published.st_mode & libc::S_IFMT) == libc::S_IFREG + && published.st_dev as u64 == created.dev() + && published.st_ino as u64 == created.ino() + }; + if !verified { + remove_published_test_inventory(binding, &file); + } + verified +} + +/// The parent publishes exactly the bytes it validated, never a reserialized +/// approximation of child evidence. +#[cfg(unix)] +fn publish_test_inventory(binding: &TestInventoryBinding, bytes: &[u8]) -> bool { + publish_test_inventory_with( + binding, + |file| file.write_all(bytes), + |file| file.sync_all(), + |file| file.metadata(), + ) } #[cfg(unix)] @@ -794,7 +923,7 @@ fn run_main_test_workflow_inner( .then(|| { test_context .as_ref() - .and_then(|_| test_inventory_binding(source_path, run_dir)) + .and_then(|_| test_inventory_binding(&args.ci_env, source_path, run_dir)) }) .flatten() .filter(prepare_test_inventory); @@ -849,7 +978,7 @@ fn run_main_test_workflow_inner( TEST_INVENTORY_FILE_ENV, inventory_binding .as_ref() - .map(|binding| binding.path.to_string_lossy()) + .map(|binding| binding.child_path.to_string_lossy()) .as_deref() .unwrap_or_default(), ); @@ -935,8 +1064,11 @@ fn run_main_test_workflow_inner( #[cfg(unix)] let test_inventory = inventory_binding.as_ref().and_then(|binding| { - valid_test_inventory(binding).filter(|inventory| { + valid_test_inventory(binding).and_then(|(inventory, bytes)| { revalidate_test_inventory_binding(binding, source_path, &inventory.runner) + .then(|| publish_test_inventory(binding, &bytes)) + .filter(|published| *published) + .map(|_| inventory) }) }); // Descriptor-bound inventory evidence is Unix-only. Other platforms retain @@ -2590,19 +2722,23 @@ mod tests { "requesting inventory mode without evidence must remain unmeasured" ); - std::fs::write(&binding.path, "not json").expect("write malformed inventory"); + std::fs::write(&binding.child_path, "not json").expect("write malformed inventory"); assert!( valid_test_inventory(&binding).is_none(), "a malformed inventory must remain fail-closed" ); + assert!( + !temp.path().join(TEST_INVENTORY_PUBLIC_FILE).exists(), + "malformed evidence must never publish a root inventory" + ); std::fs::write( - &binding.path, + &binding.child_path, valid_inventory_document(&binding, "test", "executed"), ) .expect("write inventory"); let measured = valid_test_inventory(&binding); - let evidence = measured.as_ref().expect("validated inventory"); + let (evidence, _) = measured.as_ref().expect("validated inventory"); assert_eq!(evidence.schema, TEST_INVENTORY_SCHEMA); assert_eq!(evidence.test_count, 1); assert_eq!( @@ -2675,7 +2811,7 @@ mod tests { valid_inventory_document(&binding, "test", "skipped"), ), ] { - std::fs::write(&binding.path, document).expect("write invalid inventory"); + std::fs::write(&binding.child_path, document).expect("write invalid inventory"); assert!( valid_test_inventory(&binding).is_none(), "{case} must remain fail-closed" @@ -2690,7 +2826,7 @@ mod tests { &canonical_inventory_json(&duplicate), ); std::fs::write( - &binding.path, + &binding.child_path, serde_json::to_vec(&duplicate).expect("serialize duplicate inventory"), ) .expect("write duplicate inventory"); @@ -2708,7 +2844,7 @@ mod tests { expected_outcome: None, }; std::fs::write( - &binding.path, + &binding.child_path, inventory_document(&binding, vec![missing_outcome]), ) .expect("write missing outcome inventory"); @@ -2725,24 +2861,24 @@ mod tests { let binding = test_inventory_binding_for_test(temp.path()); std::fs::write( - &binding.path, + &binding.child_path, valid_inventory_document(&binding, "test", "executed"), ) .expect("write stale inventory"); assert!(prepare_test_inventory(&binding)); assert!( - !binding.path.exists(), + !binding.child_path.exists(), "pre-existing evidence must not satisfy a new invocation" ); std::fs::write( - &binding.path, + &binding.child_path, valid_inventory_document(&binding, "test", "executed"), ) .expect("write inventory"); assert!(valid_test_inventory(&binding).is_some()); std::fs::write( - &binding.path, + &binding.child_path, valid_inventory_document(&binding, "tést", "executed"), ) .expect("write unicode inventory"); @@ -2755,7 +2891,7 @@ mod tests { "suite::other", 1, ); - std::fs::write(&binding.path, tampered).expect("write tampered inventory"); + std::fs::write(&binding.child_path, tampered).expect("write tampered inventory"); assert!( valid_test_inventory(&binding).is_none(), "the inventory fingerprint must bind the listed tests" @@ -2788,7 +2924,7 @@ mod tests { &canonical_inventory_json(&inventory), ); std::fs::write( - &binding.path, + &binding.child_path, serde_json::to_vec(&inventory).expect("serialize cargo inventory"), ) .expect("write cargo inventory"); @@ -2923,7 +3059,7 @@ print(hashlib.sha256(content.encode()).hexdigest()) valid_inventory_document(&binding, "test", "executed"), ) .expect("write target"); - symlink(&target, &binding.path).expect("link inventory"); + symlink(&target, &binding.child_path).expect("link inventory"); assert!(valid_test_inventory(&binding).is_none()); assert!(prepare_test_inventory(&binding)); @@ -2964,11 +3100,142 @@ print(hashlib.sha256(content.encode()).hexdigest()) ); } + #[cfg(unix)] + #[test] + fn inventory_publication_requires_the_fixed_root_output_and_preserves_collisions() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().expect("workspace"); + let run = tempfile::tempdir().expect("run directory"); + let binding = test_inventory_binding_for_test_in(workspace.path(), run.path()); + let output = workspace.path().join(TEST_INVENTORY_PUBLIC_FILE); + let valid = valid_inventory_document(&binding, "published", "executed").into_bytes(); + + assert!(requested_test_inventory_path( + &[( + TEST_INVENTORY_FILE_ENV.to_string(), + output.to_string_lossy().into_owned() + )], + workspace.path() + ) + .is_some()); + for rejected in [ + tempfile::tempdir() + .expect("outside") + .path() + .join(TEST_INVENTORY_PUBLIC_FILE), + workspace + .path() + .join("nested") + .join(TEST_INVENTORY_PUBLIC_FILE), + ] { + assert!(requested_test_inventory_path( + &[( + TEST_INVENTORY_FILE_ENV.to_string(), + rejected.to_string_lossy().into_owned() + )], + workspace.path() + ) + .is_none()); + } + + std::fs::write(&output, "existing regular entry").expect("create collision"); + assert!(!publish_test_inventory(&binding, &valid)); + assert_eq!( + std::fs::read(&output).expect("read collision"), + b"existing regular entry" + ); + std::fs::remove_file(&output).expect("remove test collision"); + + let target = tempfile::NamedTempFile::new().expect("symlink target"); + symlink(target.path(), &output).expect("create collision symlink"); + assert!(!publish_test_inventory(&binding, &valid)); + assert!(std::fs::symlink_metadata(&output) + .expect("inspect symlink") + .file_type() + .is_symlink()); + std::fs::remove_file(&output).expect("remove test symlink"); + + assert!(publish_test_inventory(&binding, &valid)); + assert_eq!( + std::fs::read(&output).expect("read published output"), + valid + ); + } + + #[cfg(unix)] + #[test] + fn inventory_publication_uses_held_project_root_and_cleans_only_its_entry_on_failure() { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir().expect("parent"); + let workspace = parent.path().join("workspace"); + std::fs::create_dir(&workspace).expect("workspace"); + let run = tempfile::tempdir().expect("run directory"); + let binding = test_inventory_binding_for_test_in(&workspace, run.path()); + let moved = parent.path().join("workspace-moved"); + let outside = tempfile::tempdir().expect("outside"); + std::fs::rename(&workspace, &moved).expect("rename project root"); + symlink(outside.path(), &workspace).expect("replace project root"); + let bytes = valid_inventory_document(&binding, "held", "executed").into_bytes(); + assert!(publish_test_inventory(&binding, &bytes)); + assert_eq!( + std::fs::read(moved.join(TEST_INVENTORY_PUBLIC_FILE)).expect("read held output"), + bytes + ); + assert!(!outside.path().join(TEST_INVENTORY_PUBLIC_FILE).exists()); + + let cleanup_root = tempfile::tempdir().expect("cleanup root"); + let cleanup_run = tempfile::tempdir().expect("cleanup run"); + for failure in ["write", "sync", "fstat"] { + let cleanup = + test_inventory_binding_for_test_in(cleanup_root.path(), cleanup_run.path()); + let failed = match failure { + "write" => publish_test_inventory_with( + &cleanup, + |_| Err(std::io::Error::other("simulated write failure")), + |_| Ok(()), + |file| file.metadata(), + ), + "sync" => publish_test_inventory_with( + &cleanup, + |_| Ok(()), + |_| Err(std::io::Error::other("simulated sync failure")), + |file| file.metadata(), + ), + "fstat" => publish_test_inventory_with( + &cleanup, + |_| Ok(()), + |_| Ok(()), + |_| Err(std::io::Error::other("simulated fstat failure")), + ), + _ => unreachable!(), + }; + assert!(!failed, "{failure} failure must reject publication"); + assert!( + !cleanup_root + .path() + .join(TEST_INVENTORY_PUBLIC_FILE) + .exists(), + "{failure} failure must remove only the file this parent created" + ); + } + } + #[cfg(unix)] fn test_inventory_binding_for_test(run_dir: &Path) -> TestInventoryBinding { + test_inventory_binding_for_test_in(run_dir, run_dir) + } + + #[cfg(unix)] + fn test_inventory_binding_for_test_in( + project_root: &Path, + run_dir: &Path, + ) -> TestInventoryBinding { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; let run_dir = run_dir.canonicalize().expect("canonical run dir"); + let project_root = project_root.canonicalize().expect("canonical project root"); let descriptor = std::fs::OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_CLOEXEC) @@ -2976,10 +3243,11 @@ print(hashlib.sha256(content.encode()).hexdigest()) .expect("open run directory"); let metadata = descriptor.metadata().expect("run directory metadata"); TestInventoryBinding { - path: run_dir.join(TEST_INVENTORY_FILE), + child_path: run_dir.join(TEST_INVENTORY_FILE), workspace_fingerprint: "b".repeat(64), cargo_runner_fingerprint: Some("a".repeat(64)), nextest_runner_fingerprint: Some("a".repeat(64)), + project_root: std::fs::File::open(&project_root).expect("open project root"), run_dir: descriptor, run_dir_device: metadata.dev(), run_dir_inode: metadata.ino(),