use std::time::{
SystemTime,
UNIX_EPOCH,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Pass,
Fail,
Inconclusive,
}
impl Verdict {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Verdict::Pass => "pass",
Verdict::Fail => "fail",
Verdict::Inconclusive => "inconclusive",
}
}
#[must_use]
pub fn from_statistic(statistic: Option<f64>, abs_threshold: f64) -> Self {
match statistic {
Some(t) if t.is_finite() => {
if t.abs() < abs_threshold {
Verdict::Pass
} else {
Verdict::Fail
}
}
_ => Verdict::Inconclusive,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Channel {
WallClockTiming,
IngestedTrace,
}
impl Channel {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Channel::WallClockTiming => "wall_clock_timing",
Channel::IngestedTrace => "ingested_trace",
}
}
}
#[derive(Clone, Debug)]
pub struct EnvironmentInfo {
pub os: &'static str,
pub arch: &'static str,
pub pointer_width: u32,
pub harness_version: &'static str,
pub timestamp_unix: u64,
}
impl EnvironmentInfo {
#[must_use]
pub fn capture() -> Self {
let timestamp_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self {
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
pointer_width: (usize::BITS),
harness_version: env!("CARGO_PKG_VERSION"),
timestamp_unix,
}
}
fn to_json(&self) -> String {
format!(
"{{\"os\":{},\"arch\":{},\"pointer_width\":{},\"harness_version\":{},\"timestamp_unix\":{}}}",
json_string(self.os),
json_string(self.arch),
self.pointer_width,
json_string(self.harness_version),
self.timestamp_unix,
)
}
}
#[derive(Clone, Debug)]
pub struct EvaluationReport {
pub target: String,
pub channel: Channel,
pub samples_per_class: usize,
pub t_statistic: Option<f64>,
pub abs_t_threshold: f64,
pub verdict: Verdict,
pub notes: String,
}
impl EvaluationReport {
#[must_use]
pub fn new(
target: impl Into<String>,
channel: Channel,
samples_per_class: usize,
t_statistic: Option<f64>,
abs_t_threshold: f64,
notes: impl Into<String>,
) -> Self {
let verdict = Verdict::from_statistic(t_statistic, abs_t_threshold);
Self {
target: target.into(),
channel,
samples_per_class,
t_statistic,
abs_t_threshold,
verdict,
notes: notes.into(),
}
}
#[must_use]
pub fn to_json(&self) -> String {
format!(
"{{\"target\":{},\"channel\":{},\"samples_per_class\":{},\"t_statistic\":{},\"abs_t_threshold\":{},\"verdict\":{},\"notes\":{}}}",
json_string(&self.target),
json_string(self.channel.as_str()),
self.samples_per_class,
json_number(self.t_statistic),
json_f64(self.abs_t_threshold),
json_string(self.verdict.as_str()),
json_string(&self.notes),
)
}
#[must_use]
pub fn to_markdown_row(&self) -> String {
let t = match self.t_statistic {
Some(v) if v.is_finite() => format!("{v:.4}"),
_ => "n/a".to_string(),
};
format!(
"| `{}` | {} | {} | {} | {:.2} | {} |",
self.target,
self.channel.as_str(),
self.samples_per_class,
t,
self.abs_t_threshold,
self.verdict.as_str(),
)
}
}
#[derive(Clone, Debug)]
pub struct SelfCertReport {
pub environment: EnvironmentInfo,
pub reports: Vec<EvaluationReport>,
}
impl SelfCertReport {
#[must_use]
pub fn new() -> Self {
Self {
environment: EnvironmentInfo::capture(),
reports: Vec::new(),
}
}
pub fn push(&mut self, report: EvaluationReport) {
self.reports.push(report);
}
#[must_use]
pub fn all_pass(&self) -> bool {
!self.reports.is_empty() && self.reports.iter().all(|r| r.verdict == Verdict::Pass)
}
#[must_use]
pub fn count(&self, verdict: Verdict) -> usize {
self.reports.iter().filter(|r| r.verdict == verdict).count()
}
#[must_use]
pub fn to_json(&self) -> String {
let mut entries = String::new();
for (idx, report) in self.reports.iter().enumerate() {
if idx > 0 {
entries.push(',');
}
entries.push_str(&report.to_json());
}
format!(
"{{\"schema\":\"libq.sca.self-cert.v1\",\"environment\":{},\"reports\":[{}]}}",
self.environment.to_json(),
entries,
)
}
#[must_use]
pub fn to_markdown(&self) -> String {
let mut out = String::new();
out.push_str("# libQ side-channel self-certification report\n\n");
out.push_str(&format!(
"- Schema: `libq.sca.self-cert.v1`\n- OS/arch: `{}`/`{}` ({}-bit)\n- Harness: `lib-q-sca-test {}`\n- Timestamp (Unix): `{}`\n\n",
self.environment.os,
self.environment.arch,
self.environment.pointer_width,
self.environment.harness_version,
self.environment.timestamp_unix,
));
out.push_str(&format!(
"Summary: {} pass, {} fail, {} inconclusive ({} total).\n\n",
self.count(Verdict::Pass),
self.count(Verdict::Fail),
self.count(Verdict::Inconclusive),
self.reports.len(),
));
out.push_str("| Target | Channel | Samples/class | \\|t\\| stat | Threshold | Verdict |\n");
out.push_str("|--------|---------|--------------:|----------:|----------:|---------|\n");
for report in &self.reports {
out.push_str(&report.to_markdown_row());
out.push('\n');
}
out.push_str(
"\nWall-clock timing screens are pre-laboratory regression evidence, not an \
independent side-channel evaluation. See `docs/sca-self-certification.md`.\n",
);
out
}
}
impl Default for SelfCertReport {
fn default() -> Self {
Self::new()
}
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn json_f64(value: f64) -> String {
if value.is_finite() {
format!("{value}")
} else {
"null".to_string()
}
}
fn json_number(value: Option<f64>) -> String {
match value {
Some(v) => json_f64(v),
None => "null".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_thresholding() {
assert_eq!(Verdict::from_statistic(Some(1.0), 4.5), Verdict::Pass);
assert_eq!(Verdict::from_statistic(Some(-1.0), 4.5), Verdict::Pass);
assert_eq!(Verdict::from_statistic(Some(4.5), 4.5), Verdict::Fail);
assert_eq!(Verdict::from_statistic(Some(9.0), 4.5), Verdict::Fail);
assert_eq!(Verdict::from_statistic(None, 4.5), Verdict::Inconclusive);
assert_eq!(
Verdict::from_statistic(Some(f64::NAN), 4.5),
Verdict::Inconclusive
);
}
#[test]
fn report_json_is_well_formed_single_line() {
let r = EvaluationReport::new(
"lib-q-ml-kem:decapsulate",
Channel::WallClockTiming,
1024,
Some(2.5),
4.5,
"fixed vs random dk/ct",
);
let json = r.to_json();
assert!(json.starts_with('{') && json.ends_with('}'));
assert!(!json.contains('\n'));
assert!(json.contains("\"verdict\":\"pass\""));
assert!(json.contains("\"channel\":\"wall_clock_timing\""));
}
#[test]
fn non_finite_statistic_serializes_as_null() {
let r = EvaluationReport::new("x", Channel::IngestedTrace, 0, Some(f64::INFINITY), 4.5, "");
assert!(r.to_json().contains("\"t_statistic\":null"));
assert_eq!(r.verdict, Verdict::Inconclusive);
}
#[test]
fn json_string_escapes_control_and_quotes() {
let s = json_string("a\"b\\c\nd\te");
assert_eq!(s, "\"a\\\"b\\\\c\\nd\\te\"");
}
#[test]
fn battery_all_pass_requires_nonempty_and_all_pass() {
let mut battery = SelfCertReport::new();
assert!(!battery.all_pass(), "empty battery is not a pass");
battery.push(EvaluationReport::new(
"a",
Channel::WallClockTiming,
8,
Some(1.0),
4.5,
"",
));
assert!(battery.all_pass());
battery.push(EvaluationReport::new(
"b",
Channel::WallClockTiming,
8,
None,
4.5,
"",
));
assert!(!battery.all_pass(), "inconclusive entry blocks all_pass");
assert_eq!(battery.count(Verdict::Inconclusive), 1);
}
#[test]
fn battery_markdown_and_json_render() {
let mut battery = SelfCertReport::new();
battery.push(EvaluationReport::new(
"lib-q-ml-dsa:sign",
Channel::WallClockTiming,
512,
Some(3.1),
4.5,
"fixed vs random signing key",
));
let md = battery.to_markdown();
assert!(md.contains("self-certification report"));
assert!(md.contains("lib-q-ml-dsa:sign"));
let json = battery.to_json();
assert!(json.contains("\"schema\":\"libq.sca.self-cert.v1\""));
assert!(json.contains("\"reports\":["));
}
}