Skip to main content

browser_forensic_core/
timestamp.rs

1// crates/browser-forensic-core/src/timestamp.rs
2
3pub use forensicnomicon::heuristics::{CORE_DATA_EPOCH_OFFSET_SECS, WEBKIT_EPOCH_OFFSET_US};
4pub use forensicnomicon::temporal::FILETIME_EPOCH_OFFSET;
5
6// All conversions use saturating arithmetic: untrusted browser artifacts can
7// carry extreme integer values, and a parser must clamp rather than panic on
8// overflow (never-panic invariant — CLAUDE.md robustness).
9
10pub fn webkit_micros_to_unix_nanos(webkit_us: i64) -> i64 {
11    webkit_us
12        .saturating_sub(WEBKIT_EPOCH_OFFSET_US)
13        .saturating_mul(1_000)
14}
15
16pub fn core_data_secs_to_unix_nanos(core_data_secs: f64) -> i64 {
17    ((core_data_secs as i64).saturating_add(CORE_DATA_EPOCH_OFFSET_SECS))
18        .saturating_mul(1_000_000_000)
19}
20
21pub fn unix_micros_to_nanos(us: i64) -> i64 {
22    us.saturating_mul(1_000)
23}
24
25pub fn unix_millis_to_nanos(ms: i64) -> i64 {
26    ms.saturating_mul(1_000_000)
27}
28
29pub fn unix_secs_to_nanos(secs: i64) -> i64 {
30    secs.saturating_mul(1_000_000_000)
31}
32
33/// Convert Unix epoch **seconds** carried as a floating-point value (Chromium's
34/// `TransportSecurity` `sts_observed` / `expiry`) into Unix nanoseconds,
35/// preserving sub-second precision without hand-rolling epoch math.
36pub fn unix_secs_f64_to_nanos(secs: f64) -> i64 {
37    let whole = secs.trunc() as i64;
38    let frac_ns = (secs.fract() * 1_000_000_000.0) as i64;
39    whole * 1_000_000_000 + frac_ns
40}
41
42/// Convert a Windows `FILETIME` (100 ns ticks since 1601-01-01 UTC) into Unix
43/// nanoseconds. Used by the IE / Edge-Legacy WebCache (ESE) parser, whose
44/// `Container_#` timestamp columns (`AccessedTime`, `ModifiedTime`,
45/// `CreationTime`, `ExpiryTime`, …) are little-endian FILETIMEs.
46///
47/// Reuses [`FILETIME_EPOCH_OFFSET`] (forensicnomicon) rather than hand-rolling
48/// the 1601→1970 epoch math. Computation is done in `i128` and clamped to
49/// `i64`, so an adversarial WebCache record carrying an extreme FILETIME (or a
50/// `0` "not set" value, which pre-dates 1970) clamps rather than
51/// overflow-panics (never-panic invariant for parsers).
52#[must_use]
53pub fn filetime_to_unix_nanos(ft: u64) -> i64 {
54    let ticks_since_unix = i128::from(ft) - i128::from(FILETIME_EPOCH_OFFSET);
55    let nanos = ticks_since_unix * 100;
56    i64::try_from(nanos).unwrap_or(if nanos.is_negative() {
57        i64::MIN
58    } else {
59        i64::MAX
60    })
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn webkit_epoch_at_unix_epoch_returns_zero() {
69        // WebKit epoch value that represents 1970-01-01 00:00:00 UTC
70        let webkit_us = WEBKIT_EPOCH_OFFSET_US;
71        assert_eq!(webkit_micros_to_unix_nanos(webkit_us), 0);
72    }
73
74    #[test]
75    fn webkit_one_second_after_unix_epoch() {
76        let webkit_us = WEBKIT_EPOCH_OFFSET_US + 1_000_000;
77        assert_eq!(webkit_micros_to_unix_nanos(webkit_us), 1_000_000_000);
78    }
79
80    #[test]
81    fn core_data_epoch_at_unix_offset_returns_negative_secs() {
82        // 0.0 in Core Data = 2001-01-01 = 978307200 seconds after Unix epoch
83        assert_eq!(
84            core_data_secs_to_unix_nanos(0.0),
85            CORE_DATA_EPOCH_OFFSET_SECS * 1_000_000_000
86        );
87    }
88
89    #[test]
90    fn core_data_one_second_later() {
91        assert_eq!(
92            core_data_secs_to_unix_nanos(1.0),
93            (CORE_DATA_EPOCH_OFFSET_SECS + 1) * 1_000_000_000
94        );
95    }
96
97    #[test]
98    fn unix_micros_to_nanos_multiplies_by_1000() {
99        assert_eq!(unix_micros_to_nanos(1_000_000), 1_000_000_000);
100        assert_eq!(unix_micros_to_nanos(0), 0);
101    }
102
103    #[test]
104    fn unix_millis_to_nanos_multiplies_by_1_000_000() {
105        assert_eq!(unix_millis_to_nanos(1_000), 1_000_000_000);
106    }
107
108    #[test]
109    fn unix_secs_to_nanos_multiplies_by_1_000_000_000() {
110        assert_eq!(unix_secs_to_nanos(1), 1_000_000_000);
111    }
112
113    #[test]
114    fn conversions_saturate_on_adversarial_input_never_panic() {
115        // Untrusted artifacts can carry extreme integers; conversion must clamp,
116        // never overflow-panic (never-panic invariant for parsers).
117        assert_eq!(webkit_micros_to_unix_nanos(i64::MAX), i64::MAX);
118        assert_eq!(webkit_micros_to_unix_nanos(i64::MIN), i64::MIN);
119        assert_eq!(unix_secs_to_nanos(i64::MAX), i64::MAX);
120        assert_eq!(unix_millis_to_nanos(i64::MIN), i64::MIN);
121        assert_eq!(unix_micros_to_nanos(i64::MAX), i64::MAX);
122    }
123
124    #[test]
125    fn unix_secs_f64_preserves_subsecond() {
126        assert_eq!(unix_secs_f64_to_nanos(1.0), 1_000_000_000);
127        assert_eq!(
128            unix_secs_f64_to_nanos(1_700_000_000.5),
129            1_700_000_000_500_000_000
130        );
131        assert_eq!(unix_secs_f64_to_nanos(0.0), 0);
132    }
133
134    #[test]
135    fn filetime_at_unix_epoch_returns_zero() {
136        // FILETIME == epoch offset represents 1970-01-01 00:00:00 UTC.
137        assert_eq!(filetime_to_unix_nanos(FILETIME_EPOCH_OFFSET), 0);
138    }
139
140    #[test]
141    fn filetime_one_second_after_unix_epoch() {
142        // +1 second = +10,000,000 ticks of 100 ns.
143        let ft = FILETIME_EPOCH_OFFSET + 10_000_000;
144        assert_eq!(filetime_to_unix_nanos(ft), 1_000_000_000);
145    }
146
147    #[test]
148    fn filetime_preserves_100ns_precision() {
149        // One 100 ns tick past the epoch = 100 ns, not truncated to a second.
150        let ft = FILETIME_EPOCH_OFFSET + 1;
151        assert_eq!(filetime_to_unix_nanos(ft), 100);
152    }
153
154    #[test]
155    fn filetime_known_value_2023_01_01() {
156        // 2023-01-01T00:00:00Z: Unix secs 1_672_531_200.
157        // FILETIME = (1_672_531_200 + 11_644_473_600) * 10_000_000.
158        let ft = (1_672_531_200u64 + 11_644_473_600u64) * 10_000_000;
159        assert_eq!(filetime_to_unix_nanos(ft), 1_672_531_200_000_000_000);
160    }
161
162    #[test]
163    fn filetime_adversarial_extremes_never_panic() {
164        // Untrusted WebCache records can carry extreme FILETIMEs; conversion
165        // must clamp, never overflow-panic (never-panic invariant for parsers).
166        assert_eq!(filetime_to_unix_nanos(u64::MAX), i64::MAX);
167        // FILETIME 0 ("not set") pre-dates 1970 by far → clamps to i64::MIN.
168        assert_eq!(filetime_to_unix_nanos(0), i64::MIN);
169    }
170}