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)| format!("{status} ×{count}"))
138            .collect();
139        if !statuses.is_empty() {
140            writeln!(formatter, "  answered {}", statuses.join(", "))?;
141        }
142        for skipped in &self.skipped {
143            writeln!(formatter, "  {skipped}")?;
144        }
145        for (number, finding) in self.findings.iter().enumerate() {
146            write!(formatter, "\n{}. {finding}", number + 1)?;
147        }
148        Ok(())
149    }
150}
151
152fn excerpt(body: &str) -> String {
153    if body.is_empty() {
154        return "(empty)".to_owned();
155    }
156    if body.len() <= SHOWN_BODY {
157        return body.to_owned();
158    }
159    let end = body.floor_char_boundary(SHOWN_BODY);
160    let mut shown = body[..end].to_owned();
161    let _ = write!(shown, "… ({} bytes)", body.len());
162    shown
163}
164
165#[cfg(test)]
166mod tests {
167    use std::collections::BTreeMap;
168
169    use super::{Finding, FuzzReport, Outcome};
170
171    fn finding(label: &str, status: u16) -> Finding {
172        Finding {
173            route: "order.customer.place_order".to_owned(),
174            method: "POST",
175            path: "/account/orders".to_owned(),
176            answer: "json",
177            signed_in: true,
178            input: Some((
179                label.to_owned(),
180                format!("{{\"total\":\"{}\"}}", "9".repeat(400)),
181            )),
182            outcome: Outcome::Status(status, "Internal Server Error".to_owned()),
183            trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_owned()),
184            trees: vec!["request POST /account/orders 3.10 ms\n  handler 2.00 ms".to_owned()],
185            log: vec!["2026-10-13T09:00:00Z ERROR sofie_http: the handler failed".to_owned()],
186            repeats: 0,
187        }
188    }
189
190    #[test]
191    fn a_finding_names_the_route_its_input_status_and_trace() {
192        let mut report = FuzzReport {
193            seed: 42,
194            sent: 300,
195            routes: 5,
196            statuses: BTreeMap::from([(200, 120), (500, 3)]),
197            skipped: vec!["order.admin.list: reached signed out only".to_owned()],
198            findings: Vec::new(),
199        };
200        report.add(finding("oversized total", 500));
201        report.add(finding("oversized total", 500));
202        report.add(finding("missing total", 500));
203
204        let text = report.to_string();
205
206        assert_eq!(report.findings.len(), 2);
207        assert!(
208            text.starts_with(
209                "sofie fuzz: 300 requests to 5 routes, seed 42: 2 findings\n  \
210                 answered 200 ×120, 500 ×3\n  order.admin.list: reached signed out only\n\n1. POST /account/orders \
211                 (order.customer.place_order) answered 500 Internal Server Error as json, signed in\n  \
212                 input: oversized total: {\"total\":\"999"
213            ),
214            "{text}"
215        );
216        assert!(
217            text.contains("… (412 bytes)\n  and 1 more like it\n"),
218            "{text}"
219        );
220        assert!(
221            text.contains(
222                "  trace 4bf92f3577b34da6a3ce929d0e0e4736\n    request POST /account/orders \
223                 3.10 ms\n      handler 2.00 ms\n  log:\n    2026-10-13T09:00:00Z ERROR"
224            ),
225            "{text}"
226        );
227        assert!(text.contains("\n2. POST /account/orders"), "{text}");
228    }
229
230    #[test]
231    fn reaching_another_member_s_record_is_its_own_finding() {
232        let mut report = FuzzReport {
233            seed: 42,
234            sent: 2,
235            routes: 1,
236            statuses: BTreeMap::new(),
237            skipped: Vec::new(),
238            findings: Vec::new(),
239        };
240        let mut reached = finding("oversized total", 500);
241        reached.outcome = Outcome::ForeignRecord(200, "OK".to_owned());
242        report.add(finding("oversized total", 500));
243        report.add(reached);
244
245        assert_eq!(report.findings.len(), 2);
246        assert!(
247            report
248                .to_string()
249                .contains("answered 200 OK for another member's record as json, signed in"),
250            "{report}"
251        );
252    }
253}