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
38/// Version of this crate as compiled, taken from its `Cargo.toml`.
39///
40/// Lets tools that bundle this crate, such as the `dev` CLI in
41/// `dev-tools`, report the version that is actually linked.
42///
43/// # Example
44///
45/// ```
46/// assert!(!dev_fuzz::VERSION.is_empty());
47/// ```
48pub 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// ---------------------------------------------------------------------------
63// FuzzFindingKind
64// ---------------------------------------------------------------------------
65
66/// Type of finding discovered during a fuzz run.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum FuzzFindingKind {
70    /// A crash (panic, signal, libFuzzer "deadly signal").
71    Crash,
72    /// libFuzzer reported the input exceeded the per-execution timeout.
73    Timeout,
74    /// libFuzzer reported the input exceeded the configured memory limit.
75    OutOfMemory,
76}
77
78impl FuzzFindingKind {
79    /// Severity mapped from this finding kind per REPS § 3.
80    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    /// Lowercase short label (`crash`, `timeout`, `oom`). Stable: used
89    /// in `CheckResult` names and is suitable for log / metric keys.
90    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// ---------------------------------------------------------------------------
100// FuzzBudget
101// ---------------------------------------------------------------------------
102
103/// Budget for a fuzz run.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum FuzzBudget {
107    /// Run for the given wall-clock duration. Translates to
108    /// `-max_total_time=<secs>` on the libFuzzer side.
109    Time(Duration),
110    /// Run for the given number of executions. Translates to
111    /// `-runs=<N>` on the libFuzzer side.
112    Executions(u64),
113}
114
115impl FuzzBudget {
116    /// Build a time-based budget.
117    pub fn time(d: Duration) -> Self {
118        Self::Time(d)
119    }
120
121    /// Build an execution-count budget.
122    pub fn executions(n: u64) -> Self {
123        Self::Executions(n)
124    }
125
126    /// libFuzzer-style flag suffix for this budget.
127    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// ---------------------------------------------------------------------------
136// Sanitizer
137// ---------------------------------------------------------------------------
138
139/// Which sanitizer to enable on the fuzz target build.
140///
141/// `cargo-fuzz` accepts `address`, `leak`, `memory`, `thread`,
142/// `none`. We expose the most common four.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum Sanitizer {
146    /// AddressSanitizer (default in `cargo-fuzz`).
147    Address,
148    /// LeakSanitizer.
149    Leak,
150    /// MemorySanitizer.
151    Memory,
152    /// ThreadSanitizer.
153    Thread,
154    /// No sanitizer (faster, less informative).
155    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// ---------------------------------------------------------------------------
171// FuzzRun
172// ---------------------------------------------------------------------------
173
174/// Configuration for a fuzz run.
175///
176/// # Example
177///
178/// ```no_run
179/// use dev_fuzz::{FuzzBudget, FuzzRun, Sanitizer};
180/// use std::time::Duration;
181///
182/// let run = FuzzRun::new("parse_input", "0.1.0")
183///     .budget(FuzzBudget::time(Duration::from_secs(60)))
184///     .sanitizer(Sanitizer::Address)
185///     .timeout_per_iter(Duration::from_secs(5))
186///     .rss_limit_mb(2048);
187///
188/// let _result = run.execute().unwrap();
189/// ```
190#[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    /// Begin a new fuzz run against the given fuzz target.
205    ///
206    /// `target` is the libFuzzer target name (the file under
207    /// `fuzz/fuzz_targets/<target>.rs`). `version` is descriptive and
208    /// flows into the produced `Report`.
209    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    /// Set the run budget. Default: 60 seconds of wall-clock time.
224    pub fn budget(mut self, budget: FuzzBudget) -> Self {
225        self.budget = budget;
226        self
227    }
228
229    /// Selected budget.
230    pub fn fuzz_budget(&self) -> FuzzBudget {
231        self.budget
232    }
233
234    /// Run `cargo fuzz` from `dir` instead of the current directory.
235    pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
236        self.workdir = Some(dir.into());
237        self
238    }
239
240    /// Pick the sanitizer to enable. Default: `Sanitizer::Address`.
241    pub fn sanitizer(mut self, sanitizer: Sanitizer) -> Self {
242        self.sanitizer = sanitizer;
243        self
244    }
245
246    /// Per-iteration timeout. Translates to libFuzzer's `-timeout=<secs>`
247    /// (whole seconds, at least 1). libFuzzer's own default is 1200 s.
248    pub fn timeout_per_iter(mut self, d: Duration) -> Self {
249        self.timeout_per_iter = Some(d);
250        self
251    }
252
253    /// Per-iteration RSS limit, in megabytes. Translates to libFuzzer's
254    /// `-rss_limit_mb=<N>`.
255    pub fn rss_limit_mb(mut self, mb: u32) -> Self {
256        self.rss_limit_mb = Some(mb);
257        self
258    }
259
260    /// Suppress a finding whose reproducer-path basename matches `name`.
261    ///
262    /// Useful for known false positives that have a triaged reproducer
263    /// already on disk (e.g. `crash-deadbeef`). The match is on the
264    /// final path component only.
265    pub fn allow(mut self, name: impl Into<String>) -> Self {
266        self.allow_list.push(name.into());
267        self
268    }
269
270    /// Bulk version of [`allow`](Self::allow).
271    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    /// Kill `cargo fuzz` (and every process it started) if the whole run,
281    /// build included, takes longer than `limit`.
282    ///
283    /// The [`FuzzBudget`] is enforced by libFuzzer itself and only
284    /// between inputs, and it does not cover building the target. This
285    /// is a hard backstop on top of it: set it comfortably above the
286    /// budget plus build time. Findings written before the limit expired
287    /// are still returned; with none, [`execute`](Self::execute) returns
288    /// [`FuzzError::SubprocessFailed`] with a message that starts with
289    /// `timed out after`. Default: no limit.
290    ///
291    /// On Unix the child runs in its own process group while a limit is
292    /// set, so that the whole tree can be killed at once.
293    pub fn run_timeout(mut self, limit: Duration) -> Self {
294        self.run_timeout = Some(limit);
295        self
296    }
297
298    /// Target name (the `fuzz_targets/<name>.rs` file).
299    pub fn target_name(&self) -> &str {
300        &self.target
301    }
302
303    /// Descriptive subject version.
304    pub fn subject_version(&self) -> &str {
305        &self.version
306    }
307
308    /// Execute the fuzz run.
309    ///
310    /// Spawns `cargo +nightly fuzz run <target>` with the configured
311    /// budget, sanitizer, and limits. Captures stderr (where libFuzzer
312    /// writes its findings) and parses out crash / timeout / OOM
313    /// records with reproducer paths.
314    ///
315    /// Tool / nightly / target-not-found preconditions surface as
316    /// typed [`FuzzError`] variants. No panics.
317    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// ---------------------------------------------------------------------------
347// FuzzFinding + FuzzResult
348// ---------------------------------------------------------------------------
349
350/// A single fuzz finding.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct FuzzFinding {
353    /// Kind of finding (crash / timeout / OOM).
354    pub kind: FuzzFindingKind,
355    /// Path to the input that triggered the finding ("reproducer"), as
356    /// printed by libFuzzer. When libFuzzer's output names no artifact
357    /// for the finding this is a placeholder of the form
358    /// `<unknown reproducer for crash>` (it always starts with `<`).
359    pub reproducer_path: String,
360    /// Short human-readable summary captured from libFuzzer's output.
361    pub summary: String,
362}
363
364/// Result of a fuzz run.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct FuzzResult {
367    /// Fuzz target name.
368    pub target: String,
369    /// Subject version (descriptive).
370    pub version: String,
371    /// Total executions completed by libFuzzer.
372    pub executions: u64,
373    /// Findings discovered during the run.
374    pub findings: Vec<FuzzFinding>,
375}
376
377impl FuzzResult {
378    /// Total number of findings.
379    pub fn total_findings(&self) -> usize {
380        self.findings.len()
381    }
382
383    /// Number of findings whose [`kind`](FuzzFinding::kind) equals `kind`.
384    pub fn count_of(&self, kind: FuzzFindingKind) -> usize {
385        self.findings.iter().filter(|f| f.kind == kind).count()
386    }
387
388    /// Highest severity present across findings, if any.
389    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    /// Convert into a [`Report`].
397    ///
398    /// No findings → one passing `CheckResult` named
399    /// `fuzz::<target>` with `executions` numeric evidence. Otherwise
400    /// one failing `CheckResult` per finding named
401    /// `fuzz::<target>::<kind>` tagged `fuzz` plus a kind-specific tag
402    /// (`crash`, `timeout`, `oom`). Each finding's reproducer path
403    /// rides along as `Evidence::FileRef` labelled `reproducer`, unless
404    /// the path is the `<unknown reproducer for ...>` placeholder.
405    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                // A placeholder such as `<unknown reproducer for crash>` is
426                // not a file; keep it out of FileRef (and SARIF locations).
427                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// ---------------------------------------------------------------------------
449// FuzzError
450// ---------------------------------------------------------------------------
451
452/// Errors that can arise during a fuzz run.
453#[derive(Debug)]
454pub enum FuzzError {
455    /// `cargo-fuzz` is not installed on the system.
456    ToolNotInstalled,
457    /// The nightly Rust toolchain is required but not installed.
458    NightlyRequired,
459    /// Subprocess returned a fatal error (non-zero exit with no
460    /// recoverable output).
461    SubprocessFailed(String),
462    /// The named fuzz target was not found in the project.
463    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}