1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum TimestampUnit {
9 Seconds,
10 Milliseconds,
11 Microseconds,
12 SecondsOrMilliseconds,
14 Datetime,
15 DatetimeUtc,
17}
18
19#[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
74fn 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 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 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 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}