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.