1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum FuzzFindingKind {
57 Crash,
59 Timeout,
61 OutOfMemory,
63}
64
65impl FuzzFindingKind {
66 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 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#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum FuzzBudget {
94 Time(Duration),
97 Executions(u64),
100}
101
102impl FuzzBudget {
103 pub fn time(d: Duration) -> Self {
105 Self::Time(d)
106 }
107
108 pub fn executions(n: u64) -> Self {
110 Self::Executions(n)
111 }
112
113 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum Sanitizer {
133 Address,
135 Leak,
137 Memory,
139 Thread,
141 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#[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 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 pub fn budget(mut self, budget: FuzzBudget) -> Self {
210 self.budget = budget;
211 self
212 }
213
214 pub fn fuzz_budget(&self) -> FuzzBudget {
216 self.budget
217 }
218
219 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
221 self.workdir = Some(dir.into());
222 self
223 }
224
225 pub fn sanitizer(mut self, sanitizer: Sanitizer) -> Self {
227 self.sanitizer = sanitizer;
228 self
229 }
230
231 pub fn timeout_per_iter(mut self, d: Duration) -> Self {
233 self.timeout_per_iter = Some(d);
234 self
235 }
236
237 pub fn rss_limit_mb(mut self, mb: u32) -> Self {
240 self.rss_limit_mb = Some(mb);
241 self
242 }
243
244 pub fn allow(mut self, name: impl Into<String>) -> Self {
250 self.allow_list.push(name.into());
251 self
252 }
253
254 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 pub fn target_name(&self) -> &str {
266 &self.target
267 }
268
269 pub fn subject_version(&self) -> &str {
271 &self.version
272 }
273
274 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#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct FuzzFinding {
315 pub kind: FuzzFindingKind,
317 pub reproducer_path: String,
319 pub summary: String,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct FuzzResult {
326 pub target: String,
328 pub version: String,
330 pub executions: u64,
332 pub findings: Vec<FuzzFinding>,
334}
335
336impl FuzzResult {
337 pub fn total_findings(&self) -> usize {
339 self.findings.len()
340 }
341
342 pub fn count_of(&self, kind: FuzzFindingKind) -> usize {
344 self.findings.iter().filter(|f| f.kind == kind).count()
345 }
346
347 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 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#[derive(Debug)]
404pub enum FuzzError {
405 ToolNotInstalled,
407 NightlyRequired,
409 SubprocessFailed(String),
412 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}