Skip to main content

dev_fuzz/
lib.rs

1//! # dev-fuzz
2//!
3//! Fuzzing harness integration for Rust. Part of the `dev-*`
4//! verification suite.
5//!
6//! Wraps `cargo-fuzz` (libFuzzer-based) and emits findings as
7//! `dev-report::Report`. Captures crashes, timeouts, and OOM events
8//! with reproducer inputs attached.
9//!
10//! ## What is fuzzing?
11//!
12//! Fuzzing feeds random or guided-random inputs to your code looking
13//! for crashes, panics, or unexpected behavior. It's especially
14//! valuable for parsers, deserializers, network protocol handlers,
15//! and anything that touches untrusted input.
16//!
17//! ## Quick example
18//!
19//! ```no_run
20//! use dev_fuzz::{FuzzRun, FuzzBudget};
21//! use std::time::Duration;
22//!
23//! let run = FuzzRun::new("parse_input", "0.1.0")
24//!     .budget(FuzzBudget::time(Duration::from_secs(60)));
25//! let result = run.execute().unwrap();
26//! let report = result.into_report();
27//! ```
28//!
29//! ## Status
30//!
31//! Pre-1.0. API shape defined; subprocess integration lands in `0.9.1`.
32
33#![cfg_attr(docsrs, feature(doc_cfg))]
34#![warn(missing_docs)]
35#![warn(rust_2018_idioms)]
36
37use std::time::Duration;
38
39use dev_report::{CheckResult, Evidence, Report, Severity};
40
41/// Type of finding discovered during a fuzz run.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FuzzFindingKind {
44    /// A crash (panic, segfault, abort).
45    Crash,
46    /// An iteration exceeded the timeout.
47    Timeout,
48    /// An iteration exceeded the memory limit.
49    OutOfMemory,
50}
51
52/// Budget for a fuzz run.
53#[derive(Debug, Clone, Copy)]
54pub enum FuzzBudget {
55    /// Run for the given duration.
56    Time(Duration),
57    /// Run for the given number of executions.
58    Executions(u64),
59}
60
61impl FuzzBudget {
62    /// Build a time-based budget.
63    pub fn time(d: Duration) -> Self {
64        Self::Time(d)
65    }
66
67    /// Build an execution-count budget.
68    pub fn executions(n: u64) -> Self {
69        Self::Executions(n)
70    }
71}
72
73/// Configuration for a fuzz run.
74#[derive(Debug, Clone)]
75pub struct FuzzRun {
76    target: String,
77    version: String,
78    budget: FuzzBudget,
79}
80
81impl FuzzRun {
82    /// Begin a new fuzz run against the given fuzz target.
83    pub fn new(target: impl Into<String>, version: impl Into<String>) -> Self {
84        Self {
85            target: target.into(),
86            version: version.into(),
87            budget: FuzzBudget::Time(Duration::from_secs(60)),
88        }
89    }
90
91    /// Set the run budget.
92    pub fn budget(mut self, budget: FuzzBudget) -> Self {
93        self.budget = budget;
94        self
95    }
96
97    /// Selected budget.
98    pub fn fuzz_budget(&self) -> FuzzBudget {
99        self.budget
100    }
101
102    /// Execute the fuzz run.
103    ///
104    /// In `0.9.0` this is a stub; subprocess integration lands in `0.9.1`.
105    pub fn execute(&self) -> Result<FuzzResult, FuzzError> {
106        Ok(FuzzResult {
107            target: self.target.clone(),
108            version: self.version.clone(),
109            executions: 0,
110            findings: Vec::new(),
111        })
112    }
113}
114
115/// A single fuzz finding.
116#[derive(Debug, Clone)]
117pub struct FuzzFinding {
118    /// Kind of finding.
119    pub kind: FuzzFindingKind,
120    /// Path to the input that triggered the finding (a "reproducer").
121    pub reproducer_path: String,
122    /// Short human-readable summary.
123    pub summary: String,
124}
125
126/// Result of a fuzz run.
127#[derive(Debug, Clone)]
128pub struct FuzzResult {
129    /// Fuzz target name.
130    pub target: String,
131    /// Crate version at the time of the run.
132    pub version: String,
133    /// Total executions completed.
134    pub executions: u64,
135    /// Findings discovered.
136    pub findings: Vec<FuzzFinding>,
137}
138
139impl FuzzResult {
140    /// Convert this result into a `dev-report::Report`.
141    pub fn into_report(self) -> Report {
142        let mut report = Report::new(&self.target, &self.version).with_producer("dev-fuzz");
143        if self.findings.is_empty() {
144            report.push(
145                CheckResult::pass(format!("fuzz::{}", self.target))
146                    .with_detail(format!("{} executions, 0 findings", self.executions))
147                    .with_evidence(Evidence::numeric_int("executions", self.executions as i64)),
148            );
149        } else {
150            for f in &self.findings {
151                let sev = match f.kind {
152                    FuzzFindingKind::Crash => Severity::Critical,
153                    FuzzFindingKind::Timeout => Severity::Warning,
154                    FuzzFindingKind::OutOfMemory => Severity::Error,
155                };
156                let kind_str = match f.kind {
157                    FuzzFindingKind::Crash => "crash",
158                    FuzzFindingKind::Timeout => "timeout",
159                    FuzzFindingKind::OutOfMemory => "oom",
160                };
161                report.push(
162                    CheckResult::fail(format!("fuzz::{}::{kind_str}", self.target), sev)
163                        .with_detail(f.summary.clone())
164                        .with_evidence(Evidence::file_ref("reproducer", &f.reproducer_path)),
165                );
166            }
167        }
168        report.finish();
169        report
170    }
171}
172
173/// Errors that can arise during a fuzz run.
174#[derive(Debug)]
175pub enum FuzzError {
176    /// `cargo-fuzz` is not installed.
177    ToolNotInstalled,
178    /// Nightly Rust is required but not available.
179    NightlyRequired,
180    /// Subprocess failure.
181    SubprocessFailed(String),
182    /// The named fuzz target was not found in the project.
183    TargetNotFound(String),
184}
185
186impl std::fmt::Display for FuzzError {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            Self::ToolNotInstalled => write!(f, "cargo-fuzz is not installed"),
190            Self::NightlyRequired => write!(f, "nightly Rust is required"),
191            Self::SubprocessFailed(s) => write!(f, "subprocess failed: {s}"),
192            Self::TargetNotFound(s) => write!(f, "fuzz target not found: {s}"),
193        }
194    }
195}
196
197impl std::error::Error for FuzzError {}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn run_builds() {
205        let r = FuzzRun::new("parse", "0.1.0");
206        match r.fuzz_budget() {
207            FuzzBudget::Time(_) => {}
208            _ => panic!("default should be time-based"),
209        }
210    }
211
212    #[test]
213    fn executions_budget() {
214        let r = FuzzRun::new("parse", "0.1.0").budget(FuzzBudget::executions(1_000_000));
215        match r.fuzz_budget() {
216            FuzzBudget::Executions(n) => assert_eq!(n, 1_000_000),
217            _ => panic!("expected executions budget"),
218        }
219    }
220
221    #[test]
222    fn empty_findings_passes() {
223        let r = FuzzResult {
224            target: "parse".into(),
225            version: "0.1.0".into(),
226            executions: 1_000_000,
227            findings: Vec::new(),
228        };
229        let report = r.into_report();
230        assert!(report.passed());
231    }
232
233    #[test]
234    fn crash_finding_is_critical() {
235        let r = FuzzResult {
236            target: "parse".into(),
237            version: "0.1.0".into(),
238            executions: 500,
239            findings: vec![FuzzFinding {
240                kind: FuzzFindingKind::Crash,
241                reproducer_path: "fuzz/artifacts/crash-deadbeef".into(),
242                summary: "panic in parse_input".into(),
243            }],
244        };
245        let report = r.into_report();
246        assert!(report.failed());
247        assert_eq!(report.checks[0].severity, Some(Severity::Critical));
248    }
249}