1const ZONEINFO: &str = "/usr/share/zoneinfo";
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct StartTime {
22 pub unix: i64,
24 pub offset: i64,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct Civil {
31 pub year: i64,
33 pub month: i64,
35 pub day: i64,
37 pub hour: i64,
39 pub minute: i64,
41 pub second: i64,
43}
44
45impl StartTime {
46 pub fn new(epoch: Option<i64>, utc: bool) -> StartTime {
49 let unix = epoch.unwrap_or_else(now);
50 StartTime {
51 unix,
52 offset: if utc { 0 } else { local_offset(unix) },
53 }
54 }
55
56 pub fn civil(&self) -> Civil {
58 let local = self.unix.saturating_add(self.offset);
59 let (year, month, day) = civil_from_days(local.div_euclid(86400));
60 let secs = local.rem_euclid(86400);
61 Civil {
62 year,
63 month,
64 day,
65 hour: secs / 3600,
66 minute: secs / 60 % 60,
67 second: secs % 60,
68 }
69 }
70
71 pub fn pdf_date(&self) -> String {
76 let c = self.civil();
77 let mut s = format!(
78 "D:{:04}{:02}{:02}{:02}{:02}{:02}",
79 c.year, c.month, c.day, c.hour, c.minute, c.second
80 );
81 if self.offset == 0 {
82 s.push('Z');
83 } else {
84 let sign = if self.offset < 0 { '-' } else { '+' };
85 let o = self.offset.abs() / 60;
86 s.push_str(&format!("{sign}{:02}'{:02}'", o / 60, o % 60));
87 }
88 s
89 }
90}
91
92pub fn startup_random_seed() -> i32 {
99 let d = std::time::SystemTime::now()
100 .duration_since(std::time::UNIX_EPOCH)
101 .unwrap_or_default();
102 (i64::from(d.subsec_micros()) * 1000 + (d.as_secs() % 1000) as i64) as i32
103}
104
105pub fn now() -> i64 {
107 std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .map_or(0, |d| d.as_secs() as i64)
110}
111
112pub fn source_date_epoch() -> Option<i64> {
115 let v = std::env::var_os("SOURCE_DATE_EPOCH")?;
116 parse_source_date_epoch(&v.to_string_lossy())
117}
118
119pub fn parse_source_date_epoch(v: &str) -> Option<i64> {
132 if v.is_empty() {
133 return None;
134 }
135 let s = v.trim_start_matches([' ', '\t', '\n', '\x0b', '\x0c', '\r']);
136 let (neg, digits) = match s.as_bytes().first() {
137 Some(b'-') => (true, &s[1..]),
138 Some(b'+') => (false, &s[1..]),
139 _ => (false, s),
140 };
141 let value = if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
142 digits.parse::<u64>().unwrap_or(0)
143 } else {
144 0
145 };
146 let value = if neg { value.wrapping_neg() } else { value };
147 let t = value as u32 as i32;
148 (t >= 0).then_some(i64::from(t))
149}
150
151pub fn local_offset(t: i64) -> i64 {
153 match std::env::var("TZ") {
154 Ok(tz) => offset_for_tz(&tz, t).unwrap_or(0),
155 Err(_) => std::fs::read("/etc/localtime")
156 .ok()
157 .and_then(|d| tzif_offset(&d, t))
158 .unwrap_or(0),
159 }
160}
161
162pub fn offset_for_tz(tz: &str, t: i64) -> Option<i64> {
164 let tz = tz.strip_prefix(':').unwrap_or(tz);
165 if tz.is_empty() {
166 return Some(0);
167 }
168 let path = if tz.starts_with('/') {
169 Some(std::path::PathBuf::from(tz))
170 } else if !tz.contains("..") {
171 let dir = std::env::var_os("TZDIR").unwrap_or_else(|| ZONEINFO.into());
172 Some(std::path::Path::new(&dir).join(tz))
173 } else {
174 None
175 };
176 if let Some(data) = path.and_then(|p| std::fs::read(p).ok())
177 && let Some(off) = tzif_offset(&data, t)
178 {
179 return Some(off);
180 }
181 PosixTz::parse(tz).map(|r| r.offset_at(t))
182}
183
184fn be32(d: &[u8], p: usize) -> Option<i64> {
185 d.get(p..p + 4)
186 .map(|b| i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as i64)
187}
188
189fn be64(d: &[u8], p: usize) -> Option<i64> {
190 d.get(p..p + 8).map(|b| {
191 let mut a = [0u8; 8];
192 a.copy_from_slice(b);
193 i64::from_be_bytes(a)
194 })
195}
196
197pub fn tzif_offset(d: &[u8], t: i64) -> Option<i64> {
199 if d.get(0..4)? != b"TZif" {
200 return None;
201 }
202 let version = *d.get(4)?;
203 let counts = |base: usize| -> Option<[usize; 6]> {
204 let mut c = [0usize; 6];
205 for (i, v) in c.iter_mut().enumerate() {
206 *v = usize::try_from(be32(d, base + 20 + 4 * i)?).ok()?;
207 }
208 Some(c)
209 };
210 let [isut, isstd, leap, timecnt, typecnt, charcnt] = counts(0)?;
211 let v1_len = 44 + timecnt * 5 + typecnt * 6 + charcnt + leap * 8 + isstd + isut;
212 let (base, tsize, c) = if version >= b'2' {
214 (v1_len, 8, counts(v1_len)?)
215 } else {
216 (0, 4, counts(0)?)
217 };
218 let [isut, isstd, leap, timecnt, typecnt, charcnt] = c;
219 if typecnt == 0 {
220 return None;
221 }
222 let times = base + 44;
223 let idx = times + timecnt * tsize;
224 let types = idx + timecnt;
225 let read_t = |i: usize| {
226 if tsize == 8 {
227 be64(d, times + 8 * i)
228 } else {
229 be32(d, times + 4 * i)
230 }
231 };
232 let ttinfo = |i: usize| -> Option<(i64, bool)> {
233 let p = types + 6 * i;
234 Some((be32(d, p)?, *d.get(p + 4)? != 0))
235 };
236 let footer = types + typecnt * 6 + charcnt + leap * (tsize + 4) + isstd + isut;
237
238 let mut last: Option<usize> = None;
240 for i in 0..timecnt {
241 if read_t(i)? <= t {
242 last = Some(i);
243 } else {
244 break;
245 }
246 }
247 match last {
248 Some(i) if i + 1 == timecnt && version >= b'2' => {
249 if let Some(rule) = footer_rule(d, footer) {
251 return Some(rule.offset_at(t));
252 }
253 let ty = *d.get(idx + i)? as usize;
254 Some(ttinfo(ty)?.0)
255 }
256 Some(i) => {
257 let ty = *d.get(idx + i)? as usize;
258 Some(ttinfo(ty)?.0)
259 }
260 None => {
261 if timecnt == 0
262 && version >= b'2'
263 && let Some(rule) = footer_rule(d, footer)
264 {
265 return Some(rule.offset_at(t));
266 }
267 Some(ttinfo(0)?.0)
269 }
270 }
271}
272
273fn footer_rule(d: &[u8], at: usize) -> Option<PosixTz> {
274 let rest = d.get(at..)?;
275 let rest = rest.strip_prefix(b"\n")?;
276 let end = rest.iter().position(|&b| b == b'\n')?;
277 let s = std::str::from_utf8(&rest[..end]).ok()?;
278 if s.is_empty() {
279 return None;
280 }
281 PosixTz::parse(s)
282}
283
284#[derive(Clone, Copy, Debug, PartialEq)]
286enum RuleDate {
287 Julian1(i64),
289 Julian0(i64),
291 MonthWeekDay(i64, i64, i64),
293}
294
295#[derive(Clone, Copy, Debug, PartialEq)]
297struct PosixTz {
298 std: i64,
300 dst: Option<(i64, RuleDate, i64, RuleDate, i64)>,
302}
303
304struct Cursor<'a> {
305 s: &'a [u8],
306 p: usize,
307}
308
309impl Cursor<'_> {
310 fn peek(&self) -> Option<u8> {
311 self.s.get(self.p).copied()
312 }
313
314 fn eat(&mut self, c: u8) -> bool {
315 if self.peek() == Some(c) {
316 self.p += 1;
317 true
318 } else {
319 false
320 }
321 }
322
323 fn name(&mut self) -> Option<()> {
324 if self.eat(b'<') {
325 while self.peek()? != b'>' {
326 self.p += 1;
327 }
328 self.p += 1;
329 return Some(());
330 }
331 let start = self.p;
332 while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
333 self.p += 1;
334 }
335 (self.p - start >= 3).then_some(())
336 }
337
338 fn number(&mut self) -> Option<i64> {
339 let start = self.p;
340 while self.peek().is_some_and(|c| c.is_ascii_digit()) {
341 self.p += 1;
342 }
343 std::str::from_utf8(&self.s[start..self.p])
344 .ok()?
345 .parse()
346 .ok()
347 }
348
349 fn hms(&mut self) -> Option<i64> {
351 let neg = if self.eat(b'-') {
352 true
353 } else {
354 self.eat(b'+');
355 false
356 };
357 let mut v = self.number()? * 3600;
358 if self.eat(b':') {
359 v += self.number()? * 60;
360 if self.eat(b':') {
361 v += self.number()?;
362 }
363 }
364 Some(if neg { -v } else { v })
365 }
366
367 fn date(&mut self) -> Option<RuleDate> {
368 if self.eat(b'J') {
369 Some(RuleDate::Julian1(self.number()?))
370 } else if self.eat(b'M') {
371 let m = self.number()?;
372 self.eat(b'.').then_some(())?;
373 let w = self.number()?;
374 self.eat(b'.').then_some(())?;
375 let d = self.number()?;
376 Some(RuleDate::MonthWeekDay(m, w, d))
377 } else {
378 Some(RuleDate::Julian0(self.number()?))
379 }
380 }
381}
382
383impl PosixTz {
384 fn parse(s: &str) -> Option<PosixTz> {
385 let mut c = Cursor {
386 s: s.as_bytes(),
387 p: 0,
388 };
389 c.name()?;
390 let std = -c.hms()?;
391 if c.peek().is_none() {
392 return Some(PosixTz { std, dst: None });
393 }
394 c.name()?;
395 let dst_off = if matches!(c.peek(), Some(b',') | None) {
396 std + 3600
397 } else {
398 -c.hms()?
399 };
400 let (start, st, end, et) = if c.eat(b',') {
401 let start = c.date()?;
402 let st = if c.eat(b'/') { c.hms()? } else { 7200 };
403 c.eat(b',').then_some(())?;
404 let end = c.date()?;
405 let et = if c.eat(b'/') { c.hms()? } else { 7200 };
406 (start, st, end, et)
407 } else {
408 (
411 RuleDate::MonthWeekDay(3, 2, 0),
412 7200,
413 RuleDate::MonthWeekDay(11, 1, 0),
414 7200,
415 )
416 };
417 Some(PosixTz {
418 std,
419 dst: Some((dst_off, start, st, end, et)),
420 })
421 }
422
423 fn offset_at(&self, t: i64) -> i64 {
424 let Some((dst, start, st, end, et)) = self.dst else {
425 return self.std;
426 };
427 let year = civil_from_days((t + self.std).div_euclid(86400)).0;
428 let s = days_from_civil(year, 1, 1) * 86400 + rule_day_offset(start, year) * 86400 + st
431 - self.std;
432 let e = days_from_civil(year, 1, 1) * 86400 + rule_day_offset(end, year) * 86400 + et - dst;
433 let in_dst = if s < e {
434 t >= s && t < e
435 } else {
436 !(t >= e && t < s)
437 };
438 if in_dst { dst } else { self.std }
439 }
440}
441
442fn is_leap(y: i64) -> bool {
443 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
444}
445
446fn rule_day_offset(r: RuleDate, year: i64) -> i64 {
448 match r {
449 RuleDate::Julian1(n) => {
450 let n = n.clamp(1, 365);
451 if is_leap(year) && n >= 60 { n } else { n - 1 }
452 }
453 RuleDate::Julian0(n) => n,
454 RuleDate::MonthWeekDay(m, w, d) => {
455 let m = m.clamp(1, 12);
456 let first = days_from_civil(year, m, 1);
457 let jan1 = days_from_civil(year, 1, 1);
458 let wd_first = (first + 4).rem_euclid(7);
460 let mut day = first + (d - wd_first).rem_euclid(7) + 7 * (w.clamp(1, 5) - 1);
461 let (ny, nm) = if m == 12 {
462 (year + 1, 1)
463 } else {
464 (year, m + 1)
465 };
466 let next = days_from_civil(ny, nm, 1);
467 while day >= next {
468 day -= 7;
469 }
470 day - jan1
471 }
472 }
473}
474
475pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
477 let y = if m <= 2 { y - 1 } else { y };
478 let era = y.div_euclid(400);
479 let yoe = y - era * 400;
480 let mp = (m + 9) % 12;
481 let doy = (153 * mp + 2) / 5 + d - 1;
482 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
483 era * 146097 + doe - 719468
484}
485
486pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
488 let z = z + 719468;
489 let era = z.div_euclid(146097);
490 let doe = z - era * 146097;
491 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
492 let y = yoe + era * 400;
493 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
494 let mp = (5 * doy + 2) / 153;
495 let d = doy - (153 * mp + 2) / 5 + 1;
496 let m = if mp < 10 { mp + 3 } else { mp - 9 };
497 (if m <= 2 { y + 1 } else { y }, m, d)
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn civil_round_trip() {
506 assert_eq!(days_from_civil(1970, 1, 1), 0);
507 assert_eq!(civil_from_days(0), (1970, 1, 1));
508 assert_eq!(civil_from_days(-1), (1969, 12, 31));
509 assert_eq!(days_from_civil(2000, 3, 1), 11017);
510 for z in [-800000, -1, 0, 59, 10957, 20000, 2932896] {
511 let (y, m, d) = civil_from_days(z);
512 assert_eq!(days_from_civil(y, m, d), z);
513 }
514 }
515
516 #[test]
517 fn posix_rules() {
518 let cet = PosixTz::parse("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
519 assert_eq!(cet.offset_at(0), 3600);
520 assert_eq!(
522 cet.offset_at(days_from_civil(2026, 7, 1) * 86400 + 43200),
523 7200
524 );
525 let t = days_from_civil(2026, 3, 29) * 86400 + 3600;
527 assert_eq!(cet.offset_at(t - 1), 3600);
528 assert_eq!(cet.offset_at(t), 7200);
529 let ny = PosixTz::parse("EST5EDT,M3.2.0,M11.1.0").unwrap();
530 assert_eq!(ny.offset_at(0), -18000);
531 let india = PosixTz::parse("IST-5:30").unwrap();
532 assert_eq!(india.offset_at(0), 19800);
533 let quoted = PosixTz::parse("<+0330>-3:30").unwrap();
534 assert_eq!(quoted.offset_at(0), 12600);
535 assert!(PosixTz::parse("X").is_none());
536 }
537
538 #[test]
539 fn zone_files_if_present() {
540 if std::path::Path::new("/usr/share/zoneinfo/America/New_York").exists() {
542 assert_eq!(offset_for_tz("America/New_York", 0), Some(-18000));
543 assert_eq!(offset_for_tz("Asia/Kolkata", 0), Some(19800));
544 assert_eq!(offset_for_tz("Europe/Paris", 0), Some(3600));
545 assert_eq!(offset_for_tz("Europe/Paris", 1_790_000_000), Some(7200));
546 }
547 assert_eq!(offset_for_tz("UTC0", 0), Some(0));
548 assert_eq!(offset_for_tz("", 0), Some(0));
549 }
550
551 #[test]
552 fn source_date_epoch_like_luatex() {
553 let cases: &[(&str, Option<i64>)] = &[
555 ("", None),
556 ("0", Some(0)),
557 ("abc", Some(0)),
558 ("-5", None),
559 (" 5", Some(5)),
560 ("5x", Some(0)),
561 ("5 ", Some(0)),
562 (" ", Some(0)),
563 ("+7", Some(7)),
564 ("-0", Some(0)),
565 ("0x10", Some(0)),
566 ("99999999999", Some(1_215_752_191)),
567 ("18446744073709551616", Some(0)),
568 ("4294967295", None),
569 ("2147483648", None),
570 ("2147483647", Some(2_147_483_647)),
571 ("4294967296", Some(0)),
572 ("4294967301", Some(5)),
573 ("-4294967291", Some(5)),
574 ];
575 for &(v, want) in cases {
576 assert_eq!(parse_source_date_epoch(v), want, "{v:?}");
577 }
578 }
579
580 #[test]
581 fn dates_like_luatex() {
582 let d = |unix, offset| StartTime { unix, offset }.pdf_date();
583 assert_eq!(d(0, 0), "D:19700101000000Z");
584 assert_eq!(d(0, 3600), "D:19700101010000+01'00'");
585 assert_eq!(d(0, -18000), "D:19691231190000-05'00'");
586 assert_eq!(d(0, 19800), "D:19700101053000+05'30'");
587 assert_eq!(d(1_790_000_000, 7200), "D:20260921161320+02'00'");
588 assert_eq!(d(1_790_000_000, 0), "D:20260921141320Z");
589 let c = StartTime {
590 unix: 1_790_000_000,
591 offset: 7200,
592 }
593 .civil();
594 assert_eq!(
595 (c.year, c.month, c.day, c.hour, c.minute, c.second),
596 (2026, 9, 21, 16, 13, 20)
597 );
598 assert_eq!(
599 StartTime::new(Some(5), true),
600 StartTime { unix: 5, offset: 0 }
601 );
602 }
603
604 #[test]
605 fn garbage_is_not_a_panic() {
606 assert_eq!(tzif_offset(b"TZif2", 0), None);
607 assert_eq!(tzif_offset(b"nope", 0), None);
608 let mut d = b"TZif2".to_vec();
609 d.resize(44, 0);
610 d[39] = 1; assert_eq!(tzif_offset(&d, 0), None);
612 }
613}