Skip to main content

sofie_cli/fuzz/
report.rs

1use std::collections::BTreeMap;
2use std::fmt::{self, Write as _};
3
4const SHOWN_BODY: usize = 300;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum Outcome {
8    Status(u16, String),
9    /// A success to a signed-in member asking for another member's record.
10    ForeignRecord(u16, String),
11    NoAnswer(String),
12    Exited(String),
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct Finding {
17    pub route: String,
18    pub method: &'static str,
19    pub path: String,
20    pub answer: &'static str,
21    pub signed_in: bool,
22    /// The case's label and the body sent.
23    pub input: Option<(String, String)>,
24    pub outcome: Outcome,
25    /// None when the app answered nothing.
26    pub trace_id: Option<String>,
27    pub trees: Vec<String>,
28    pub log: Vec<String>,
29    pub repeats: usize,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct FuzzReport {
34    pub seed: u64,
35    pub sent: usize,
36    pub routes: usize,
37    pub statuses: BTreeMap<u16, usize>,
38    pub skipped: Vec<String>,
39    pub findings: Vec<Finding>,
40}
41
42impl Outcome {
43    fn kind(&self) -> String {
44        match self {
45            Self::Status(status, _) => status.to_string(),
46            Self::ForeignRecord(..) => "foreign record".to_owned(),
47            Self::NoAnswer(_) => "no answer".to_owned(),
48            Self::Exited(_) => "exited".to_owned(),
49        }
50    }
51}
52
53impl Finding {
54    fn label(&self) -> &str {
55        self.input.as_ref().map_or("", |(label, _)| label.as_str())
56    }
57}
58
59impl FuzzReport {
60    /// One finding per route, outcome and kind of input; the others count.
61    pub(crate) fn add(&mut self, finding: Finding) {
62        let same = self.findings.iter_mut().find(|found| {
63            found.route == finding.route
64                && found.outcome.kind() == finding.outcome.kind()
65                && found.label() == finding.label()
66        });
67        match same {
68            Some(found) => found.repeats += 1,
69            None => self.findings.push(finding),
70        }
71    }
72}
73
74impl fmt::Display for Outcome {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Status(status, reason) => write!(formatter, "answered {status} {reason}"),
78            Self::ForeignRecord(status, reason) => write!(
79                formatter,
80                "answered {status} {reason} for another member's record"
81            ),
82            Self::NoAnswer(error) => write!(formatter, "answered nothing ({error})"),
83            Self::Exited(status) => write!(formatter, "crashed the app ({status})"),
84        }
85    }
86}
87
88impl fmt::Display for Finding {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        let principal = if self.signed_in {
91            "signed in"
92        } else {
93            "signed out"
94        };
95        writeln!(
96            formatter,
97            "{} {} ({}) {} as {}, {principal}",
98            self.method, self.path, self.route, self.outcome, self.answer
99        )?;
100        if let Some((label, body)) = &self.input {
101            writeln!(formatter, "  input: {label}: {}", excerpt(body))?;
102        }
103        if self.repeats > 0 {
104            writeln!(formatter, "  and {} more like it", self.repeats)?;
105        }
106        if let Some(trace_id) = &self.trace_id {
107            writeln!(formatter, "  trace {trace_id}")?;
108        }
109        for line in self.trees.iter().flat_map(|tree| tree.lines()) {
110            writeln!(formatter, "    {line}")?;
111        }
112        if !self.log.is_empty() {
113            writeln!(formatter, "  log:")?;
114        }
115        for line in &self.log {
116            writeln!(formatter, "    {line}")?;
117        }
118        Ok(())
119    }
120}
121
122impl fmt::Display for FuzzReport {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        let findings = match self.findings.len() {
125            0 => "no 500, panic or foreign record reached".to_owned(),
126            1 => "1 finding".to_owned(),
127            count => format!("{count} findings"),
128        };
129        writeln!(
130            formatter,
131            "sofie fuzz: {} requests to {} routes, seed {}: {findings}",
132            self.sent, self.routes, self.seed
133        )?;
134        let statuses: Vec<String> = self
135            .statuses
136            .iter()
137            .map(|(status, count)| match status {
138                429 => format!("{status} ×{count} (a route's throttle, expected)"),
139                _ => format!("{status} ×{count}"),
140            })
141            .collect();
142        if !statuses.is_empty() {
143            writeln!(formatter, "  answered {}", statuses.join(", "))?;
144        }
145        for skipped in &self.skipped {
146            writeln!(formatter, "  {skipped}")?;
147        }
148        for (number, finding) in self.findings.iter().enumerate() {
149            write!(formatter, "\n{}. {finding}", number + 1)?;
150        }
151        Ok(())
152    }
153}
154
155fn excerpt(body: &str) -> String {
156    if body.is_empty() {
157        return "(empty)".to_owned();
158    }
159    if body.len() <= SHOWN_BODY {
160        return body.to_owned();
161    }
162    let end = body.floor_char_boundary(SHOWN_BODY);
163    let mut shown = body[..end].to_owned();
164    let _ = write!(shown, "… ({} bytes)", body.len());
165    shown
166}
167
168#[cfg(test)]
169mod tests {
170    use std::collections::BTreeMap;
171
172    use super::{Finding, FuzzReport, Outcome};
173
174    fn finding(label: &str, status: u16) -> Finding {
175        Finding {
176            route: "order.customer.place_order".to_owned(),
177            method: "POST",
178            path: "/account/orders".to_owned(),
179            answer: "json",
180            signed_in: true,
181            input: Some((
182                label.to_owned(),
183                format!("{{\"total\":\"{}\"}}", "9".repeat(400)),
184            )),
185            outcome: Outcome::Status(status, "Internal Server Error".to_owned()),
186            trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_owned()),
187            trees: vec!["request POST /account/orders 3.10 ms\n  handler 2.00 ms".to_owned()],
188            log: vec!["2026-10-13T09:00:00Z ERROR sofie_http: the handler failed".to_owned()],
189            repeats: 0,
190        }
191    }
192
193    #[test]
194    fn a_finding_names_the_route_its_input_status_and_trace() {
195        let mut report = FuzzReport {
196            seed: 42,
197            sent: 300,
198            routes: 5,
199            statuses: BTreeMap::from([(200, 120), (429, 9), (500, 3)]),
200            skipped: vec!["order.admin.list: reached signed out only".to_owned()],
201            findings: Vec::new(),
202        };
203        report.add(finding("oversized total", 500));
204        report.add(finding("oversized total", 500));
205        report.add(finding("missing total", 500));
206
207        let text = report.to_string();
208
209        assert_eq!(report.findings.len(), 2);
210        assert!(
211            text.starts_with(
212                "sofie fuzz: 300 requests to 5 routes, seed 42: 2 findings\n  \
213                 answered 200 ×120, 429 ×9 (a route's throttle, expected), 500 ×3\n  order.admin.list: reached signed out only\n\n1. POST /account/orders \
214                 (order.customer.place_order) answered 500 Internal Server Error as json, signed in\n  \
215                 input: oversized total: {\"total\":\"999"
216            ),
217            "{text}"
218        );
219        assert!(
220            text.contains("… (412 bytes)\n  and 1 more like it\n"),
221            "{text}"
222        );
223        assert!(
224            text.contains(
225                "  trace 4bf92f3577b34da6a3ce929d0e0e4736\n    request POST /account/orders \
226                 3.10 ms\n      handler 2.00 ms\n  log:\n    2026-10-13T09:00:00Z ERROR"
227            ),
228            "{text}"
229        );
230        assert!(text.contains("\n2. POST /account/orders"), "{text}");
231    }
232
233    #[test]
234    fn reaching_another_member_s_record_is_its_own_finding() {
235        let mut report = FuzzReport {
236            seed: 42,
237            sent: 2,
238            routes: 1,
239            statuses: BTreeMap::new(),
240            skipped: Vec::new(),
241            findings: Vec::new(),
242        };
243        let mut reached = finding("oversized total", 500);
244        reached.outcome = Outcome::ForeignRecord(200, "OK".to_owned());
245        report.add(finding("oversized total", 500));
246        report.add(reached);
247
248        assert_eq!(report.findings.len(), 2);
249        assert!(
250            report
251                .to_string()
252                .contains("answered 200 OK for another member's record as json, signed in"),
253            "{report}"
254        );
255    }
256}