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 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 pub input: Option<(String, String)>,
24 pub outcome: Outcome,
25 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 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}