const ZONEINFO: &str = "/usr/share/zoneinfo";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StartTime {
pub unix: i64,
pub offset: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Civil {
pub year: i64,
pub month: i64,
pub day: i64,
pub hour: i64,
pub minute: i64,
pub second: i64,
}
impl StartTime {
pub fn new(epoch: Option<i64>, utc: bool) -> StartTime {
let unix = epoch.unwrap_or_else(now);
StartTime {
unix,
offset: if utc { 0 } else { local_offset(unix) },
}
}
pub fn civil(&self) -> Civil {
let local = self.unix.saturating_add(self.offset);
let (year, month, day) = civil_from_days(local.div_euclid(86400));
let secs = local.rem_euclid(86400);
Civil {
year,
month,
day,
hour: secs / 3600,
minute: secs / 60 % 60,
second: secs % 60,
}
}
pub fn pdf_date(&self) -> String {
let c = self.civil();
let mut s = format!(
"D:{:04}{:02}{:02}{:02}{:02}{:02}",
c.year, c.month, c.day, c.hour, c.minute, c.second
);
if self.offset == 0 {
s.push('Z');
} else {
let sign = if self.offset < 0 { '-' } else { '+' };
let o = self.offset.abs() / 60;
s.push_str(&format!("{sign}{:02}'{:02}'", o / 60, o % 60));
}
s
}
}
pub fn startup_random_seed() -> i32 {
let d = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
(i64::from(d.subsec_micros()) * 1000 + (d.as_secs() % 1000) as i64) as i32
}
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
pub fn source_date_epoch() -> Option<i64> {
let v = std::env::var_os("SOURCE_DATE_EPOCH")?;
parse_source_date_epoch(&v.to_string_lossy())
}
pub fn parse_source_date_epoch(v: &str) -> Option<i64> {
if v.is_empty() {
return None;
}
let s = v.trim_start_matches([' ', '\t', '\n', '\x0b', '\x0c', '\r']);
let (neg, digits) = match s.as_bytes().first() {
Some(b'-') => (true, &s[1..]),
Some(b'+') => (false, &s[1..]),
_ => (false, s),
};
let value = if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
digits.parse::<u64>().unwrap_or(0)
} else {
0
};
let value = if neg { value.wrapping_neg() } else { value };
let t = value as u32 as i32;
(t >= 0).then_some(i64::from(t))
}
pub fn local_offset(t: i64) -> i64 {
match std::env::var("TZ") {
Ok(tz) => offset_for_tz(&tz, t).unwrap_or(0),
Err(_) => std::fs::read("/etc/localtime")
.ok()
.and_then(|d| tzif_offset(&d, t))
.unwrap_or(0),
}
}
pub fn offset_for_tz(tz: &str, t: i64) -> Option<i64> {
let tz = tz.strip_prefix(':').unwrap_or(tz);
if tz.is_empty() {
return Some(0);
}
let path = if tz.starts_with('/') {
Some(std::path::PathBuf::from(tz))
} else if !tz.contains("..") {
let dir = std::env::var_os("TZDIR").unwrap_or_else(|| ZONEINFO.into());
Some(std::path::Path::new(&dir).join(tz))
} else {
None
};
if let Some(data) = path.and_then(|p| std::fs::read(p).ok())
&& let Some(off) = tzif_offset(&data, t)
{
return Some(off);
}
PosixTz::parse(tz).map(|r| r.offset_at(t))
}
fn be32(d: &[u8], p: usize) -> Option<i64> {
d.get(p..p + 4)
.map(|b| i32::from_be_bytes([b[0], b[1], b[2], b[3]]) as i64)
}
fn be64(d: &[u8], p: usize) -> Option<i64> {
d.get(p..p + 8).map(|b| {
let mut a = [0u8; 8];
a.copy_from_slice(b);
i64::from_be_bytes(a)
})
}
pub fn tzif_offset(d: &[u8], t: i64) -> Option<i64> {
if d.get(0..4)? != b"TZif" {
return None;
}
let version = *d.get(4)?;
let counts = |base: usize| -> Option<[usize; 6]> {
let mut c = [0usize; 6];
for (i, v) in c.iter_mut().enumerate() {
*v = usize::try_from(be32(d, base + 20 + 4 * i)?).ok()?;
}
Some(c)
};
let [isut, isstd, leap, timecnt, typecnt, charcnt] = counts(0)?;
let v1_len = 44 + timecnt * 5 + typecnt * 6 + charcnt + leap * 8 + isstd + isut;
let (base, tsize, c) = if version >= b'2' {
(v1_len, 8, counts(v1_len)?)
} else {
(0, 4, counts(0)?)
};
let [isut, isstd, leap, timecnt, typecnt, charcnt] = c;
if typecnt == 0 {
return None;
}
let times = base + 44;
let idx = times + timecnt * tsize;
let types = idx + timecnt;
let read_t = |i: usize| {
if tsize == 8 {
be64(d, times + 8 * i)
} else {
be32(d, times + 4 * i)
}
};
let ttinfo = |i: usize| -> Option<(i64, bool)> {
let p = types + 6 * i;
Some((be32(d, p)?, *d.get(p + 4)? != 0))
};
let footer = types + typecnt * 6 + charcnt + leap * (tsize + 4) + isstd + isut;
let mut last: Option<usize> = None;
for i in 0..timecnt {
if read_t(i)? <= t {
last = Some(i);
} else {
break;
}
}
match last {
Some(i) if i + 1 == timecnt && version >= b'2' => {
if let Some(rule) = footer_rule(d, footer) {
return Some(rule.offset_at(t));
}
let ty = *d.get(idx + i)? as usize;
Some(ttinfo(ty)?.0)
}
Some(i) => {
let ty = *d.get(idx + i)? as usize;
Some(ttinfo(ty)?.0)
}
None => {
if timecnt == 0
&& version >= b'2'
&& let Some(rule) = footer_rule(d, footer)
{
return Some(rule.offset_at(t));
}
Some(ttinfo(0)?.0)
}
}
}
fn footer_rule(d: &[u8], at: usize) -> Option<PosixTz> {
let rest = d.get(at..)?;
let rest = rest.strip_prefix(b"\n")?;
let end = rest.iter().position(|&b| b == b'\n')?;
let s = std::str::from_utf8(&rest[..end]).ok()?;
if s.is_empty() {
return None;
}
PosixTz::parse(s)
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum RuleDate {
Julian1(i64),
Julian0(i64),
MonthWeekDay(i64, i64, i64),
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct PosixTz {
std: i64,
dst: Option<(i64, RuleDate, i64, RuleDate, i64)>,
}
struct Cursor<'a> {
s: &'a [u8],
p: usize,
}
impl Cursor<'_> {
fn peek(&self) -> Option<u8> {
self.s.get(self.p).copied()
}
fn eat(&mut self, c: u8) -> bool {
if self.peek() == Some(c) {
self.p += 1;
true
} else {
false
}
}
fn name(&mut self) -> Option<()> {
if self.eat(b'<') {
while self.peek()? != b'>' {
self.p += 1;
}
self.p += 1;
return Some(());
}
let start = self.p;
while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
self.p += 1;
}
(self.p - start >= 3).then_some(())
}
fn number(&mut self) -> Option<i64> {
let start = self.p;
while self.peek().is_some_and(|c| c.is_ascii_digit()) {
self.p += 1;
}
std::str::from_utf8(&self.s[start..self.p])
.ok()?
.parse()
.ok()
}
fn hms(&mut self) -> Option<i64> {
let neg = if self.eat(b'-') {
true
} else {
self.eat(b'+');
false
};
let mut v = self.number()? * 3600;
if self.eat(b':') {
v += self.number()? * 60;
if self.eat(b':') {
v += self.number()?;
}
}
Some(if neg { -v } else { v })
}
fn date(&mut self) -> Option<RuleDate> {
if self.eat(b'J') {
Some(RuleDate::Julian1(self.number()?))
} else if self.eat(b'M') {
let m = self.number()?;
self.eat(b'.').then_some(())?;
let w = self.number()?;
self.eat(b'.').then_some(())?;
let d = self.number()?;
Some(RuleDate::MonthWeekDay(m, w, d))
} else {
Some(RuleDate::Julian0(self.number()?))
}
}
}
impl PosixTz {
fn parse(s: &str) -> Option<PosixTz> {
let mut c = Cursor {
s: s.as_bytes(),
p: 0,
};
c.name()?;
let std = -c.hms()?;
if c.peek().is_none() {
return Some(PosixTz { std, dst: None });
}
c.name()?;
let dst_off = if matches!(c.peek(), Some(b',') | None) {
std + 3600
} else {
-c.hms()?
};
let (start, st, end, et) = if c.eat(b',') {
let start = c.date()?;
let st = if c.eat(b'/') { c.hms()? } else { 7200 };
c.eat(b',').then_some(())?;
let end = c.date()?;
let et = if c.eat(b'/') { c.hms()? } else { 7200 };
(start, st, end, et)
} else {
(
RuleDate::MonthWeekDay(3, 2, 0),
7200,
RuleDate::MonthWeekDay(11, 1, 0),
7200,
)
};
Some(PosixTz {
std,
dst: Some((dst_off, start, st, end, et)),
})
}
fn offset_at(&self, t: i64) -> i64 {
let Some((dst, start, st, end, et)) = self.dst else {
return self.std;
};
let year = civil_from_days((t + self.std).div_euclid(86400)).0;
let s = days_from_civil(year, 1, 1) * 86400 + rule_day_offset(start, year) * 86400 + st
- self.std;
let e = days_from_civil(year, 1, 1) * 86400 + rule_day_offset(end, year) * 86400 + et - dst;
let in_dst = if s < e {
t >= s && t < e
} else {
!(t >= e && t < s)
};
if in_dst { dst } else { self.std }
}
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn rule_day_offset(r: RuleDate, year: i64) -> i64 {
match r {
RuleDate::Julian1(n) => {
let n = n.clamp(1, 365);
if is_leap(year) && n >= 60 { n } else { n - 1 }
}
RuleDate::Julian0(n) => n,
RuleDate::MonthWeekDay(m, w, d) => {
let m = m.clamp(1, 12);
let first = days_from_civil(year, m, 1);
let jan1 = days_from_civil(year, 1, 1);
let wd_first = (first + 4).rem_euclid(7);
let mut day = first + (d - wd_first).rem_euclid(7) + 7 * (w.clamp(1, 5) - 1);
let (ny, nm) = if m == 12 {
(year + 1, 1)
} else {
(year, m + 1)
};
let next = days_from_civil(ny, nm, 1);
while day >= next {
day -= 7;
}
day - jan1
}
}
}
pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
let z = z + 719468;
let era = z.div_euclid(146097);
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_round_trip() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
assert_eq!(days_from_civil(2000, 3, 1), 11017);
for z in [-800000, -1, 0, 59, 10957, 20000, 2932896] {
let (y, m, d) = civil_from_days(z);
assert_eq!(days_from_civil(y, m, d), z);
}
}
#[test]
fn posix_rules() {
let cet = PosixTz::parse("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
assert_eq!(cet.offset_at(0), 3600);
assert_eq!(
cet.offset_at(days_from_civil(2026, 7, 1) * 86400 + 43200),
7200
);
let t = days_from_civil(2026, 3, 29) * 86400 + 3600;
assert_eq!(cet.offset_at(t - 1), 3600);
assert_eq!(cet.offset_at(t), 7200);
let ny = PosixTz::parse("EST5EDT,M3.2.0,M11.1.0").unwrap();
assert_eq!(ny.offset_at(0), -18000);
let india = PosixTz::parse("IST-5:30").unwrap();
assert_eq!(india.offset_at(0), 19800);
let quoted = PosixTz::parse("<+0330>-3:30").unwrap();
assert_eq!(quoted.offset_at(0), 12600);
assert!(PosixTz::parse("X").is_none());
}
#[test]
fn zone_files_if_present() {
if std::path::Path::new("/usr/share/zoneinfo/America/New_York").exists() {
assert_eq!(offset_for_tz("America/New_York", 0), Some(-18000));
assert_eq!(offset_for_tz("Asia/Kolkata", 0), Some(19800));
assert_eq!(offset_for_tz("Europe/Paris", 0), Some(3600));
assert_eq!(offset_for_tz("Europe/Paris", 1_790_000_000), Some(7200));
}
assert_eq!(offset_for_tz("UTC0", 0), Some(0));
assert_eq!(offset_for_tz("", 0), Some(0));
}
#[test]
fn source_date_epoch_like_luatex() {
let cases: &[(&str, Option<i64>)] = &[
("", None),
("0", Some(0)),
("abc", Some(0)),
("-5", None),
(" 5", Some(5)),
("5x", Some(0)),
("5 ", Some(0)),
(" ", Some(0)),
("+7", Some(7)),
("-0", Some(0)),
("0x10", Some(0)),
("99999999999", Some(1_215_752_191)),
("18446744073709551616", Some(0)),
("4294967295", None),
("2147483648", None),
("2147483647", Some(2_147_483_647)),
("4294967296", Some(0)),
("4294967301", Some(5)),
("-4294967291", Some(5)),
];
for &(v, want) in cases {
assert_eq!(parse_source_date_epoch(v), want, "{v:?}");
}
}
#[test]
fn dates_like_luatex() {
let d = |unix, offset| StartTime { unix, offset }.pdf_date();
assert_eq!(d(0, 0), "D:19700101000000Z");
assert_eq!(d(0, 3600), "D:19700101010000+01'00'");
assert_eq!(d(0, -18000), "D:19691231190000-05'00'");
assert_eq!(d(0, 19800), "D:19700101053000+05'30'");
assert_eq!(d(1_790_000_000, 7200), "D:20260921161320+02'00'");
assert_eq!(d(1_790_000_000, 0), "D:20260921141320Z");
let c = StartTime {
unix: 1_790_000_000,
offset: 7200,
}
.civil();
assert_eq!(
(c.year, c.month, c.day, c.hour, c.minute, c.second),
(2026, 9, 21, 16, 13, 20)
);
assert_eq!(
StartTime::new(Some(5), true),
StartTime { unix: 5, offset: 0 }
);
}
#[test]
fn garbage_is_not_a_panic() {
assert_eq!(tzif_offset(b"TZif2", 0), None);
assert_eq!(tzif_offset(b"nope", 0), None);
let mut d = b"TZif2".to_vec();
d.resize(44, 0);
d[39] = 1; assert_eq!(tzif_offset(&d, 0), None);
}
}