Skip to content

Commit 5663ce2

Browse files
authored
ci(fuzz): discover fuzz targets dynamically (#1290)
Mirror the `check features` pattern: expose `cargo xtask fuzz list` (with `--format github-matrix`) that enumerates fuzz targets by scanning `fuzz/fuzz_targets/*.rs`, and have the fuzz workflow build its matrix from that output. New targets dropped into the directory are picked up by CI automatically, with no workflow edits required.
1 parent a8a813a commit 5663ce2

6 files changed

Lines changed: 141 additions & 37 deletions

File tree

.github/workflows/fuzz.yml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,33 @@ jobs:
3434
./fuzz/artifacts
3535
key: fuzz-corpus-${{ github.run_id }}
3636

37+
fuzz-setup:
38+
name: Fuzz matrix setup
39+
needs: [corpus-download]
40+
runs-on: ubuntu-latest
41+
outputs:
42+
fuzz-matrix: ${{ steps.setup-matrix.outputs.fuzz-matrix }}
43+
44+
steps:
45+
- uses: actions/checkout@v6
46+
47+
- name: Rust cache
48+
uses: Swatinem/rust-cache@v2.7.3
49+
50+
- name: Setup matrix
51+
id: setup-matrix
52+
run: |
53+
MATRIX="$(cargo xtask fuzz list --format github-matrix)"
54+
echo "fuzz-matrix=$MATRIX" >> "$GITHUB_OUTPUT"
55+
3756
fuzz:
3857
name: Fuzzing ${{ matrix.target }}
39-
needs: [corpus-download]
58+
needs: [corpus-download, fuzz-setup]
4059
runs-on: ubuntu-latest
4160
strategy:
4261
fail-fast: false
4362
matrix:
44-
target: [pdu_decoding, rle_decompression, bitmap_stream, cliprdr_format, cliprdr_channel_processing, channel_processing]
63+
include: ${{ fromJson(needs.fuzz-setup.outputs.fuzz-matrix) }}
4564

4665
steps:
4766
- uses: actions/checkout@v6

xtask/src/cli.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ TASKS:
3434
Minify fuzzing corpus for a specific target (or all if unspecified)
3535
fuzz corpus-push Push fuzzing corpus to Azure storage
3636
fuzz install Install dependencies required for fuzzing
37+
fuzz list [--format <FMT>]
38+
List fuzz targets (fmt: human (default) | github-matrix)
3739
fuzz run [--duration <SECONDS>] [--target <NAME>]
3840
Fuzz a specific target if any or all targets for a limited duration (default is 5s)
3941
wasm check Ensure WASM module is compatible for the web
@@ -57,6 +59,27 @@ pub struct Args {
5759
pub action: Action,
5860
}
5961

62+
pub enum ListFormat {
63+
Human,
64+
GithubMatrix,
65+
}
66+
67+
impl ListFormat {
68+
pub const DEFAULT: Self = Self::Human;
69+
}
70+
71+
impl core::str::FromStr for ListFormat {
72+
type Err = anyhow::Error;
73+
74+
fn from_str(value: &str) -> anyhow::Result<Self> {
75+
match value {
76+
"human" => Ok(Self::Human),
77+
"github-matrix" => Ok(Self::GithubMatrix),
78+
other => anyhow::bail!("unknown --format value: {other}"),
79+
}
80+
}
81+
}
82+
6083
pub enum Action {
6184
ShowHelp,
6285
Bootstrap,
@@ -70,7 +93,7 @@ pub enum Action {
7093
CheckFeatures {
7194
case: Option<String>,
7295
list: bool,
73-
format: Option<String>,
96+
format: ListFormat,
7497
},
7598
CheckInstall,
7699
Ci,
@@ -91,6 +114,9 @@ pub enum Action {
91114
},
92115
FuzzCorpusPush,
93116
FuzzInstall,
117+
FuzzList {
118+
format: ListFormat,
119+
},
94120
FuzzRun {
95121
duration: Option<u32>,
96122
target: Option<String>,
@@ -129,7 +155,7 @@ pub fn parse_args() -> anyhow::Result<Args> {
129155
Some("features") => Action::CheckFeatures {
130156
case: args.opt_value_from_str("--case")?,
131157
list: args.contains("--list"),
132-
format: args.opt_value_from_str("--format")?,
158+
format: args.opt_value_from_str("--format")?.unwrap_or(ListFormat::DEFAULT),
133159
},
134160
Some("install") => Action::CheckInstall,
135161
Some(unknown) => anyhow::bail!("unknown check action: {unknown}"),
@@ -157,6 +183,9 @@ pub fn parse_args() -> anyhow::Result<Args> {
157183
},
158184
Some("corpus-push") => Action::FuzzCorpusPush,
159185
Some("install") => Action::FuzzInstall,
186+
Some("list") => Action::FuzzList {
187+
format: args.opt_value_from_str("--format")?.unwrap_or(ListFormat::DEFAULT),
188+
},
160189
Some("run") => Action::FuzzRun {
161190
duration: args.opt_value_from_str("--duration")?,
162191
target: args.opt_value_from_str("--target")?,

xtask/src/cov.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ pub fn grcov(sh: &Shell) -> anyhow::Result<()> {
184184

185185
cmd!(sh, "{CARGO} clean").run()?;
186186

187-
for target in FUZZ_TARGETS {
187+
for target in crate::fuzz::discover_targets()? {
188188
cmd!(sh, "rustup run {NIGHTLY_TOOLCHAIN} cargo fuzz coverage {target}").run()?;
189189
}
190190

xtask/src/fuzz.rs

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
1+
use std::collections::HashMap;
2+
3+
use tinyjson::JsonValue;
4+
15
use crate::prelude::*;
26
// NOTE: cargo-fuzz (libFuzzer) does not support Windows yet (coming soon?)
37

8+
/// Enumerate fuzz targets by scanning `fuzz/fuzz_targets/*.rs`.
9+
///
10+
/// The fuzz targets directory is the single source of truth: each `.rs` file
11+
/// there is a libFuzzer binary registered in `fuzz/Cargo.toml`. Discovering
12+
/// them dynamically means the CI matrix picks up new targets automatically.
13+
pub fn discover_targets() -> anyhow::Result<Vec<String>> {
14+
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
15+
.ancestors()
16+
.nth(1)
17+
.context("retrieve project root path")?
18+
.join("fuzz")
19+
.join("fuzz_targets");
20+
21+
let mut targets: Vec<String> = std::fs::read_dir(&dir)
22+
.with_context(|| format!("read fuzz targets directory: {}", dir.display()))?
23+
.map(|entry| {
24+
let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?;
25+
Ok(entry.path())
26+
})
27+
.collect::<anyhow::Result<Vec<_>>>()?
28+
.into_iter()
29+
.filter_map(|path| {
30+
if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
31+
return None;
32+
}
33+
path.file_stem().and_then(|stem| stem.to_str()).map(str::to_owned)
34+
})
35+
.collect();
36+
37+
targets.sort();
38+
Ok(targets)
39+
}
40+
441
pub fn corpus_minify(sh: &Shell, target: Option<String>) -> anyhow::Result<()> {
542
let _s = Section::new("FUZZ-CORPUS-MINIFY");
643
windows_skip!();
744

845
let _guard = sh.push_dir("./fuzz");
946

10-
let target_from_user = target.as_deref().map(|value| [value]);
11-
12-
let targets = if let Some(targets) = &target_from_user {
13-
targets
14-
} else {
15-
FUZZ_TARGETS
47+
let targets = match target {
48+
Some(value) => vec![value],
49+
None => discover_targets()?,
1650
};
1751

18-
for target in targets {
52+
for target in &targets {
1953
cmd!(sh, "rustup run {NIGHTLY_TOOLCHAIN} cargo fuzz cmin {target}").run()?;
2054
}
2155

@@ -72,15 +106,12 @@ pub fn run(sh: &Shell, duration: Option<u32>, target: Option<String>) -> anyhow:
72106
let _guard = sh.push_dir("./fuzz");
73107

74108
let duration = duration.unwrap_or(5).to_string();
75-
let target_from_user = target.as_deref().map(|value| [value]);
76-
77-
let targets = if let Some(targets) = &target_from_user {
78-
targets
79-
} else {
80-
FUZZ_TARGETS
109+
let targets = match target {
110+
Some(value) => vec![value],
111+
None => discover_targets()?,
81112
};
82113

83-
for target in targets {
114+
for target in &targets {
84115
cmd!(
85116
sh,
86117
"rustup run {NIGHTLY_TOOLCHAIN} cargo fuzz run {target} -- -max_total_time={duration} -timeout=10"
@@ -92,3 +123,38 @@ pub fn run(sh: &Shell, duration: Option<u32>, target: Option<String>) -> anyhow:
92123

93124
Ok(())
94125
}
126+
127+
/// Print each fuzz target, one per line. Useful for local discovery.
128+
pub fn list_human() -> anyhow::Result<()> {
129+
for target in discover_targets()? {
130+
println!("{target}");
131+
}
132+
Ok(())
133+
}
134+
135+
/// Emit a `matrix.include`-compatible JSON array on stdout, one entry per
136+
/// discovered fuzz target. Suitable for piping into a GitHub Actions matrix:
137+
///
138+
/// ```yaml
139+
/// - id: setup
140+
/// run: echo "fuzz-matrix=$(cargo xtask fuzz list --format github-matrix)" >> "$GITHUB_OUTPUT"
141+
/// ```
142+
///
143+
/// Each entry has the shape `{ "target": "<name>" }`.
144+
pub fn list_github_matrix() -> anyhow::Result<()> {
145+
let items: Vec<JsonValue> = discover_targets()?
146+
.into_iter()
147+
.map(|name| {
148+
let mut obj = HashMap::new();
149+
obj.insert("target".to_owned(), JsonValue::String(name));
150+
JsonValue::Object(obj)
151+
})
152+
.collect();
153+
154+
let json = JsonValue::Array(items);
155+
let stringified = json
156+
.stringify()
157+
.context("serialize fuzz matrix include array as JSON")?;
158+
println!("{stringified}");
159+
Ok(())
160+
}

xtask/src/main.rs

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,6 @@ pub const CARGO: &str = env!("CARGO");
3434

3535
pub const WASM_PACKAGES: &[&str] = &["ironrdp-web"];
3636

37-
pub const FUZZ_TARGETS: &[&str] = &[
38-
"pdu_decoding",
39-
"rle_decompression",
40-
"bitmap_stream",
41-
"cliprdr_format",
42-
"cliprdr_channel_processing",
43-
"channel_processing",
44-
"bulk_mppc",
45-
"bulk_ncrush",
46-
"bulk_xcrush",
47-
"bulk_round_trip",
48-
];
49-
5037
fn main() -> anyhow::Result<()> {
5138
let args = match cli::parse_args() {
5239
Ok(args) => args,
@@ -88,10 +75,9 @@ fn main() -> anyhow::Result<()> {
8875
}
8976
Action::CheckFeatures { case, list, format } => {
9077
if list {
91-
match format.as_deref() {
92-
Some("github-matrix") => features::list_github_matrix()?,
93-
Some("human") | None => features::list_human()?,
94-
Some(other) => anyhow::bail!("unknown --format value: {other}"),
78+
match format {
79+
cli::ListFormat::Human => features::list_human()?,
80+
cli::ListFormat::GithubMatrix => features::list_github_matrix()?,
9581
}
9682
} else if let Some(case_name) = case {
9783
features::run_case(&sh, &case_name)?;
@@ -125,6 +111,10 @@ fn main() -> anyhow::Result<()> {
125111
Action::FuzzCorpusMin { target } => fuzz::corpus_minify(&sh, target)?,
126112
Action::FuzzCorpusPush => fuzz::corpus_push(&sh)?,
127113
Action::FuzzInstall => fuzz::install(&sh)?,
114+
Action::FuzzList { format } => match format {
115+
cli::ListFormat::Human => fuzz::list_human()?,
116+
cli::ListFormat::GithubMatrix => fuzz::list_github_matrix()?,
117+
},
128118
Action::FuzzRun { duration, target } => fuzz::run(&sh, duration, target)?,
129119
Action::WasmCheck => wasm::check(&sh)?,
130120
Action::WasmInstall => wasm::install(&sh)?,

xtask/src/prelude.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ pub use crate::bin_install::{cargo_install, is_installed};
55
pub use crate::bin_version::*;
66
pub(crate) use crate::macros::{run_cmd_in, trace, windows_skip};
77
pub use crate::section::Section;
8-
pub use crate::{CARGO, FUZZ_TARGETS, WASM_PACKAGES, is_verbose, list_files};
8+
pub use crate::{CARGO, WASM_PACKAGES, is_verbose, list_files};

0 commit comments

Comments
 (0)