1use std::fmt;
22
23use serde::Deserialize;
24use serde::Serialize;
25
26pub const DISPATCH_STATS_SCHEMA_VERSION: u32 = 1;
31
32#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
49pub struct DispatchCounters {
50 pub signal_traps: Option<u64>,
52 pub patched_direct_calls: Option<u64>,
55 pub ptrace_seccomp_stops: Option<u64>,
57 pub ptrace_sigtrap_stops: Option<u64>,
59 pub sigill_marker_hits: Option<u64>,
62 pub ptrace_syscall_entry_stops: Option<u64>,
64 pub ptrace_syscall_exit_stops: Option<u64>,
66 pub refusals: Option<u64>,
68}
69
70impl DispatchCounters {
71 pub const ZERO: Self = Self {
73 signal_traps: Some(0),
74 patched_direct_calls: Some(0),
75 ptrace_seccomp_stops: Some(0),
76 ptrace_sigtrap_stops: Some(0),
77 sigill_marker_hits: Some(0),
78 ptrace_syscall_entry_stops: Some(0),
79 ptrace_syscall_exit_stops: Some(0),
80 refusals: Some(0),
81 };
82
83 pub fn dispatches(&self) -> Option<u64> {
89 sum_all([self.patched_direct_calls, self.trapped_dispatches()])
90 }
91
92 pub fn trapped_dispatches(&self) -> Option<u64> {
95 sum_all([
96 self.signal_traps,
97 self.ptrace_seccomp_stops,
98 self.ptrace_sigtrap_stops,
99 self.sigill_marker_hits,
100 ])
101 }
102
103 fn fields(&self) -> [(&'static str, Option<u64>); 8] {
104 [
105 ("signal_traps", self.signal_traps),
106 ("patched_direct_calls", self.patched_direct_calls),
107 ("ptrace_seccomp_stops", self.ptrace_seccomp_stops),
108 ("ptrace_sigtrap_stops", self.ptrace_sigtrap_stops),
109 ("sigill_marker_hits", self.sigill_marker_hits),
110 (
111 "ptrace_syscall_entry_stops",
112 self.ptrace_syscall_entry_stops,
113 ),
114 ("ptrace_syscall_exit_stops", self.ptrace_syscall_exit_stops),
115 ("refusals", self.refusals),
116 ]
117 }
118}
119
120fn sum_all<const N: usize>(values: [Option<u64>; N]) -> Option<u64> {
121 values
122 .into_iter()
123 .try_fold(0_u64, |total, value| value.map(|value| total + value))
124}
125
126#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
134pub struct SiteCounters {
135 pub candidates: Option<u64>,
137 pub patched: Option<u64>,
139 pub fell_back: Option<u64>,
141}
142
143impl SiteCounters {
144 pub const NONE_PATCHED: Self = Self {
146 candidates: Some(0),
147 patched: Some(0),
148 fell_back: Some(0),
149 };
150
151 pub fn from_rewrite(candidates: u64, patched: u64) -> Self {
154 Self {
155 candidates: Some(candidates),
156 patched: Some(patched),
157 fell_back: Some(candidates.saturating_sub(patched)),
158 }
159 }
160}
161
162#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
164pub struct ProcessDispatchStats {
165 pub process: u32,
170 pub counters: DispatchCounters,
172}
173
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub struct DispatchStats {
177 pub schema_version: u32,
179 pub backend: String,
181 pub counters: DispatchCounters,
183 pub sites: SiteCounters,
185 pub per_process: Option<Vec<ProcessDispatchStats>>,
190}
191
192impl DispatchStats {
193 pub fn new(backend: &str, counters: DispatchCounters, sites: SiteCounters) -> Self {
195 Self {
196 schema_version: DISPATCH_STATS_SCHEMA_VERSION,
197 backend: backend.to_owned(),
198 counters,
199 sites,
200 per_process: None,
201 }
202 }
203
204 pub fn with_per_process(
206 mut self,
207 processes: impl IntoIterator<Item = ProcessDispatchStats>,
208 ) -> Self {
209 let mut processes: Vec<_> = processes.into_iter().collect();
210 processes.sort_by_key(|process| process.process);
211 self.per_process = Some(processes);
212 self
213 }
214
215 pub fn inconsistencies(&self) -> Vec<String> {
221 let mut problems = Vec::new();
222 if self.schema_version != DISPATCH_STATS_SCHEMA_VERSION {
223 problems.push(format!(
224 "schema_version {} is not {DISPATCH_STATS_SCHEMA_VERSION}",
225 self.schema_version
226 ));
227 }
228 if let Some(candidates) = self.sites.candidates {
229 let patched = self.sites.patched.unwrap_or(0);
230 let fell_back = self.sites.fell_back.unwrap_or(0);
231 if patched + fell_back > candidates {
232 problems.push(format!(
233 "sites: patched {patched} + fell_back {fell_back} exceeds candidates {candidates}"
234 ));
235 }
236 }
237 if let Some(processes) = &self.per_process {
238 if processes
239 .windows(2)
240 .any(|pair| pair[0].process >= pair[1].process)
241 {
242 problems.push("per_process indices are not unique and sorted".to_owned());
243 }
244 for (index, (name, aggregate)) in self.counters.fields().into_iter().enumerate() {
245 let attributed: Vec<_> = processes
246 .iter()
247 .map(|process| process.counters.fields()[index].1)
248 .collect();
249 if attributed.iter().all(Option::is_none) {
250 continue;
251 }
252 let Some(sum) = attributed
253 .iter()
254 .try_fold(0_u64, |total, value| value.map(|value| total + value))
255 else {
256 problems.push(format!("{name} is attributed to only some processes"));
257 continue;
258 };
259 if aggregate != Some(sum) {
260 problems.push(format!(
261 "{name}: per-process sum {sum} does not match aggregate {}",
262 Measured(aggregate)
263 ));
264 }
265 }
266 }
267 problems
268 }
269}
270
271struct Measured(Option<u64>);
273
274impl fmt::Display for Measured {
275 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276 match self.0 {
277 Some(value) => write!(formatter, "{value}"),
278 None => formatter.write_str("n/a"),
279 }
280 }
281}
282
283impl fmt::Display for DispatchCounters {
284 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285 for (index, (name, value)) in self.fields().into_iter().enumerate() {
286 if index != 0 {
287 formatter.write_str(" ")?;
288 }
289 write!(formatter, "{name}={}", Measured(value))?;
290 }
291 Ok(())
292 }
293}
294
295impl fmt::Display for DispatchStats {
296 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297 write!(
298 formatter,
299 "dispatch stats v{} backend={} dispatches={} trapped={} direct={} [{}] sites[candidates={} patched={} fell_back={}]",
300 self.schema_version,
301 self.backend,
302 Measured(self.counters.dispatches()),
303 Measured(self.counters.trapped_dispatches()),
304 Measured(self.counters.patched_direct_calls),
305 self.counters,
306 Measured(self.sites.candidates),
307 Measured(self.sites.patched),
308 Measured(self.sites.fell_back),
309 )?;
310 match &self.per_process {
311 Some(processes) => write!(formatter, " processes={}", processes.len()),
312 None => formatter.write_str(" processes=n/a"),
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 fn process(process: u32, seccomp: u64) -> ProcessDispatchStats {
322 ProcessDispatchStats {
323 process,
324 counters: DispatchCounters {
325 ptrace_seccomp_stops: Some(seccomp),
326 ..DispatchCounters::default()
327 },
328 }
329 }
330
331 fn ptrace_like(seccomp: u64) -> DispatchStats {
332 DispatchStats::new(
333 "ptrace",
334 DispatchCounters {
335 ptrace_seccomp_stops: Some(seccomp),
336 ..DispatchCounters::ZERO
337 },
338 SiteCounters::NONE_PATCHED,
339 )
340 }
341
342 #[test]
343 fn dispatch_totals_separate_trapped_from_direct_routes() {
344 let counters = DispatchCounters {
345 signal_traps: Some(3),
346 patched_direct_calls: Some(40),
347 ptrace_seccomp_stops: Some(2),
348 ptrace_sigtrap_stops: Some(0),
349 sigill_marker_hits: Some(1),
350 ptrace_syscall_entry_stops: Some(100),
351 ptrace_syscall_exit_stops: Some(100),
352 refusals: Some(5),
353 };
354 assert_eq!(counters.trapped_dispatches(), Some(6));
355 assert_eq!(counters.dispatches(), Some(46));
356 assert_eq!(DispatchCounters::default().dispatches(), None);
357 let partial = DispatchCounters {
358 ptrace_sigtrap_stops: None,
359 ..counters
360 };
361 assert_eq!(partial.trapped_dispatches(), None);
362 assert_eq!(partial.dispatches(), None);
363 assert_eq!(partial.patched_direct_calls, Some(40));
364 }
365
366 #[test]
367 fn unmeasured_counters_render_distinctly_from_zero() {
368 let record = DispatchStats::new(
369 "e9patch",
370 DispatchCounters::default(),
371 SiteCounters {
372 candidates: Some(0),
373 patched: Some(0),
374 fell_back: Some(0),
375 },
376 );
377 let rendered = record.to_string();
378 assert!(rendered.contains("signal_traps=n/a"), "{rendered}");
379 assert!(rendered.contains("dispatches=n/a"), "{rendered}");
380 assert!(rendered.contains("candidates=0"), "{rendered}");
381 assert!(rendered.ends_with("processes=n/a"), "{rendered}");
382 }
383
384 #[test]
385 fn json_round_trip_keeps_unmeasured_counters_as_null() {
386 let record = ptrace_like(7).with_per_process([process(20, 4), process(10, 3)]);
387 let json = serde_json::to_value(&record).unwrap();
388 assert_eq!(json["schema_version"], DISPATCH_STATS_SCHEMA_VERSION);
389 assert_eq!(json["counters"]["ptrace_seccomp_stops"], 7);
390 assert_eq!(json["per_process"][0]["process"], 10);
391 assert!(json["per_process"][0]["counters"]["signal_traps"].is_null());
392 let decoded: DispatchStats = serde_json::from_value(json).unwrap();
393 assert_eq!(decoded, record);
394 }
395
396 #[test]
397 fn consistent_record_reports_no_problems() {
398 let record = ptrace_like(7).with_per_process([process(10, 3), process(20, 4)]);
399 assert_eq!(record.inconsistencies(), Vec::<String>::new());
400 }
401
402 #[test]
403 fn per_process_sum_mismatch_is_reported() {
404 let record = ptrace_like(8).with_per_process([process(10, 3), process(20, 4)]);
405 assert_eq!(
406 record.inconsistencies(),
407 ["ptrace_seccomp_stops: per-process sum 7 does not match aggregate 8"]
408 );
409 }
410
411 #[test]
412 fn partially_attributed_counter_is_reported() {
413 let mut unattributed = process(20, 4);
414 unattributed.counters.ptrace_seccomp_stops = None;
415 let record = ptrace_like(7).with_per_process([process(10, 3), unattributed]);
416 assert_eq!(
417 record.inconsistencies(),
418 ["ptrace_seccomp_stops is attributed to only some processes"]
419 );
420 }
421
422 #[test]
423 fn duplicate_process_and_excess_sites_are_reported() {
424 let mut record = ptrace_like(7).with_per_process([process(10, 3), process(10, 4)]);
425 record.sites = SiteCounters {
426 candidates: Some(2),
427 patched: Some(2),
428 fell_back: Some(1),
429 };
430 assert_eq!(
431 record.inconsistencies(),
432 [
433 "sites: patched 2 + fell_back 1 exceeds candidates 2",
434 "per_process indices are not unique and sorted",
435 ]
436 );
437 }
438}