1#![cfg_attr(docsrs, feature(doc_cfg))]
35#![warn(missing_docs)]
36#![warn(rust_2018_idioms)]
37
38pub const VERSION: &str = env!("CARGO_PKG_VERSION");
49
50use std::path::PathBuf;
51use std::time::Duration;
52
53use dev_report::{CheckResult, Evidence, Report, Severity};
54use serde::{Deserialize, Serialize};
55
56mod process;
57mod producer;
58mod runner;
59
60pub use producer::FuzzProducer;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum FuzzFindingKind {
70 Crash,
72 Timeout,
74 OutOfMemory,
76}
77
78impl FuzzFindingKind {
79 pub fn severity(self) -> Severity {
81 match self {
82 Self::Crash => Severity::Critical,
83 Self::OutOfMemory => Severity::Error,
84 Self::Timeout => Severity::Warning,
85 }
86 }
87
88 pub fn label(self) -> &'static str {
91 match self {
92 Self::Crash => "crash",
93 Self::Timeout => "timeout",
94 Self::OutOfMemory => "oom",
95 }
96 }
97}
98
99#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum FuzzBudget {
107 Time(Duration),
110 Executions(u64),
113}
114
115impl FuzzBudget {
116 pub fn time(d: Duration) -> Self {
118 Self::Time(d)
119 }
120
121 pub fn executions(n: u64) -> Self {
123 Self::Executions(n)
124 }
125
126 pub(crate) fn as_libfuzzer_flag(&self) -> String {
128 match self {
129 Self::Time(d) => format!("-max_total_time={}", d.as_secs().max(1)),
130 Self::Executions(n) => format!("-runs={}", n),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum Sanitizer {
146 Address,
148 Leak,
150 Memory,
152 Thread,
154 None,
156}
157
158impl Sanitizer {
159 pub(crate) fn as_cargo_fuzz_flag(self) -> &'static str {
160 match self {
161 Self::Address => "address",
162 Self::Leak => "leak",
163 Self::Memory => "memory",
164 Self::Thread => "thread",
165 Self::None => "none",
166 }
167 }
168}
169
170#[derive(Debug, Clone)]
191pub struct FuzzRun {
192 target: String,
193 version: String,
194 budget: FuzzBudget,
195 workdir: Option<PathBuf>,
196 sanitizer: Sanitizer,
197 timeout_per_iter: Option<Duration>,
198 rss_limit_mb: Option<u32>,
199 allow_list: Vec<String>,
200 run_timeout: Option<Duration>,
201}
202
203impl FuzzRun {
204 pub fn new(target: impl Into<String>, version: impl Into<String>) -> Self {
210 Self {
211 target: target.into(),
212 version: version.into(),
213 budget: FuzzBudget::Time(Duration::from_secs(60)),
214 workdir: None,
215 sanitizer: Sanitizer::Address,
216 timeout_per_iter: None,
217 rss_limit_mb: None,
218 allow_list: Vec::new(),
219 run_timeout: None,
220 }
221 }
222
223 pub fn budget(mut self, budget: FuzzBudget) -> Self {
225 self.budget = budget;
226 self
227 }
228
229 pub fn fuzz_budget(&self) -> FuzzBudget {
231 self.budget
232 }
233
234 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
236 self.workdir = Some(dir.into());
237 self
238 }
239
240 pub fn sanitizer(mut self, sanitizer: Sanitizer) -> Self {
242 self.sanitizer = sanitizer;
243 self
244 }
245
246 pub fn timeout_per_iter(mut self, d: Duration) -> Self {
249 self.timeout_per_iter = Some(d);
250 self
251 }
252
253 pub fn rss_limit_mb(mut self, mb: u32) -> Self {
256 self.rss_limit_mb = Some(mb);
257 self
258 }
259
260 pub fn allow(mut self, name: impl Into<String>) -> Self {
266 self.allow_list.push(name.into());
267 self
268 }
269
270 pub fn allow_all<I, S>(mut self, names: I) -> Self
272 where
273 I: IntoIterator<Item = S>,
274 S: Into<String>,
275 {
276 self.allow_list.extend(names.into_iter().map(Into::into));
277 self
278 }
279
280 pub fn run_timeout(mut self, limit: Duration) -> Self {
294 self.run_timeout = Some(limit);
295 self
296 }
297
298 pub fn target_name(&self) -> &str {
300 &self.target
301 }
302
303 pub fn subject_version(&self) -> &str {
305 &self.version
306 }
307
308 pub fn execute(&self) -> Result<FuzzResult, FuzzError> {
318 runner::run(self)
319 }
320
321 pub(crate) fn workdir_path(&self) -> Option<&std::path::Path> {
322 self.workdir.as_deref()
323 }
324
325 pub(crate) fn sanitizer_kind(&self) -> Sanitizer {
326 self.sanitizer
327 }
328
329 pub(crate) fn timeout_per_iter_value(&self) -> Option<Duration> {
330 self.timeout_per_iter
331 }
332
333 pub(crate) fn rss_limit_value(&self) -> Option<u32> {
334 self.rss_limit_mb
335 }
336
337 pub(crate) fn allow_list_view(&self) -> &[String] {
338 &self.allow_list
339 }
340
341 pub(crate) fn run_timeout_value(&self) -> Option<Duration> {
342 self.run_timeout
343 }
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct FuzzFinding {
353 pub kind: FuzzFindingKind,
355 pub reproducer_path: String,
360 pub summary: String,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct FuzzResult {
367 pub target: String,
369 pub version: String,
371 pub executions: u64,
373 pub findings: Vec<FuzzFinding>,
375}
376
377impl FuzzResult {
378 pub fn total_findings(&self) -> usize {
380 self.findings.len()
381 }
382
383 pub fn count_of(&self, kind: FuzzFindingKind) -> usize {
385 self.findings.iter().filter(|f| f.kind == kind).count()
386 }
387
388 pub fn worst_severity(&self) -> Option<Severity> {
390 self.findings
391 .iter()
392 .map(|f| f.kind.severity())
393 .max_by_key(|s| severity_ord(*s))
394 }
395
396 pub fn into_report(self) -> Report {
406 let mut report = Report::new(&self.target, &self.version).with_producer("dev-fuzz");
407 if self.findings.is_empty() {
408 report.push(
409 CheckResult::pass(format!("fuzz::{}", self.target))
410 .with_tag("fuzz")
411 .with_detail(format!("{} executions, 0 findings", self.executions))
412 .with_evidence(Evidence::numeric_int(
413 "executions",
414 i64::try_from(self.executions).unwrap_or(i64::MAX),
415 )),
416 );
417 } else {
418 for f in &self.findings {
419 let sev = f.kind.severity();
420 let mut check =
421 CheckResult::fail(format!("fuzz::{}::{}", self.target, f.kind.label()), sev)
422 .with_detail(f.summary.clone())
423 .with_tag("fuzz")
424 .with_tag(f.kind.label());
425 if !f.reproducer_path.starts_with('<') {
428 check =
429 check.with_evidence(Evidence::file_ref("reproducer", &f.reproducer_path));
430 }
431 report.push(check);
432 }
433 }
434 report.finish();
435 report
436 }
437}
438
439pub(crate) fn severity_ord(s: Severity) -> u8 {
440 match s {
441 Severity::Info => 0,
442 Severity::Warning => 1,
443 Severity::Error => 2,
444 Severity::Critical => 3,
445 }
446}
447
448#[derive(Debug)]
454pub enum FuzzError {
455 ToolNotInstalled,
457 NightlyRequired,
459 SubprocessFailed(String),
462 TargetNotFound(String),
464}
465
466impl std::fmt::Display for FuzzError {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 match self {
469 Self::ToolNotInstalled => write!(
470 f,
471 "cargo-fuzz is not installed; run `cargo install cargo-fuzz`"
472 ),
473 Self::NightlyRequired => write!(
474 f,
475 "nightly Rust required; run `rustup toolchain install nightly`"
476 ),
477 Self::SubprocessFailed(s) => write!(f, "cargo fuzz failed: {s}"),
478 Self::TargetNotFound(s) => write!(f, "fuzz target not found: {s}"),
479 }
480 }
481}
482
483impl std::error::Error for FuzzError {}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn finding_kind_severity_mapping_matches_reps() {
491 assert_eq!(FuzzFindingKind::Crash.severity(), Severity::Critical);
492 assert_eq!(FuzzFindingKind::OutOfMemory.severity(), Severity::Error);
493 assert_eq!(FuzzFindingKind::Timeout.severity(), Severity::Warning);
494 }
495
496 #[test]
497 fn finding_kind_labels_are_stable() {
498 assert_eq!(FuzzFindingKind::Crash.label(), "crash");
499 assert_eq!(FuzzFindingKind::OutOfMemory.label(), "oom");
500 assert_eq!(FuzzFindingKind::Timeout.label(), "timeout");
501 }
502
503 #[test]
504 fn budget_as_libfuzzer_flag() {
505 assert_eq!(
506 FuzzBudget::time(Duration::from_secs(60)).as_libfuzzer_flag(),
507 "-max_total_time=60"
508 );
509 assert_eq!(
510 FuzzBudget::time(Duration::from_millis(500)).as_libfuzzer_flag(),
511 "-max_total_time=1"
512 );
513 assert_eq!(
514 FuzzBudget::executions(1_000_000).as_libfuzzer_flag(),
515 "-runs=1000000"
516 );
517 }
518
519 #[test]
520 fn sanitizer_flag_values() {
521 assert_eq!(Sanitizer::Address.as_cargo_fuzz_flag(), "address");
522 assert_eq!(Sanitizer::Leak.as_cargo_fuzz_flag(), "leak");
523 assert_eq!(Sanitizer::Memory.as_cargo_fuzz_flag(), "memory");
524 assert_eq!(Sanitizer::Thread.as_cargo_fuzz_flag(), "thread");
525 assert_eq!(Sanitizer::None.as_cargo_fuzz_flag(), "none");
526 }
527
528 #[test]
529 fn run_builder_chains() {
530 let run = FuzzRun::new("parse", "0.1.0")
531 .budget(FuzzBudget::time(Duration::from_secs(30)))
532 .sanitizer(Sanitizer::Memory)
533 .timeout_per_iter(Duration::from_secs(5))
534 .rss_limit_mb(2048)
535 .allow("crash-deadbeef")
536 .allow_all(["crash-cafebabe", "timeout-abc"]);
537 assert_eq!(run.target_name(), "parse");
538 assert_eq!(run.subject_version(), "0.1.0");
539 assert_eq!(run.sanitizer_kind(), Sanitizer::Memory);
540 assert_eq!(run.rss_limit_value(), Some(2048));
541 assert_eq!(run.allow_list_view().len(), 3);
542 }
543
544 #[test]
545 fn empty_findings_passes_with_executions_evidence() {
546 let r = FuzzResult {
547 target: "parse".into(),
548 version: "0.1.0".into(),
549 executions: 1_000_000,
550 findings: Vec::new(),
551 };
552 let report = r.into_report();
553 assert!(report.passed());
554 assert_eq!(report.checks.len(), 1);
555 let c = &report.checks[0];
556 assert!(c.has_tag("fuzz"));
557 assert!(c.evidence.iter().any(|e| e.label == "executions"));
558 }
559
560 #[test]
561 fn unknown_reproducer_is_not_a_file_ref() {
562 let r = FuzzResult {
563 target: "parse".into(),
564 version: "0.1.0".into(),
565 executions: 1,
566 findings: vec![FuzzFinding {
567 kind: FuzzFindingKind::Crash,
568 reproducer_path: "<unknown reproducer for crash>".into(),
569 summary: "SUMMARY: ThreadSanitizer: data race".into(),
570 }],
571 };
572 let report = r.into_report();
573 assert!(report.failed());
574 assert!(report.checks[0].evidence.is_empty());
575 }
576
577 #[test]
578 fn huge_execution_count_is_clamped_in_evidence() {
579 let r = FuzzResult {
580 target: "parse".into(),
581 version: "0.1.0".into(),
582 executions: u64::MAX,
583 findings: Vec::new(),
584 };
585 let report = r.into_report();
586 let e = &report.checks[0].evidence[0];
587 assert_eq!(e.label, "executions");
588 match &e.data {
589 dev_report::EvidenceData::Numeric(v) => {
590 assert!(*v > 0.0, "executions must not wrap negative: {v}")
591 }
592 other => panic!("unexpected evidence {other:?}"),
593 }
594 }
595
596 #[test]
597 fn run_timeout_is_recorded() {
598 let run = FuzzRun::new("parse", "0.1.0").run_timeout(Duration::from_secs(900));
599 assert_eq!(run.run_timeout_value(), Some(Duration::from_secs(900)));
600 assert_eq!(FuzzRun::new("parse", "0.1.0").run_timeout_value(), None);
601 }
602
603 #[test]
604 fn crash_finding_is_critical() {
605 let r = FuzzResult {
606 target: "parse".into(),
607 version: "0.1.0".into(),
608 executions: 500,
609 findings: vec![FuzzFinding {
610 kind: FuzzFindingKind::Crash,
611 reproducer_path: "fuzz/artifacts/parse/crash-deadbeef".into(),
612 summary: "panic in parse_input".into(),
613 }],
614 };
615 let report = r.into_report();
616 assert!(report.failed());
617 assert_eq!(report.checks[0].severity, Some(Severity::Critical));
618 assert!(report.checks[0].has_tag("crash"));
619 }
620
621 #[test]
622 fn each_kind_produces_one_check() {
623 let r = FuzzResult {
624 target: "p".into(),
625 version: "0.1.0".into(),
626 executions: 10,
627 findings: vec![
628 FuzzFinding {
629 kind: FuzzFindingKind::Crash,
630 reproducer_path: "a".into(),
631 summary: "x".into(),
632 },
633 FuzzFinding {
634 kind: FuzzFindingKind::OutOfMemory,
635 reproducer_path: "b".into(),
636 summary: "x".into(),
637 },
638 FuzzFinding {
639 kind: FuzzFindingKind::Timeout,
640 reproducer_path: "c".into(),
641 summary: "x".into(),
642 },
643 ],
644 };
645 let report = r.into_report();
646 assert_eq!(report.checks.len(), 3);
647 assert!(report
648 .checks
649 .iter()
650 .any(|c| c.severity == Some(Severity::Critical)));
651 assert!(report
652 .checks
653 .iter()
654 .any(|c| c.severity == Some(Severity::Error)));
655 assert!(report
656 .checks
657 .iter()
658 .any(|c| c.severity == Some(Severity::Warning)));
659 }
660
661 #[test]
662 fn count_of_filters_by_kind() {
663 let r = FuzzResult {
664 target: "p".into(),
665 version: "0.1.0".into(),
666 executions: 0,
667 findings: vec![
668 FuzzFinding {
669 kind: FuzzFindingKind::Crash,
670 reproducer_path: "a".into(),
671 summary: "x".into(),
672 },
673 FuzzFinding {
674 kind: FuzzFindingKind::Crash,
675 reproducer_path: "b".into(),
676 summary: "x".into(),
677 },
678 FuzzFinding {
679 kind: FuzzFindingKind::Timeout,
680 reproducer_path: "c".into(),
681 summary: "x".into(),
682 },
683 ],
684 };
685 assert_eq!(r.count_of(FuzzFindingKind::Crash), 2);
686 assert_eq!(r.count_of(FuzzFindingKind::Timeout), 1);
687 assert_eq!(r.count_of(FuzzFindingKind::OutOfMemory), 0);
688 assert_eq!(r.total_findings(), 3);
689 }
690
691 #[test]
692 fn worst_severity_picks_max() {
693 let r = FuzzResult {
694 target: "p".into(),
695 version: "0.1.0".into(),
696 executions: 0,
697 findings: vec![
698 FuzzFinding {
699 kind: FuzzFindingKind::Timeout,
700 reproducer_path: "a".into(),
701 summary: "x".into(),
702 },
703 FuzzFinding {
704 kind: FuzzFindingKind::Crash,
705 reproducer_path: "b".into(),
706 summary: "x".into(),
707 },
708 ],
709 };
710 assert_eq!(r.worst_severity(), Some(Severity::Critical));
711 let empty = FuzzResult {
712 target: "p".into(),
713 version: "0.1.0".into(),
714 executions: 0,
715 findings: Vec::new(),
716 };
717 assert_eq!(empty.worst_severity(), None);
718 }
719
720 #[test]
721 fn result_round_trips_through_json() {
722 let r = FuzzResult {
723 target: "parse".into(),
724 version: "0.1.0".into(),
725 executions: 1234,
726 findings: vec![FuzzFinding {
727 kind: FuzzFindingKind::Crash,
728 reproducer_path: "fuzz/artifacts/parse/crash-1".into(),
729 summary: "panicked".into(),
730 }],
731 };
732 let s = serde_json::to_string(&r).unwrap();
733 let back: FuzzResult = serde_json::from_str(&s).unwrap();
734 assert_eq!(back.findings.len(), 1);
735 assert_eq!(back.findings[0].kind, FuzzFindingKind::Crash);
736 }
737}