keyhog 0.5.43

keyhog detects leaked credentials in source trees, git history, archives, and remote sources
Documentation
//! Statistical timing evidence persisted by autoroute calibration.

use keyhog_core::timing::{midpoint_u128, two_sided_95_student_t_critical};
use serde::{Deserialize, Serialize};
use std::time::Duration;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct BackendTimingEvidence {
    pub(crate) trials_ns: Vec<u128>,
}

impl BackendTimingEvidence {
    pub(crate) fn add_to_first_trial(mut self, overhead_ns: u128) -> Self {
        if let Some(first) = self.trials_ns.first_mut() {
            *first = first.saturating_add(overhead_ns);
        }
        self
    }

    pub(crate) fn from_durations(durations: Vec<Duration>) -> Option<Self> {
        let trials_ns = durations.into_iter().map(|dur| dur.as_nanos()).collect();
        Self::from_trial_ns(trials_ns)
    }

    #[cfg(test)]
    pub(crate) fn constant_ms(ms: u128, trials: usize) -> Self {
        let trials_ns = vec![ms.saturating_mul(1_000_000); trials.max(1)];
        match Self::from_trial_ns(trials_ns) {
            Some(evidence) => evidence,
            None => unreachable!("a non-empty trial set always yields timing evidence"),
        }
    }

    pub(crate) fn from_trial_ns(trials_ns: Vec<u128>) -> Option<Self> {
        if trials_ns.is_empty() {
            return None;
        }
        Some(Self { trials_ns })
    }

    pub(crate) fn median_ns(&self) -> u128 {
        let mut trials = self.trials_ns.clone();
        trials.sort_unstable();
        let middle = trials.len() / 2;
        if trials.len() % 2 == 1 {
            trials[middle]
        } else {
            midpoint_u128(trials[middle - 1], trials[middle])
        }
    }

    pub(crate) fn median_ms(&self) -> u128 {
        self.median_ns() / 1_000_000
    }

    pub(crate) fn confidence_interval_95_ns(&self) -> TimingConfidenceInterval {
        TimingConfidenceInterval::from_trials(&self.trials_ns)
    }

    pub(crate) fn is_valid_for_trials(&self, expected_trials: usize) -> bool {
        self.trials_ns.len() == expected_trials && self.trials_ns.iter().all(|&trial| trial > 0)
    }
}

pub(crate) fn paired_candidate_is_faster_95(
    candidate_trials_ns: &[u128],
    competitor_trials_ns: &[u128],
) -> bool {
    let count = candidate_trials_ns.len().min(competitor_trials_ns.len());
    if count < 2 {
        return false;
    }
    let paired_differences = || {
        candidate_trials_ns
            .iter()
            .zip(competitor_trials_ns)
            .take(count)
            .map(|(&candidate, &competitor)| competitor as f64 - candidate as f64)
    };
    let count_f64 = count as f64;
    let mean = paired_differences().sum::<f64>() / count_f64;
    let variance = paired_differences()
        .map(|difference| {
            let delta = difference - mean;
            delta * delta
        })
        .sum::<f64>()
        / (count_f64 - 1.0);
    let half_width = two_sided_95_student_t_critical(count) * variance.sqrt() / count_f64.sqrt();
    mean - half_width > 0.0
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct TimingConfidenceInterval {
    pub(crate) low_ns: u128,
    pub(crate) high_ns: u128,
}

impl TimingConfidenceInterval {
    fn from_trials(trials_ns: &[u128]) -> Self {
        let count = trials_ns.len() as f64;
        let mean = trials_ns.iter().map(|&ns| ns as f64).sum::<f64>() / count;
        let variance = if trials_ns.len() > 1 {
            trials_ns
                .iter()
                .map(|&ns| {
                    let delta = ns as f64 - mean;
                    delta * delta
                })
                .sum::<f64>()
                / (count - 1.0)
        } else {
            0.0
        };
        let half_width =
            two_sided_95_student_t_critical(trials_ns.len()) * variance.sqrt() / count.sqrt();
        Self {
            low_ns: (mean - half_width).max(0.0).floor() as u128,
            high_ns: (mean + half_width).ceil() as u128,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::paired_candidate_is_faster_95;

    #[test]
    fn paired_difference_separates_shared_host_drift() {
        let candidate = [100, 300, 120, 280, 140, 260, 160];
        let competitor = [110, 310, 130, 290, 150, 270, 170];
        assert!(paired_candidate_is_faster_95(&candidate, &competitor));
    }

    #[test]
    fn paired_difference_rejects_ties_and_reversed_routes() {
        let candidate = [100, 300, 120, 280, 140, 260, 160];
        assert!(!paired_candidate_is_faster_95(&candidate, &candidate));
        let faster_competitor = [90, 290, 110, 270, 130, 250, 150];
        assert!(!paired_candidate_is_faster_95(
            &candidate,
            &faster_competitor
        ));
    }
}