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)| 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}