Skip to main content

pixellint_core/
timestamp.rs

1//! Exact timestamp conversion for deterministic event age checks.
2
3use serde::{Deserialize, Serialize};
4
5/// The time representation a vendor accepts for an event timestamp.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum TimestampUnit {
9    Seconds,
10    Milliseconds,
11    Microseconds,
12    /// Modern Unix timestamps with magnitude at least 10^12 are milliseconds.
13    SecondsOrMilliseconds,
14    Datetime,
15    /// A documented UTC local timestamp. An omitted zone means UTC.
16    DatetimeUtc,
17}
18
19/// Capture one clock value for a validation operation. The JS wrapper supplies
20/// Date.now through validate_at, since this target has no native wall clock.
21#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
22pub(crate) fn current_unix_seconds() -> i64 {
23    use std::time::{SystemTime, UNIX_EPOCH};
24    match SystemTime::now().duration_since(UNIX_EPOCH) {
25        Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
26        Err(error) => {
27            let duration = error.duration();
28            let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX);
29            seconds
30                .saturating_neg()
31                .saturating_sub(i64::from(duration.subsec_nanos() > 0))
32        }
33    }
34}
35
36#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
37pub(crate) fn current_unix_seconds() -> i64 {
38    panic!("this target has no native wall clock; supply Unix seconds with validate_at")
39}
40
41pub(crate) fn timestamp_millis(value: &str, unit: TimestampUnit) -> Option<i64> {
42    if unit == TimestampUnit::Datetime {
43        return datetime_millis(value);
44    }
45    if unit == TimestampUnit::DatetimeUtc {
46        return datetime_millis(value).or_else(|| datetime_millis(&format!("{value}Z")));
47    }
48    let (negative, digits, exponent) = decimal_parts(value)?;
49    let scale = match unit {
50        TimestampUnit::Seconds => 3,
51        TimestampUnit::Milliseconds => 0,
52        TimestampUnit::Microseconds => -3,
53        TimestampUnit::SecondsOrMilliseconds => {
54            if digits != "0" && i64::try_from(digits.len()).ok()?.checked_add(exponent)? >= 13 {
55                0
56            } else {
57                3
58            }
59        }
60        TimestampUnit::Datetime | TimestampUnit::DatetimeUtc => unreachable!(),
61    };
62    let exponent = exponent.checked_add(scale)?;
63    if !(0..=19).contains(&exponent) || digits.len().checked_add(exponent as usize)? > 19 {
64        return None;
65    }
66    let mut millis = digits.parse::<i128>().ok()?;
67    millis = millis.checked_mul(10_i128.checked_pow(exponent as u32)?)?;
68    if negative {
69        millis = millis.checked_neg()?;
70    }
71    i64::try_from(millis).ok()
72}
73
74/// Normalize JSON decimal syntax without a float or a bounded significand.
75fn decimal_parts(value: &str) -> Option<(bool, &str, i64)> {
76    let negative = value.starts_with('-');
77    let unsigned = value.strip_prefix('-').unwrap_or(value);
78    let (significand, exponent) = match unsigned.split_once(['e', 'E']) {
79        Some((significand, exponent)) => {
80            let digits = exponent.strip_prefix(['+', '-']).unwrap_or(exponent);
81            if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
82                return None;
83            }
84            (significand, exponent.parse::<i64>().ok()?)
85        }
86        None => (unsigned, 0),
87    };
88    // Numeric event timestamps are integers. Integral exponent notation is
89    // accepted without losing the original precision.
90    if significand.is_empty()
91        || !significand.bytes().all(|byte| byte.is_ascii_digit())
92        || (significand.len() > 1 && significand.starts_with('0'))
93    {
94        return None;
95    }
96    let digits = significand.trim_start_matches('0');
97    if digits.is_empty() {
98        return Some((false, "0", 0));
99    }
100    let stripped = digits.trim_end_matches('0');
101    let exponent = exponent.checked_add(i64::try_from(digits.len() - stripped.len()).ok()?)?;
102    Some((negative, stripped, exponent))
103}
104
105fn datetime_millis(value: &str) -> Option<i64> {
106    if !value.is_ascii() {
107        return None;
108    }
109    let normalized = value.replacen(' ', "T", 1);
110    if !crate::manifest::valid_datetime(&normalized, false, false, true) {
111        return None;
112    }
113    let (date, time) = normalized.split_once(['T', 't'])?;
114    let (year, month, day) = if date.len() == 8 {
115        (
116            date[..4].parse::<i64>().ok()?,
117            date[4..6].parse::<i64>().ok()?,
118            date[6..].parse::<i64>().ok()?,
119        )
120    } else {
121        (
122            date[..4].parse::<i64>().ok()?,
123            date[5..7].parse::<i64>().ok()?,
124            date[8..].parse::<i64>().ok()?,
125        )
126    };
127    let (hour, minute, second, mut rest) = if time.as_bytes().get(2) == Some(&b':') {
128        (
129            time[..2].parse::<i64>().ok()?,
130            time[3..5].parse::<i64>().ok()?,
131            time[6..8].parse::<i64>().ok()?,
132            &time[8..],
133        )
134    } else {
135        (
136            time[..2].parse::<i64>().ok()?,
137            time[2..4].parse::<i64>().ok()?,
138            time[4..6].parse::<i64>().ok()?,
139            &time[6..],
140        )
141    };
142    let mut fraction_millis = 0;
143    if let Some(fraction) = rest.strip_prefix('.') {
144        let count = fraction.bytes().take_while(u8::is_ascii_digit).count();
145        let first = &fraction[..count.min(3)];
146        // Epoch milliseconds discard smaller units deterministically.
147        fraction_millis = first.parse::<i64>().ok()? * 10_i64.pow((3 - first.len()) as u32);
148        rest = &fraction[count..];
149    }
150    let offset_seconds = if rest.is_empty() || rest == "Z" || rest == "z" {
151        0
152    } else {
153        let hours = rest[1..3].parse::<i64>().ok()?;
154        let minutes = if rest.len() == 5 {
155            &rest[3..5]
156        } else {
157            &rest[4..6]
158        }
159        .parse::<i64>()
160        .ok()?;
161        (hours * 3600 + minutes * 60) * if rest.starts_with('-') { -1 } else { 1 }
162    };
163    // Gregorian calendar conversion. March starts each arithmetic year so leap
164    // days occur at its end; the era division also handles year zero.
165    let year = year - i64::from(month <= 2);
166    let era = year.div_euclid(400);
167    let year_of_era = year - era * 400;
168    let month = month + if month > 2 { -3 } else { 9 };
169    let day_of_year = (153 * month + 2) / 5 + day - 1;
170    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
171    let days = era * 146097 + day_of_era - 719468;
172    let seconds = days
173        .checked_mul(86400)?
174        .checked_add(hour * 3600 + minute * 60 + second)?
175        .checked_sub(offset_seconds)?;
176    seconds.checked_mul(1000)?.checked_add(fraction_millis)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn numeric_units_keep_exact_milliseconds_and_large_integer_precision() {
185        assert_eq!(timestamp_millis("1", TimestampUnit::Seconds), Some(1000));
186        assert_eq!(timestamp_millis("-1", TimestampUnit::Seconds), Some(-1000));
187        assert_eq!(
188            timestamp_millis("1700000000", TimestampUnit::SecondsOrMilliseconds),
189            Some(1700000000000)
190        );
191        assert_eq!(
192            timestamp_millis("1700000000123", TimestampUnit::SecondsOrMilliseconds),
193            Some(1700000000123)
194        );
195        assert_eq!(
196            timestamp_millis("9007199254740993", TimestampUnit::Milliseconds),
197            Some(9007199254740993)
198        );
199        assert_eq!(
200            timestamp_millis("17e8", TimestampUnit::Seconds),
201            Some(1700000000000)
202        );
203        assert_eq!(
204            timestamp_millis("1000e-3", TimestampUnit::Milliseconds),
205            Some(1)
206        );
207        assert_eq!(
208            timestamp_millis("0e999999", TimestampUnit::Milliseconds),
209            Some(0)
210        );
211    }
212
213    #[test]
214    fn numeric_overflow_and_non_integral_milliseconds_are_rejected() {
215        for value in [
216            "1e999",
217            "9223372036854775808",
218            "-9223372036854775809",
219            "1e-3",
220            "01",
221            "+1",
222            "NaN",
223            "1.5",
224            "",
225            "1e+",
226        ] {
227            assert_eq!(
228                timestamp_millis(value, TimestampUnit::Milliseconds),
229                None,
230                "{value}"
231            );
232        }
233        assert_eq!(
234            timestamp_millis("9223372036854775807", TimestampUnit::Milliseconds),
235            Some(i64::MAX)
236        );
237        assert_eq!(
238            timestamp_millis("-9223372036854775808", TimestampUnit::Milliseconds),
239            Some(i64::MIN)
240        );
241        assert_eq!(
242            timestamp_millis("9223372036854776", TimestampUnit::Seconds),
243            None
244        );
245        assert_eq!(timestamp_millis("1e-3", TimestampUnit::Seconds), Some(1));
246    }
247
248    #[test]
249    fn datetime_epoch_offsets_basic_notation_and_utc_defaults_agree() {
250        for value in [
251            "1970-01-01T00:00:00Z",
252            "19700101T000000Z",
253            "1970-01-01 00:00:00",
254            "1970-01-01T01:00:00+01:00",
255            "19691231T190000-0500",
256        ] {
257            assert_eq!(
258                timestamp_millis(value, TimestampUnit::Datetime),
259                Some(0),
260                "{value}"
261            );
262        }
263        assert_eq!(
264            timestamp_millis("1969-12-31T23:59:59.999Z", TimestampUnit::Datetime),
265            Some(-1)
266        );
267        assert_eq!(
268            timestamp_millis("1970-01-01T00:00:00.1Z", TimestampUnit::Datetime),
269            Some(100)
270        );
271        assert_eq!(
272            timestamp_millis("1970-01-01T00:00:00.123456Z", TimestampUnit::Datetime),
273            Some(123)
274        );
275        assert_eq!(
276            timestamp_millis("2000-02-29T00:00:00Z", TimestampUnit::Datetime),
277            Some(951782400000)
278        );
279    }
280
281    #[test]
282    fn invalid_calendars_and_malformed_offsets_are_rejected() {
283        for value in [
284            "1900-02-29T00:00:00Z",
285            "2025-02-29T00:00:00Z",
286            "2026-04-31T00:00:00Z",
287            "2026-01-01T24:00:00Z",
288            "2026-01-01T00:00:00+25:00",
289            "2026-01-01T00:00:00.123oops",
290            "2026-01-01",
291            "😀",
292        ] {
293            assert_eq!(
294                timestamp_millis(value, TimestampUnit::Datetime),
295                None,
296                "{value}"
297            );
298        }
299    }
300}