use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq, Eq)]
struct FuzzTarget {
name: String,
path: String,
}
#[test]
fn security_fuzz_workflow_targets_match_fuzz_manifest() {
let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let fuzz_root = crate_root.join("fuzz");
let manifest_path = fuzz_root.join("Cargo.toml");
let workflow_path = repo_root(crate_root)
.join(".github")
.join("workflows")
.join("security-fuzz.yml");
let manifest = read_to_string(&manifest_path);
let workflow = read_to_string(&workflow_path);
let manifest_targets = fuzz_manifest_targets(&manifest);
assert!(
!manifest_targets.is_empty(),
"{} must declare cargo-fuzz bin targets",
manifest_path.display()
);
let manifest_target_names = target_name_set(&manifest_targets);
let workflow_target_names = string_set(&workflow_fuzz_targets(&workflow), "workflow targets");
assert_eq!(
workflow_target_names,
manifest_target_names,
"security-fuzz workflow target list drifted from fuzz/Cargo.toml\n\
missing from workflow: {:?}\n\
extra in workflow: {:?}",
manifest_target_names
.difference(&workflow_target_names)
.collect::<Vec<_>>(),
workflow_target_names
.difference(&manifest_target_names)
.collect::<Vec<_>>()
);
let manifest_target_paths = manifest_targets
.iter()
.map(|target| normalize_path(Path::new(&target.path)))
.collect::<Vec<_>>();
let _ = string_set(&manifest_target_paths, "manifest target paths");
let missing_target_files = manifest_target_paths
.iter()
.filter(|path| !fuzz_root.join(path).is_file())
.collect::<Vec<_>>();
assert!(
missing_target_files.is_empty(),
"fuzz/Cargo.toml bin target paths must exist: {missing_target_files:#?}"
);
let mismatched_stems = manifest_targets
.iter()
.filter_map(|target| {
let stem = Path::new(&target.path)
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("");
(stem != target.name).then(|| format!("{} -> {}", target.name, target.path))
})
.collect::<Vec<_>>();
assert!(
mismatched_stems.is_empty(),
"cargo-fuzz bin target names must match their file stems: {mismatched_stems:#?}"
);
}
#[test]
fn security_fuzz_workflow_has_required_ci_controls() {
let workflow_path = repo_root(Path::new(env!("CARGO_MANIFEST_DIR")))
.join(".github")
.join("workflows")
.join("security-fuzz.yml");
let workflow = read_to_string(&workflow_path);
assert_yaml_key(&workflow, "pull_request");
assert_yaml_key(&workflow, "schedule");
assert_yaml_key(&workflow, "workflow_dispatch");
assert_contains(
&workflow,
"strategy:",
"workflow must run fuzz targets as independent matrix jobs",
);
assert_contains(
&workflow,
"fail-fast: false",
"workflow matrix must keep collecting evidence after one target fails",
);
assert_contains(
&workflow,
"name: cargo-fuzz (${{ matrix.target }})",
"workflow job name must identify the matrix fuzz target",
);
assert_contains(
&workflow,
"path: crates/running-process/fuzz/corpus/${{ matrix.target }}",
"workflow must cache the fuzz corpus path per target",
);
assert_contains(
&workflow,
"uses: actions/upload-artifact@v4",
"workflow must upload artifacts on fuzz failures",
);
assert_contains(
&workflow,
"path: crates/running-process/fuzz/artifacts/${{ matrix.target }}",
"workflow must upload cargo-fuzz artifacts per target",
);
assert_contains(
&workflow,
r#"if [[ "${{ github.event_name }}" == "pull_request" ]]; then"#,
"workflow must distinguish pull_request runs from longer runs",
);
assert_contains(
&workflow,
r#"fuzz_seconds="${FUZZ_SECONDS:-30}""#,
"pull_request fuzz runs must default to 30 seconds per target",
);
assert_contains(
&workflow,
r#"fuzz_seconds="${fuzz_seconds:-3600}""#,
"workflow_dispatch fuzz runs must default to one hour per target",
);
assert_contains(
&workflow,
r#"fuzz_seconds="${FUZZ_SECONDS:-1800}""#,
"scheduled fuzz runs must default to 1800 seconds per target",
);
assert_contains(
&workflow,
r#"release_evidence=true"#,
"release dispatch runs must mark evidence uploads when fuzz_seconds is at least 3600",
);
assert_contains(
&workflow,
"name: release-fuzz-evidence-${{ matrix.target }}",
"successful release fuzz runs must upload per-target evidence artifacts",
);
let fuzz_run = workflow
.lines()
.find(|line| line.contains("cargo +nightly fuzz run"))
.unwrap_or_else(|| panic!("workflow must run cargo +nightly fuzz run"));
assert!(
fuzz_run.contains(r#"-max_total_time="${FUZZ_SECONDS}""#),
"cargo-fuzz run must use -max_total_time from FUZZ_SECONDS: {fuzz_run}"
);
assert!(
fuzz_run.contains(r#""${{ matrix.target }}""#),
"cargo-fuzz run must execute exactly one matrix target per job: {fuzz_run}"
);
assert!(
fuzz_run.contains("-timeout=30"),
"cargo-fuzz run must keep per-input timeout at 30 seconds: {fuzz_run}"
);
}
fn repo_root(crate_root: &Path) -> PathBuf {
crate_root
.parent()
.and_then(|path| path.parent())
.unwrap_or_else(|| panic!("failed to derive repo root from {}", crate_root.display()))
.to_path_buf()
}
fn read_to_string(path: &Path) -> String {
std::fs::read_to_string(path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()))
}
fn fuzz_manifest_targets(manifest: &str) -> Vec<FuzzTarget> {
let mut targets = Vec::new();
let mut current = None;
for line in manifest.lines() {
let trimmed = line.trim();
if trimmed == "[[bin]]" {
push_manifest_target(&mut targets, current.take());
current = Some(PartialFuzzTarget::default());
continue;
}
if trimmed.starts_with('[') {
push_manifest_target(&mut targets, current.take());
continue;
}
let Some(target) = &mut current else {
continue;
};
if let Some(value) = toml_string_assignment(trimmed, "name") {
target.name = Some(value);
} else if let Some(value) = toml_string_assignment(trimmed, "path") {
target.path = Some(value);
}
}
push_manifest_target(&mut targets, current);
targets
}
#[derive(Default)]
struct PartialFuzzTarget {
name: Option<String>,
path: Option<String>,
}
fn push_manifest_target(targets: &mut Vec<FuzzTarget>, target: Option<PartialFuzzTarget>) {
let Some(target) = target else {
return;
};
let name = target
.name
.unwrap_or_else(|| panic!("fuzz/Cargo.toml [[bin]] entry is missing name"));
let path = target
.path
.unwrap_or_else(|| panic!("fuzz/Cargo.toml [[bin]] entry {name} is missing path"));
targets.push(FuzzTarget { name, path });
}
fn toml_string_assignment(line: &str, key: &str) -> Option<String> {
let (lhs, rhs) = line.split_once('=')?;
if lhs.trim() != key {
return None;
}
let value = rhs.trim().strip_prefix('"')?;
let end = value
.find('"')
.unwrap_or_else(|| panic!("unterminated TOML string assignment for {key}"));
Some(value[..end].to_owned())
}
fn workflow_fuzz_targets(workflow: &str) -> Vec<String> {
let mut targets = Vec::new();
let mut in_target_list = false;
let mut target_indent = 0;
for line in workflow.lines() {
let trimmed = line.trim();
if !in_target_list {
if trimmed != "target:" {
continue;
}
in_target_list = true;
target_indent = indent_len(line);
continue;
}
if trimmed.is_empty() {
continue;
}
let indent = indent_len(line);
if indent <= target_indent && !trimmed.starts_with("- ") {
break;
}
let Some(target) = trimmed.strip_prefix("- ") else {
continue;
};
let target = target.trim_matches(|ch| ch == '\'' || ch == '"');
if !target.is_empty() {
targets.push(target.to_owned());
}
}
if targets.is_empty() {
panic!("security-fuzz workflow must declare a matrix.target list");
}
targets
}
fn indent_len(line: &str) -> usize {
line.chars().take_while(|ch| *ch == ' ').count()
}
fn target_name_set(targets: &[FuzzTarget]) -> BTreeSet<String> {
let names = targets
.iter()
.map(|target| target.name.clone())
.collect::<Vec<_>>();
string_set(&names, "manifest target names")
}
fn string_set(values: &[String], label: &str) -> BTreeSet<String> {
let mut set = BTreeSet::new();
for value in values {
assert!(
set.insert(value.clone()),
"{label} must not contain duplicate fuzz target entry {value}"
);
}
set
}
fn normalize_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn assert_yaml_key(workflow: &str, key: &str) {
assert!(
workflow
.lines()
.any(|line| line.trim() == format!("{key}:")),
"security-fuzz workflow must declare {key}"
);
}
fn assert_contains(haystack: &str, needle: &str, message: &str) {
assert!(haystack.contains(needle), "{message}: missing {needle}");
}
#[cfg(test)]
mod tests {
use super::{fuzz_manifest_targets, workflow_fuzz_targets, FuzzTarget};
#[test]
fn security_fuzz_workflow_parser_reads_manifest_bin_targets() {
let targets = fuzz_manifest_targets(
r#"
[package]
name = "running-process-fuzz"
[[bin]]
name = "fuzz_one"
path = "fuzz_targets/fuzz_one.rs"
test = false
[[bin]]
name = "fuzz_two"
path = "fuzz_targets/fuzz_two.rs"
"#,
);
assert_eq!(
targets,
[
FuzzTarget {
name: "fuzz_one".to_string(),
path: "fuzz_targets/fuzz_one.rs".to_string()
},
FuzzTarget {
name: "fuzz_two".to_string(),
path: "fuzz_targets/fuzz_two.rs".to_string()
}
]
);
}
#[test]
fn security_fuzz_workflow_parser_reads_matrix_target_list() {
let targets = workflow_fuzz_targets(
r#"
strategy:
matrix:
target:
- fuzz_one
- "fuzz_two"
"#,
);
assert_eq!(targets, ["fuzz_one", "fuzz_two"]);
}
}