Skip to main content

stackforge_core/anonymize/
timestamp.rs

1//! Order-preserving timestamp anonymization.
2//!
3//! Shifts all timestamps by a random epoch offset and optionally adds
4//! bounded per-timestamp jitter. The epoch offset is generated once per
5//! engine session, so relative durations and ordering are preserved.
6
7use std::time::Duration;
8
9use rand::Rng;
10use rand::SeedableRng;
11use rand::rngs::StdRng;
12
13/// Timestamp anonymizer with epoch shift and optional jitter.
14#[derive(Debug)]
15pub struct TimestampAnonymizer {
16    /// Fixed offset applied to all timestamps.
17    epoch_offset: Duration,
18    /// Maximum per-timestamp jitter in milliseconds (0 = no jitter).
19    jitter_ms: u32,
20    /// RNG for jitter generation (only used if `jitter_ms > 0`).
21    rng: StdRng,
22}
23
24impl TimestampAnonymizer {
25    /// Create a new anonymizer with epoch shift only.
26    pub fn epoch_shift_only(rng: &mut StdRng) -> Self {
27        // Random offset: 30-365 days into the future
28        let offset_secs: u64 = rng.random_range(30 * 86400..365 * 86400);
29        Self {
30            epoch_offset: Duration::from_secs(offset_secs),
31            jitter_ms: 0,
32            rng: StdRng::from_os_rng(),
33        }
34    }
35
36    /// Create a new anonymizer with epoch shift and bounded jitter.
37    pub fn with_jitter(jitter_ms: u32, rng: &mut StdRng) -> Self {
38        let offset_secs: u64 = rng.random_range(30 * 86400..365 * 86400);
39        Self {
40            epoch_offset: Duration::from_secs(offset_secs),
41            jitter_ms,
42            rng: StdRng::from_os_rng(),
43        }
44    }
45
46    /// Anonymize a single timestamp.
47    ///
48    /// Applies the epoch offset and optional jitter.
49    pub fn anonymize(&mut self, ts: Duration) -> Duration {
50        let shifted = ts + self.epoch_offset;
51        if self.jitter_ms == 0 {
52            return shifted;
53        }
54        let jitter = Duration::from_millis(
55            self.rng.random_range(0..=u64::from(self.jitter_ms)),
56        );
57        shifted + jitter
58    }
59
60    /// The fixed epoch offset applied to all timestamps.
61    #[must_use]
62    pub fn epoch_offset(&self) -> Duration {
63        self.epoch_offset
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use rand::SeedableRng;
71
72    #[test]
73    fn test_epoch_shift_preserves_ordering() {
74        let mut rng = StdRng::seed_from_u64(42);
75        let mut anon = TimestampAnonymizer::epoch_shift_only(&mut rng);
76
77        let t1 = Duration::from_secs(100);
78        let t2 = Duration::from_secs(200);
79        let t3 = Duration::from_secs(300);
80
81        let a1 = anon.anonymize(t1);
82        let a2 = anon.anonymize(t2);
83        let a3 = anon.anonymize(t3);
84
85        assert!(a1 < a2);
86        assert!(a2 < a3);
87    }
88
89    #[test]
90    fn test_epoch_shift_preserves_duration() {
91        let mut rng = StdRng::seed_from_u64(42);
92        let mut anon = TimestampAnonymizer::epoch_shift_only(&mut rng);
93
94        let t1 = Duration::from_secs(100);
95        let t2 = Duration::from_secs(200);
96
97        let a1 = anon.anonymize(t1);
98        let a2 = anon.anonymize(t2);
99
100        // Without jitter, duration is perfectly preserved
101        assert_eq!(a2 - a1, t2 - t1);
102    }
103
104    #[test]
105    fn test_offset_is_positive() {
106        let mut rng = StdRng::seed_from_u64(42);
107        let anon = TimestampAnonymizer::epoch_shift_only(&mut rng);
108        // At least 30 days
109        assert!(anon.epoch_offset() >= Duration::from_secs(30 * 86400));
110    }
111
112    #[test]
113    fn test_jitter_adds_noise() {
114        let mut rng = StdRng::seed_from_u64(42);
115        let mut anon = TimestampAnonymizer::with_jitter(10, &mut rng);
116        let ts = Duration::from_secs(100);
117
118        // Collect multiple anonymizations to check variance
119        let results: Vec<Duration> = (0..100).map(|_| anon.anonymize(ts)).collect();
120
121        // Not all results should be identical (probabilistically guaranteed)
122        let first = results[0];
123        assert!(results.iter().any(|&r| r != first));
124    }
125}