Skip to main content

dev_fuzz/
lib.rs

1//! # dev-fuzz
2//!
3//! Fuzzing harness integration for Rust. Wraps
4//! [`cargo-fuzz`](https://crates.io/crates/cargo-fuzz) (libFuzzer-based)
5//! and emits findings as [`dev_report::Report`].
6//!
7//! Captures crashes, timeouts, and OOM events with reproducer inputs
8//! attached as [`Evidence::FileRef`](dev_report::Evidence) so consumers
9//! can replay the input that triggered each finding.
10//!
11//! ## Quick example
12//!
13//! ```no_run
14//! use dev_fuzz::{FuzzBudget, FuzzRun};
15//! use std::time::Duration;
16//!
17//! let run = FuzzRun::new("parse_input", "0.1.0")
18//!     .budget(FuzzBudget::time(Duration::from_secs(60)));
19//! let result = run.execute().unwrap();
20//! let report = result.into_report();
21//! ```
22//!
23//! ## Requirements
24//!
25//! ```text
26//! cargo install cargo-fuzz
27//! rustup toolchain install nightly      # libFuzzer requires nightly
28//! ```
29//!
30//! The crate detects absence of either prerequisite and surfaces
31//! [`FuzzError::ToolNotInstalled`] / [`FuzzError::NightlyRequired`]
32//! without panicking.
33
34#![cfg_attr(docsrs, feature(doc_cfg))]
35#![warn(missing_docs)]
36#![warn(rust_2018_idioms)]
37
38use std::path::PathBuf;
39use std::time::Duration;
40
41use dev_report::{CheckResult, Evidence, Report, Severity};
42use serde::{Deserialize, Serialize};
43
44mod producer;
45mod runner;
46
47pub use producer::FuzzProducer;
48
49// ---------------------------------------------------------------------------
50// FuzzFindingKind
51// ---------------------------------------------------------------------------
52
53/// Type of finding discovered during a fuzz run.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum FuzzFindingKind {
57    /// A crash (panic, signal, libFuzzer "deadly signal").
58    Crash,
59    /// libFuzzer reported the input exceeded the per-execution timeout.
60    Timeout,
61    /// libFuzzer reported the input exceeded the configured memory limit.
62    OutOfMemory,
63}
64
65impl FuzzFindingKind {
66    /// Severity mapped from this finding kind per REPS § 3.
67    pub fn severity(self) -> Severity {
68        match self {
69            Self::Crash => Severity::Critical,
70            Self::OutOfMemory => Severity::Error,
71            Self::Timeout => Severity::Warning,
72        }
73    }
74
75    /// Lowercase short label (`crash`, `timeout`, `oom`). Stable: used
76    /// in `CheckResult` names and is suitable for log / metric keys.
77    pub fn label(self) -> &'static str {
78        match self {
79            Self::Crash => "crash",
80            Self::Timeout => "timeout",
81            Self::OutOfMemory => "oom",
82        }
83    }
84}
85
86// ---------------------------------------------------------------------------
87// FuzzBudget
88// ---------------------------------------------------------------------------
89
90/// Budget for a fuzz run.
91#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum FuzzBudget {
94    /// Run for the given wall-clock duration. Translates to
95    /// `-max_total_time=<secs>` on the libFuzzer side.
96    Time(Duration),
97    /// Run for the given number of executions. Translates to
98    /// `-runs=<N>` on the libFuzzer side.
99    Executions(u64),
100}
101
102impl FuzzBudget {
103    /// Build a time-based budget.
104    pub fn time(d: Duration) -> Self {
105        Self::Time(d)
106    }
107
108    /// Build an execution-count budget.
109    pub fn executions(n: u64) -> Self {
110        Self::Executions(n)
111    }
112
113    /// libFuzzer-style flag suffix for this budget.
114    pub(crate) fn as_libfuzzer_flag(&self) -> String {
115        match self {
116            Self::Time(d) => format!("-max_total_time={}", d.as_secs().max(1)),
117            Self::Executions(n) => format!("-runs={}", n),
118        }
119    }
120}
121
122// ---------------------------------------------------------------------------
123// Sanitizer
124// ---------------------------------------------------------------------------
125
126/// Which sanitizer to enable on the fuzz target build.
127///
128/// `cargo-fuzz` accepts `address`, `leak`, `memory`, `thread`,
129/// `none`. We expose the most common four.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum Sanitizer {
133    /// AddressSanitizer (default in `cargo-fuzz`).
134    Address,
135    /// LeakSanitizer.
136    Leak,
137    /// MemorySanitizer.
138    Memory,
139    /// ThreadSanitizer.
140    Thread,
141    /// No sanitizer (faster, less informative).
142    None,
143}
144
145impl Sanitizer {
146    pub(crate) fn as_cargo_fuzz_flag(self) -> &'static str {
147        match self {
148            Self::Address => "address",
149            Self::Leak => "leak",
150            Self::Memory => "memory",
151            Self::Thread => "thread",
152            Self::None => "none",
153        }
154    }
155}
156
157// ---------------------------------------------------------------------------
158// FuzzRun
159// ---------------------------------------------------------------------------
160
161/// Configuration for a fuzz run.
162///
163/// # Example
164///
165/// ```no_run
166/// use dev_fuzz::{FuzzBudget, FuzzRun, Sanitizer};
167/// use std::time::Duration;
168///
169/// let run = FuzzRun::new("parse_input", "0.1.0")
170///     .budget(FuzzBudget::time(Duration::from_secs(60)))
171///     .sanitizer(Sanitizer::Address)
172///     .timeout_per_iter(Duration::from_secs(5))
173///     .rss_limit_mb(2048);
174///
175/// let _result = run.execute().unwrap();
176/// ```
177#[derive(Debug, Clone)]
178pub struct FuzzRun {
179    target: String,
180    version: String,
181    budget: FuzzBudget,
182    workdir: Option<PathBuf>,
183    sanitizer: Sanitizer,
184    timeout_per_iter: Option<Duration>,
185    rss_limit_mb: Option<u32>,
186    allow_list: Vec<String>,
187}
188
189impl FuzzRun {
190    /// Begin a new fuzz run against the given fuzz target.
191    ///
192    /// `target` is the libFuzzer target name (the file under
193    /// `fuzz/fuzz_targets/<target>.rs`). `version` is descriptive and
194    /// flows into the produced `Report`.
195    pub fn new(target: impl Into<String>, version: impl Into<String>) -> Self {
196        Self {
197            target: target.into(),
198            version: version.into(),
199            budget: FuzzBudget::Time(Duration::from_secs(60)),
200            workdir: None,
201            sanitizer: Sanitizer::Address,
202            timeout_per_iter: None,
203            rss_limit_mb: None,
204            allow_list: Vec::new(),
205        }
206    }
207
208    /// Set the run budget. Default: 60 seconds of wall-clock time.
209    pub fn budget(mut self, budget: FuzzBudget) -> Self {
210        self.budget = budget;
211        self
212    }
213
214    /// Selected budget.
215    pub fn fuzz_budget(&self) -> FuzzBudget {
216        self.budget
217    }
218
219    /// Run `cargo fuzz` from `dir` instead of the current directory.
220    pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
221        self.workdir = Some(dir.into());
222        self
223    }
224
225    /// Pick the sanitizer to enable. Default: `Sanitizer::Address`.
226    pub fn sanitizer(mut self, sanitizer: Sanitizer) -> Self {
227        self.sanitizer = sanitizer;
228        self
229    }
230
231    /// Per-iteration timeout. Translates to libFuzzer's `-timeout=<secs>`.
232    pub fn timeout_per_iter(mut self, d: Duration) -> Self {
233        self.timeout_per_iter = Some(d);
234        self
235    }
236
237    /// Per-iteration RSS limit, in megabytes. Translates to libFuzzer's
238    /// `-rss_limit_mb=<N>`.
239    pub fn rss_limit_mb(mut self, mb: u32) -> Self {
240        self.rss_limit_mb = Some(mb);
241        self
242    }
243
244    /// Suppress a finding whose reproducer-path basename matches `name`.
245    ///
246    /// Useful for known false positives that have a triaged reproducer
247    /// already on disk (e.g. `crash-deadbeef`). The match is on the
248    /// final path component only.
249    pub fn allow(mut self, name: impl Into<String>) -> Self {
250        self.allow_list.push(name.into());
251        self
252    }
253
254    /// Bulk version of [`allow`](Self::allow).
255    pub fn allow_all<I, S>(mut self, names: I) -> Self
256    where
257        I: IntoIterator<Item = S>,
258        S: Into<String>,
259    {
260        self.allow_list.extend(names.into_iter().map(Into::into));
261        self
262    }
263
264    /// Target name (the `fuzz_targets/<name>.rs` file).
265    pub fn target_name(&self) -> &str {
266        &self.target
267    }
268
269    /// Descriptive subject version.
270    pub fn subject_version(&self) -> &str {
271        &self.version
272    }
273
274    /// Execute the fuzz run.
275    ///
276    /// Spawns `cargo +nightly fuzz run <target>` with the configured
277    /// budget, sanitizer, and limits. Captures stderr (where libFuzzer
278    /// writes its findings) and parses out crash / timeout / OOM
279    /// records with reproducer paths.
280    ///
281    /// Tool / nightly / target-not-found preconditions surface as
282    /// typed [`FuzzError`] variants. No panics.
283    pub fn execute(&self) -> Result<FuzzResult, FuzzError> {
284        runner::run(self)
285    }
286
287    pub(crate) fn workdir_path(&self) -> Option<&std::path::Path> {
288        self.workdir.as_deref()
289    }
290
291    pub(crate) fn sanitizer_kind(&self) -> Sanitizer {
292        self.sanitizer
293    }
294
295    pub(crate) fn timeout_per_iter_value(&self) -> Option<Duration> {
296        self.timeout_per_iter
297    }
298
299    pub(crate) fn rss_limit_value(&self) -> Option<u32> {
300        self.rss_limit_mb
301    }
302
303    pub(crate) fn allow_list_view(&self) -> &[String] {
304        &self.allow_list
305    }
306}
307
308// ---------------------------------------------------------------------------
309// FuzzFinding + FuzzResult
310// ---------------------------------------------------------------------------
311
312/// A single fuzz finding.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct FuzzFinding {
315    /// Kind of finding (crash / timeout / OOM).
316    pub kind: FuzzFindingKind,
317    /// Path to the input that triggered the finding ("reproducer").
318    pub reproducer_path: String,
319    /// Short human-readable summary captured from libFuzzer's output.
320    pub summary: String,
321}
322
323/// Result of a fuzz run.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct FuzzResult {
326    /// Fuzz target name.
327    pub target: String,
328    /// Subject version (descriptive).
329    pub version: String,
330    /// Total executions completed by libFuzzer.
331    pub executions: u64,
332    /// Findings discovered during the run.
333    pub findings: Vec<FuzzFinding>,
334}
335
336impl FuzzResult {
337    /// Total number of findings.
338    pub fn total_findings(&self) -> usize {
339        self.findings.len()
340    }
341
342    /// Number of findings whose [`kind`](FuzzFinding::kind) equals `kind`.
343    pub fn count_of(&self, kind: FuzzFindingKind) -> usize {
344        self.findings.iter().filter(|f| f.kind == kind).count()
345    }
346
347    /// Highest severity present across findings, if any.
348    pub fn worst_severity(&self) -> Option<Severity> {
349        self.findings
350            .iter()
351            .map(|f| f.kind.severity())
352            .max_by_key(|s| severity_ord(*s))
353    }
354
355    /// Convert into a [`Report`].
356    ///
357    /// No findings → one passing `CheckResult` named
358    /// `fuzz::<target>` with `executions` numeric evidence. Otherwise
359    /// one failing `CheckResult` per finding named
360    /// `fuzz::<target>::<kind>` tagged `fuzz` plus a kind-specific tag
361    /// (`crash`, `timeout`, `oom`). Each finding's reproducer path
362    /// rides along as `Evidence::FileRef`.
363    pub fn into_report(self) -> Report {
364        let mut report = Report::new(&self.target, &self.version).with_producer("dev-fuzz");
365        if self.findings.is_empty() {
366            report.push(
367                CheckResult::pass(format!("fuzz::{}", self.target))
368                    .with_tag("fuzz")
369                    .with_detail(format!("{} executions, 0 findings", self.executions))
370                    .with_evidence(Evidence::numeric_int("executions", self.executions as i64)),
371            );
372        } else {
373            for f in &self.findings {
374                let sev = f.kind.severity();
375                let check =
376                    CheckResult::fail(format!("fuzz::{}::{}", self.target, f.kind.label()), sev)
377                        .with_detail(f.summary.clone())
378                        .with_tag("fuzz")
379                        .with_tag(f.kind.label())
380                        .with_evidence(Evidence::file_ref("reproducer", &f.reproducer_path));
381                report.push(check);
382            }
383        }
384        report.finish();
385        report
386    }
387}
388
389pub(crate) fn severity_ord(s: Severity) -> u8 {
390    match s {
391        Severity::Info => 0,
392        Severity::Warning => 1,
393        Severity::Error => 2,
394        Severity::Critical => 3,
395    }
396}
397
398// ---------------------------------------------------------------------------
399// FuzzError
400// ---------------------------------------------------------------------------
401
402/// Errors that can arise during a fuzz run.
403#[derive(Debug)]
404pub enum FuzzError {
405    /// `cargo-fuzz` is not installed on the system.
406    ToolNotInstalled,
407    /// The nightly Rust toolchain is required but not installed.
408    NightlyRequired,
409    /// Subprocess returned a fatal error (non-zero exit with no
410    /// recoverable output).
411    SubprocessFailed(String),
412    /// The named fuzz target was not found in the project.
413    TargetNotFound(String),
414}
415
416impl std::fmt::Display for FuzzError {
417    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418        match self {
419            Self::ToolNotInstalled => write!(
420                f,
421                "cargo-fuzz is not installed; run `cargo install cargo-fuzz`"
422            ),
423            Self::NightlyRequired => write!(
424                f,
425                "nightly Rust required; run `rustup toolchain install nightly`"
426            ),
427            Self::SubprocessFailed(s) => write!(f, "cargo fuzz failed: {s}"),
428            Self::TargetNotFound(s) => write!(f, "fuzz target not found: {s}"),
429        }
430    }
431}
432
433impl std::error::Error for FuzzError {}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn finding_kind_severity_mapping_matches_reps() {
441        assert_eq!(FuzzFindingKind::Crash.severity(), Severity::Critical);
442        assert_eq!(FuzzFindingKind::OutOfMemory.severity(), Severity::Error);
443        assert_eq!(FuzzFindingKind::Timeout.severity(), Severity::Warning);
444    }
445
446    #[test]
447    fn finding_kind_labels_are_stable() {
448        assert_eq!(FuzzFindingKind::Crash.label(), "crash");
449        assert_eq!(FuzzFindingKind::OutOfMemory.label(), "oom");
450        assert_eq!(FuzzFindingKind::Timeout.label(), "timeout");
451    }
452
453    #[test]
454    fn budget_as_libfuzzer_flag() {
455        assert_eq!(
456            FuzzBudget::time(Duration::from_secs(60)).as_libfuzzer_flag(),
457            "-max_total_time=60"
458        );
459        assert_eq!(
460            FuzzBudget::time(Duration::from_millis(500)).as_libfuzzer_flag(),
461            "-max_total_time=1"
462        );
463        assert_eq!(
464            FuzzBudget::executions(1_000_000).as_libfuzzer_flag(),
465            "-runs=1000000"
466        );
467    }
468
469    #[test]
470    fn sanitizer_flag_values() {
471        assert_eq!(Sanitizer::Address.as_cargo_fuzz_flag(), "address");
472        assert_eq!(Sanitizer::Leak.as_cargo_fuzz_flag(), "leak");
473        assert_eq!(Sanitizer::Memory.as_cargo_fuzz_flag(), "memory");
474        assert_eq!(Sanitizer::Thread.as_cargo_fuzz_flag(), "thread");
475        assert_eq!(Sanitizer::None.as_cargo_fuzz_flag(), "none");
476    }
477
478    #[test]
479    fn run_builder_chains() {
480        let run = FuzzRun::new("parse", "0.1.0")
481            .budget(FuzzBudget::time(Duration::from_secs(30)))
482            .sanitizer(Sanitizer::Memory)
483            .timeout_per_iter(Duration::from_secs(5))
484            .rss_limit_mb(2048)
485            .allow("crash-deadbeef")
486            .allow_all(["crash-cafebabe", "timeout-abc"]);
487        assert_eq!(run.target_name(), "parse");
488        assert_eq!(run.subject_version(), "0.1.0");
489        assert_eq!(run.sanitizer_kind(), Sanitizer::Memory);
490        assert_eq!(run.rss_limit_value(), Some(2048));
491        assert_eq!(run.allow_list_view().len(), 3);
492    }
493
494    #[test]
495    fn empty_findings_passes_with_executions_evidence() {
496        let r = FuzzResult {
497            target: "parse".into(),
498            version: "0.1.0".into(),
499            executions: 1_000_000,
500            findings: Vec::new(),
501        };
502        let report = r.into_report();
503        assert!(report.passed());
504        assert_eq!(report.checks.len(), 1);
505        let c = &report.checks[0];
506        assert!(c.has_tag("fuzz"));
507        assert!(c.evidence.iter().any(|e| e.label == "executions"));
508    }
509
510    #[test]
511    fn crash_finding_is_critical() {
512        let r = FuzzResult {
513            target: "parse".into(),
514            version: "0.1.0".into(),
515            executions: 500,
516            findings: vec![FuzzFinding {
517                kind: FuzzFindingKind::Crash,
518                reproducer_path: "fuzz/artifacts/parse/crash-deadbeef".into(),
519                summary: "panic in parse_input".into(),
520            }],
521        };
522        let report = r.into_report();
523        assert!(report.failed());
524        assert_eq!(report.checks[0].severity, Some(Severity::Critical));
525        assert!(report.checks[0].has_tag("crash"));
526    }
527
528    #[test]
529    fn each_kind_produces_one_check() {
530        let r = FuzzResult {
531            target: "p".into(),
532            version: "0.1.0".into(),
533            executions: 10,
534            findings: vec![
535                FuzzFinding {
536                    kind: FuzzFindingKind::Crash,
537                    reproducer_path: "a".into(),
538                    summary: "x".into(),
539                },
540                FuzzFinding {
541                    kind: FuzzFindingKind::OutOfMemory,
542                    reproducer_path: "b".into(),
543                    summary: "x".into(),
544                },
545                FuzzFinding {
546                    kind: FuzzFindingKind::Timeout,
547                    reproducer_path: "c".into(),
548                    summary: "x".into(),
549                },
550            ],
551        };
552        let report = r.into_report();
553        assert_eq!(report.checks.len(), 3);
554        assert!(report
555            .checks
556            .iter()
557            .any(|c| c.severity == Some(Severity::Critical)));
558        assert!(report
559            .checks
560            .iter()
561            .any(|c| c.severity == Some(Severity::Error)));
562        assert!(report
563            .checks
564            .iter()
565            .any(|c| c.severity == Some(Severity::Warning)));
566    }
567
568    #[test]
569    fn count_of_filters_by_kind() {
570        let r = FuzzResult {
571            target: "p".into(),
572            version: "0.1.0".into(),
573            executions: 0,
574            findings: vec![
575                FuzzFinding {
576                    kind: FuzzFindingKind::Crash,
577                    reproducer_path: "a".into(),
578                    summary: "x".into(),
579                },
580                FuzzFinding {
581                    kind: FuzzFindingKind::Crash,
582                    reproducer_path: "b".into(),
583                    summary: "x".into(),
584                },
585                FuzzFinding {
586                    kind: FuzzFindingKind::Timeout,
587                    reproducer_path: "c".into(),
588                    summary: "x".into(),
589                },
590            ],
591        };
592        assert_eq!(r.count_of(FuzzFindingKind::Crash), 2);
593        assert_eq!(r.count_of(FuzzFindingKind::Timeout), 1);
594        assert_eq!(r.count_of(FuzzFindingKind::OutOfMemory), 0);
595        assert_eq!(r.total_findings(), 3);
596    }
597
598    #[test]
599    fn worst_severity_picks_max() {
600        let r = FuzzResult {
601            target: "p".into(),
602            version: "0.1.0".into(),
603            executions: 0,
604            findings: vec![
605                FuzzFinding {
606                    kind: FuzzFindingKind::Timeout,
607                    reproducer_path: "a".into(),
608                    summary: "x".into(),
609                },
610                FuzzFinding {
611                    kind: FuzzFindingKind::Crash,
612                    reproducer_path: "b".into(),
613                    summary: "x".into(),
614                },
615            ],
616        };
617        assert_eq!(r.worst_severity(), Some(Severity::Critical));
618        let empty = FuzzResult {
619            target: "p".into(),
620            version: "0.1.0".into(),
621            executions: 0,
622            findings: Vec::new(),
623        };
624        assert_eq!(empty.worst_severity(), None);
625    }
626
627    #[test]
628    fn result_round_trips_through_json() {
629        let r = FuzzResult {
630            target: "parse".into(),
631            version: "0.1.0".into(),
632            executions: 1234,
633            findings: vec![FuzzFinding {
634                kind: FuzzFindingKind::Crash,
635                reproducer_path: "fuzz/artifacts/parse/crash-1".into(),
636                summary: "panicked".into(),
637            }],
638        };
639        let s = serde_json::to_string(&r).unwrap();
640        let back: FuzzResult = serde_json::from_str(&s).unwrap();
641        assert_eq!(back.findings.len(), 1);
642        assert_eq!(back.findings[0].kind, FuzzFindingKind::Crash);
643    }
644}