Skip to main content

sidecheck_core/
export.rs

1//! Result export — CSV with raw measurements (for anyone to independently
2//! re-check the statistics) and a JSON report (for CI/automation).
3
4use crate::report::DetectionReport;
5use crate::sampler::RawSamples;
6use crate::stats::BOOTSTRAP_ITERATIONS;
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::io::Write;
10use std::path::Path;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13pub fn write_csv(path: &Path, raw: &RawSamples) -> Result<()> {
14    let mut file = std::fs::File::create(path)
15        .with_context(|| format!("failed to create {}", path.display()))?;
16
17    writeln!(file, "class,elapsed_seconds")?;
18    for v in &raw.class_a {
19        writeln!(file, "a,{v:.9}")?;
20    }
21    for v in &raw.class_b {
22        writeln!(file, "b,{v:.9}")?;
23    }
24
25    Ok(())
26}
27
28/// Machine-readable report for CI/automation/bug bounty attachments.
29/// Always carries the sidecheck version and seed — a year from now the
30/// statistics algorithm may have changed, and without a version an old
31/// report becomes unclear about what to trust.
32#[derive(Serialize, Deserialize)]
33pub struct JsonReport {
34    pub target: String,
35    pub injection_point: String,
36    pub samples_per_class: usize,
37    pub jitter_ms: f64,
38    pub estimated_leak_us: f64,
39    pub significant: bool,
40    pub bootstrap_confidence: f64,
41    pub bootstrap_iterations: usize,
42    pub ci_low_us: f64,
43    pub ci_high_us: f64,
44    pub failed_requests: usize,
45    pub seed: u64,
46    pub timestamp_unix: u64,
47    pub sidecheck_version: String,
48}
49
50impl JsonReport {
51    pub fn from_detection(report: &DetectionReport) -> Self {
52        Self {
53            target: report.target.clone(),
54            injection_point: report.field.clone(),
55            samples_per_class: report.samples_per_class,
56            jitter_ms: report.jitter_seconds * 1000.0,
57            estimated_leak_us: report.result.estimated_leak * 1_000_000.0,
58            significant: report.result.is_significant(),
59            bootstrap_confidence: report.result.confidence,
60            bootstrap_iterations: BOOTSTRAP_ITERATIONS,
61            ci_low_us: report.result.ci_low * 1_000_000.0,
62            ci_high_us: report.result.ci_high * 1_000_000.0,
63            failed_requests: report.failures,
64            seed: report.seed,
65            timestamp_unix: SystemTime::now()
66                .duration_since(UNIX_EPOCH)
67                .map(|d| d.as_secs())
68                .unwrap_or(0),
69            sidecheck_version: report.sidecheck_version.clone(),
70        }
71    }
72}
73
74pub fn write_json(path: &Path, report: &DetectionReport) -> Result<()> {
75    let json_report = JsonReport::from_detection(report);
76    let text =
77        serde_json::to_string_pretty(&json_report).context("failed to serialize report to JSON")?;
78    std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
79    Ok(())
80}
81
82/// Reads back a report previously written by `write_json` — used by
83/// `sidecheck compare` to load both sides of a baseline/current
84/// comparison. Deliberately lenient about `sidecheck_version`: an older
85/// report from a previous release should still load, since comparing
86/// across versions (the whole point of a CI baseline) is the normal
87/// case, not an error condition.
88pub fn read_json(path: &Path) -> Result<JsonReport> {
89    let text = std::fs::read_to_string(path)
90        .with_context(|| format!("failed to read {}", path.display()))?;
91    serde_json::from_str(&text)
92        .with_context(|| format!("{} is not a valid sidecheck JSON report", path.display()))
93}