From e1c764ce08671cef6684628827731ac494020b89 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:35:40 +0000 Subject: [PATCH] fix(check): fail closed when Rust analysis sees no source files When Cargo could not run (for example, cargo missing from PATH) or the workspace had no packages, Rust analysis produced no file facts. Custom Rust rules then evaluated nothing, and fensu check printed 'Found 0 faults' and exited 0 unless the RSL901 setup diagnostic happened to be selected. fensu check now exits 2 with the Cargo cause when a Rust target has .rs sources but analysis returned no files. A regression test covers missing cargo, a workspace with no packages, and a custom-rule host reply over the 16 MiB capture limit, which already failed closed. --- .../src/check/_helpers/evaluation.rs | 17 ++++ .../tests/command/test_check_rust.rs | 81 ++++++++++++++++++- crates/fensu-cli/tests/command/test_types.rs | 8 ++ docs/rust.md | 5 +- .../docs/docs/concepts/analyzers/rust.mdx | 3 + 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/crates/fensu-cli/src/check/_helpers/evaluation.rs b/crates/fensu-cli/src/check/_helpers/evaluation.rs index f56bce98..42bf0e7d 100644 --- a/crates/fensu-cli/src/check/_helpers/evaluation.rs +++ b/crates/fensu-cli/src/check/_helpers/evaluation.rs @@ -378,6 +378,23 @@ fn evaluate_rust_target(request: EvaluationRequest<'_>) -> Result>(); + let rust_source_count = source_paths + .iter() + .filter(|path| path.ends_with(".rs")) + .count(); + if rust_source_count > 0 && analysis.facts.files.is_empty() { + let cause = analysis + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == fensu_rust::METADATA_SETUP_CODE) + .map_or_else( + || "Cargo reported no workspace source files".to_owned(), + |diagnostic| diagnostic.message.clone(), + ); + return Err(format!( + "Rust analysis found 0 Rust files, but the target has {rust_source_count}; no Rust rule can be evaluated. {cause}" + )); + } let mut faults: Vec = Vec::new(); for diagnostic in analysis.diagnostics { let diagnostic_path = diagnostic.path.to_string_lossy().replace('\\', "/"); diff --git a/crates/fensu-cli/tests/command/test_check_rust.rs b/crates/fensu-cli/tests/command/test_check_rust.rs index f9695626..03cfc12c 100644 --- a/crates/fensu-cli/tests/command/test_check_rust.rs +++ b/crates/fensu-cli/tests/command/test_check_rust.rs @@ -1,8 +1,8 @@ use crate::helpers::{require_workspace_python, run_check, run_check_with, write}; use crate::test_types::{ InvalidCheckConfigTestCase, RustCacheTestCase, RustCheckTestCase, - RustCustomCacheInvalidationTestCase, RustCustomCoverageTestCase, RustCustomFileRuleTestCase, - RustCustomHostRoutingTestCase, RustCustomMalformedSourceTestCase, + RustCustomCacheInvalidationTestCase, RustCustomCoverageTestCase, RustCustomFailClosedTestCase, + RustCustomFileRuleTestCase, RustCustomHostRoutingTestCase, RustCustomMalformedSourceTestCase, RustCustomNarrowCacheTestCase, RustCustomPolicyTestCase, RustMetadataCacheTestCase, }; @@ -919,6 +919,83 @@ fn given_malformed_rust_when_custom_rule_reads_file_facts_then_parse_error_remai } } +#[test] +fn given_unavailable_rust_facts_when_custom_rule_is_selected_then_check_fails_closed() { + let python = require_workspace_python!(); + let package = "[package]\nname = \"example\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"; + let test_cases = [ + RustCustomFailClosedTestCase { + description: "cargo cannot run", + manifest: package, + rule_suffix: "", + search_path: |empty| empty.as_os_str().to_owned(), + expected_error: "failed to start `cargo metadata`", + }, + RustCustomFailClosedTestCase { + description: "workspace has Rust sources but no packages", + manifest: "[workspace]\nmembers = []\nresolver = \"2\"\n", + rule_suffix: "", + search_path: |_| std::env::var_os("PATH").unwrap_or_default(), + expected_error: "Rust analysis found 0 Rust files, but the target has 1", + }, + RustCustomFailClosedTestCase { + description: "host reply exceeds the capture limit", + manifest: package, + rule_suffix: "import sys\nsys.stdout.write('x' * (17 * 1024 * 1024))\n", + search_path: |_| std::env::var_os("PATH").unwrap_or_default(), + expected_error: "stdout exceeded its 16777216-byte capture limit", + }, + ]; + for test_case in test_cases { + let repository = tempfile::tempdir().expect("temporary repository"); + let empty_path = tempfile::tempdir().expect("empty search path"); + write(repository.path().join("Cargo.toml"), test_case.manifest); + write( + repository.path().join("src/lib.rs"), + "pub struct LegacyThing;\n", + ); + write( + repository.path().join("rules/custom.py"), + &format!( + "from fensu import AnalyzerId, Family, Fault, File, RuleContext, rule\n@rule(code='XRS006', family=Family.CUSTOM, slug='any-file', message='file seen', analyzers=(AnalyzerId.RUST,), cacheable=True)\ndef any_file(*, file: File, ctx: RuleContext) -> list[Fault]:\n return [ctx.path_fault()] if ctx.rust.file(file) is not None else []\n{}", + test_case.rule_suffix + ), + ); + write( + repository.path().join("fensu.toml"), + "[targets.rust]\nanalyzer = \"rust\"\nroots = [\"src\"]\ntests = []\ntooling = []\nrule_packs = [\"rust\"]\nrule_paths = [\"rules/custom.py\"]\nselect = [\"XRS006\"]\n", + ); + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_fensu")) + .args(["check", "--no-color", "--no-cache"]) + .current_dir(repository.path()) + .env("FENSU_PYTHON", &python) + .env("PATH", (test_case.search_path)(empty_path.path())) + .env_remove("CARGO") + .output() + .expect("custom Rust check runs"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!( + output.status.code(), + Some(2), + "{}: {stdout} {stderr}", + test_case.description + ); + assert!( + stderr.contains(test_case.expected_error), + "{}: {stderr}", + test_case.description + ); + assert!( + !stdout.contains("Found 0 faults"), + "{}: {stdout}", + test_case.description + ); + } +} + #[test] fn given_custom_rust_policy_when_checking_then_warning_ignore_and_exception_semantics_are_shared() { let python = require_workspace_python!(); diff --git a/crates/fensu-cli/tests/command/test_types.rs b/crates/fensu-cli/tests/command/test_types.rs index c5c248f8..64468f4f 100644 --- a/crates/fensu-cli/tests/command/test_types.rs +++ b/crates/fensu-cli/tests/command/test_types.rs @@ -134,6 +134,14 @@ pub(crate) struct RustCustomNarrowCacheTestCase { pub(crate) expected_reused_message: &'static str, } +pub(crate) struct RustCustomFailClosedTestCase { + pub(crate) description: &'static str, + pub(crate) manifest: &'static str, + pub(crate) rule_suffix: &'static str, + pub(crate) search_path: fn(&std::path::Path) -> std::ffi::OsString, + pub(crate) expected_error: &'static str, +} + pub(crate) struct RustCustomMalformedSourceTestCase { pub(crate) description: &'static str, pub(crate) expected_exit_code: i32, diff --git a/docs/rust.md b/docs/rust.md index 5756a764..d27535a0 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -199,7 +199,10 @@ by family. Rust sources, Cargo manifests, existing lockfiles, and target settings are cache inputs. Existing lockfiles are read with locked metadata resolution. Lock-free workspaces use declared dependency metadata without creating a lockfile. A Cargo metadata setup failure produces a -diagnostic and prevents caching, even if that diagnostic is excluded by rule selection. +diagnostic and prevents caching, even if that diagnostic is excluded by rule selection. When the +target has Rust sources but Cargo yields no source files, for example because `cargo` is not on +`PATH` or the workspace has no packages, `fensu check` exits 2 with the cause instead of +reporting a result. ## File placement and embedded resources The `rust` pack enables `FPRSR311` (`rust-rsr311`) and `FPRSL106` (`rust-rsl106`) diff --git a/website/src/content/docs/docs/concepts/analyzers/rust.mdx b/website/src/content/docs/docs/concepts/analyzers/rust.mdx index 622a5c58..ebbffca3 100644 --- a/website/src/content/docs/docs/concepts/analyzers/rust.mdx +++ b/website/src/content/docs/docs/concepts/analyzers/rust.mdx @@ -235,3 +235,6 @@ lockfile. If Cargo metadata cannot be read, Fensu reports a setup diagnostic and does not cache the result. The result remains uncached even when rule selection hides that diagnostic. +When the target has Rust sources but Cargo yields no source files, for example because +`cargo` is not on `PATH` or the workspace has no packages, `fensu check` exits 2 with +the cause instead of reporting a result.