use std::fmt;
use serde::Deserialize;
use serde::Serialize;
pub const DISPATCH_STATS_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct DispatchCounters {
pub signal_traps: Option<u64>,
pub patched_direct_calls: Option<u64>,
pub ptrace_seccomp_stops: Option<u64>,
pub ptrace_sigtrap_stops: Option<u64>,
pub sigill_marker_hits: Option<u64>,
pub ptrace_syscall_entry_stops: Option<u64>,
pub ptrace_syscall_exit_stops: Option<u64>,
pub refusals: Option<u64>,
}
impl DispatchCounters {
pub const ZERO: Self = Self {
signal_traps: Some(0),
patched_direct_calls: Some(0),
ptrace_seccomp_stops: Some(0),
ptrace_sigtrap_stops: Some(0),
sigill_marker_hits: Some(0),
ptrace_syscall_entry_stops: Some(0),
ptrace_syscall_exit_stops: Some(0),
refusals: Some(0),
};
pub fn dispatches(&self) -> Option<u64> {
sum_all([self.patched_direct_calls, self.trapped_dispatches()])
}
pub fn trapped_dispatches(&self) -> Option<u64> {
sum_all([
self.signal_traps,
self.ptrace_seccomp_stops,
self.ptrace_sigtrap_stops,
self.sigill_marker_hits,
])
}
fn fields(&self) -> [(&'static str, Option<u64>); 8] {
[
("signal_traps", self.signal_traps),
("patched_direct_calls", self.patched_direct_calls),
("ptrace_seccomp_stops", self.ptrace_seccomp_stops),
("ptrace_sigtrap_stops", self.ptrace_sigtrap_stops),
("sigill_marker_hits", self.sigill_marker_hits),
(
"ptrace_syscall_entry_stops",
self.ptrace_syscall_entry_stops,
),
("ptrace_syscall_exit_stops", self.ptrace_syscall_exit_stops),
("refusals", self.refusals),
]
}
}
fn sum_all<const N: usize>(values: [Option<u64>; N]) -> Option<u64> {
values
.into_iter()
.try_fold(0_u64, |total, value| value.map(|value| total + value))
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct SiteCounters {
pub candidates: Option<u64>,
pub patched: Option<u64>,
pub fell_back: Option<u64>,
}
impl SiteCounters {
pub const NONE_PATCHED: Self = Self {
candidates: Some(0),
patched: Some(0),
fell_back: Some(0),
};
pub fn from_rewrite(candidates: u64, patched: u64) -> Self {
Self {
candidates: Some(candidates),
patched: Some(patched),
fell_back: Some(candidates.saturating_sub(patched)),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProcessDispatchStats {
pub process: u32,
pub counters: DispatchCounters,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DispatchStats {
pub schema_version: u32,
pub backend: String,
pub counters: DispatchCounters,
pub sites: SiteCounters,
pub per_process: Option<Vec<ProcessDispatchStats>>,
}
impl DispatchStats {
pub fn new(backend: &str, counters: DispatchCounters, sites: SiteCounters) -> Self {
Self {
schema_version: DISPATCH_STATS_SCHEMA_VERSION,
backend: backend.to_owned(),
counters,
sites,
per_process: None,
}
}
pub fn with_per_process(
mut self,
processes: impl IntoIterator<Item = ProcessDispatchStats>,
) -> Self {
let mut processes: Vec<_> = processes.into_iter().collect();
processes.sort_by_key(|process| process.process);
self.per_process = Some(processes);
self
}
pub fn inconsistencies(&self) -> Vec<String> {
let mut problems = Vec::new();
if self.schema_version != DISPATCH_STATS_SCHEMA_VERSION {
problems.push(format!(
"schema_version {} is not {DISPATCH_STATS_SCHEMA_VERSION}",
self.schema_version
));
}
if let Some(candidates) = self.sites.candidates {
let patched = self.sites.patched.unwrap_or(0);
let fell_back = self.sites.fell_back.unwrap_or(0);
if patched + fell_back > candidates {
problems.push(format!(
"sites: patched {patched} + fell_back {fell_back} exceeds candidates {candidates}"
));
}
}
if let Some(processes) = &self.per_process {
if processes
.windows(2)
.any(|pair| pair[0].process >= pair[1].process)
{
problems.push("per_process indices are not unique and sorted".to_owned());
}
for (index, (name, aggregate)) in self.counters.fields().into_iter().enumerate() {
let attributed: Vec<_> = processes
.iter()
.map(|process| process.counters.fields()[index].1)
.collect();
if attributed.iter().all(Option::is_none) {
continue;
}
let Some(sum) = attributed
.iter()
.try_fold(0_u64, |total, value| value.map(|value| total + value))
else {
problems.push(format!("{name} is attributed to only some processes"));
continue;
};
if aggregate != Some(sum) {
problems.push(format!(
"{name}: per-process sum {sum} does not match aggregate {}",
Measured(aggregate)
));
}
}
}
problems
}
}
struct Measured(Option<u64>);
impl fmt::Display for Measured {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(value) => write!(formatter, "{value}"),
None => formatter.write_str("n/a"),
}
}
}
impl fmt::Display for DispatchCounters {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, (name, value)) in self.fields().into_iter().enumerate() {
if index != 0 {
formatter.write_str(" ")?;
}
write!(formatter, "{name}={}", Measured(value))?;
}
Ok(())
}
}
impl fmt::Display for DispatchStats {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"dispatch stats v{} backend={} dispatches={} trapped={} direct={} [{}] sites[candidates={} patched={} fell_back={}]",
self.schema_version,
self.backend,
Measured(self.counters.dispatches()),
Measured(self.counters.trapped_dispatches()),
Measured(self.counters.patched_direct_calls),
self.counters,
Measured(self.sites.candidates),
Measured(self.sites.patched),
Measured(self.sites.fell_back),
)?;
match &self.per_process {
Some(processes) => write!(formatter, " processes={}", processes.len()),
None => formatter.write_str(" processes=n/a"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn process(process: u32, seccomp: u64) -> ProcessDispatchStats {
ProcessDispatchStats {
process,
counters: DispatchCounters {
ptrace_seccomp_stops: Some(seccomp),
..DispatchCounters::default()
},
}
}
fn ptrace_like(seccomp: u64) -> DispatchStats {
DispatchStats::new(
"ptrace",
DispatchCounters {
ptrace_seccomp_stops: Some(seccomp),
..DispatchCounters::ZERO
},
SiteCounters::NONE_PATCHED,
)
}
#[test]
fn dispatch_totals_separate_trapped_from_direct_routes() {
let counters = DispatchCounters {
signal_traps: Some(3),
patched_direct_calls: Some(40),
ptrace_seccomp_stops: Some(2),
ptrace_sigtrap_stops: Some(0),
sigill_marker_hits: Some(1),
ptrace_syscall_entry_stops: Some(100),
ptrace_syscall_exit_stops: Some(100),
refusals: Some(5),
};
assert_eq!(counters.trapped_dispatches(), Some(6));
assert_eq!(counters.dispatches(), Some(46));
assert_eq!(DispatchCounters::default().dispatches(), None);
let partial = DispatchCounters {
ptrace_sigtrap_stops: None,
..counters
};
assert_eq!(partial.trapped_dispatches(), None);
assert_eq!(partial.dispatches(), None);
assert_eq!(partial.patched_direct_calls, Some(40));
}
#[test]
fn unmeasured_counters_render_distinctly_from_zero() {
let record = DispatchStats::new(
"e9patch",
DispatchCounters::default(),
SiteCounters {
candidates: Some(0),
patched: Some(0),
fell_back: Some(0),
},
);
let rendered = record.to_string();
assert!(rendered.contains("signal_traps=n/a"), "{rendered}");
assert!(rendered.contains("dispatches=n/a"), "{rendered}");
assert!(rendered.contains("candidates=0"), "{rendered}");
assert!(rendered.ends_with("processes=n/a"), "{rendered}");
}
#[test]
fn json_round_trip_keeps_unmeasured_counters_as_null() {
let record = ptrace_like(7).with_per_process([process(20, 4), process(10, 3)]);
let json = serde_json::to_value(&record).unwrap();
assert_eq!(json["schema_version"], DISPATCH_STATS_SCHEMA_VERSION);
assert_eq!(json["counters"]["ptrace_seccomp_stops"], 7);
assert_eq!(json["per_process"][0]["process"], 10);
assert!(json["per_process"][0]["counters"]["signal_traps"].is_null());
let decoded: DispatchStats = serde_json::from_value(json).unwrap();
assert_eq!(decoded, record);
}
#[test]
fn consistent_record_reports_no_problems() {
let record = ptrace_like(7).with_per_process([process(10, 3), process(20, 4)]);
assert_eq!(record.inconsistencies(), Vec::<String>::new());
}
#[test]
fn per_process_sum_mismatch_is_reported() {
let record = ptrace_like(8).with_per_process([process(10, 3), process(20, 4)]);
assert_eq!(
record.inconsistencies(),
["ptrace_seccomp_stops: per-process sum 7 does not match aggregate 8"]
);
}
#[test]
fn partially_attributed_counter_is_reported() {
let mut unattributed = process(20, 4);
unattributed.counters.ptrace_seccomp_stops = None;
let record = ptrace_like(7).with_per_process([process(10, 3), unattributed]);
assert_eq!(
record.inconsistencies(),
["ptrace_seccomp_stops is attributed to only some processes"]
);
}
#[test]
fn duplicate_process_and_excess_sites_are_reported() {
let mut record = ptrace_like(7).with_per_process([process(10, 3), process(10, 4)]);
record.sites = SiteCounters {
candidates: Some(2),
patched: Some(2),
fell_back: Some(1),
};
assert_eq!(
record.inconsistencies(),
[
"sites: patched 2 + fell_back 1 exceeds candidates 2",
"per_process indices are not unique and sorted",
]
);
}
}