Skip to main content

nodejs/
tzif.rs

1//! Named IANA time zones, read from the system's compiled zoneinfo
2//! (`/usr/share/zoneinfo`, RFC 8536 TZif files) — what `Intl` and
3//! `toLocaleString` need for a `timeZone: "America/New_York"` option.
4//!
5//! A TZif file lists every transition up to some year and then, in its footer,
6//! the POSIX `TZ` rule that continues past the last one (`EST5EDT,M3.2.0,
7//! M11.1.0`). macOS ships "slim" files whose transitions stop early and lean on
8//! that rule, so both halves are read.
9
10use std::collections::HashMap;
11use std::rc::Rc;
12
13/// One local-time type: offset east of UTC in seconds, and its abbreviation.
14#[derive(Clone, Debug, PartialEq)]
15pub struct LocalType {
16    pub offset: i64,
17    pub abbrev: String,
18}
19
20/// A parsed zone.
21#[derive(Debug)]
22pub struct Tz {
23    /// The canonical name as found on disk.
24    pub name: String,
25    transitions: Vec<i64>,
26    type_of: Vec<usize>,
27    types: Vec<LocalType>,
28    rule: Option<PosixRule>,
29}
30
31/// The POSIX `TZ` rule of the footer: standard time, and optionally daylight
32/// time between two yearly transition dates.
33#[derive(Debug)]
34struct PosixRule {
35    std: LocalType,
36    dst: Option<(LocalType, RuleDate, RuleDate)>,
37}
38
39/// `Mm.w.d/time` — day `d` (0 = Sunday) of week `w` (5 = last) of month `m`,
40/// at `time` seconds of LOCAL time. `Jn` and plain-`n` dates are read as
41/// day-of-year forms.
42#[derive(Debug, Clone, Copy)]
43enum RuleDate {
44    MonthWeekDay {
45        month: i64,
46        week: i64,
47        day: i64,
48        time: i64,
49    },
50    /// `Jn`: day 1..=365, February 29 never counted.
51    Julian1 { day: i64, time: i64 },
52    /// `n`: day 0..=365, February 29 counted.
53    Julian0 { day: i64, time: i64 },
54}
55
56impl Tz {
57    /// The local type in force at `t` (seconds since the epoch, UTC).
58    pub fn at(&self, t: i64) -> LocalType {
59        match self.transitions.binary_search(&t) {
60            Ok(i) => self.types[self.type_of[i]].clone(),
61            // Past the last transition (or with none at all), the footer's
62            // rule continues the zone.
63            Err(i) if i == self.transitions.len() && self.rule.is_some() => {
64                self.rule.as_ref().map(|r| r.at(t)).expect("checked")
65            }
66            // Before the first transition: type 0 (RFC 8536 3.2).
67            Err(0) => self.types[0].clone(),
68            Err(i) => self.types[self.type_of[i - 1]].clone(),
69        }
70    }
71}
72
73thread_local! {
74    static CACHE: std::cell::RefCell<HashMap<String, Option<Rc<Tz>>>> =
75        std::cell::RefCell::new(HashMap::new());
76}
77
78/// The zone named `name`, if the system has it. Names are matched as the
79/// filesystem matches them; `..` and absolute paths are refused.
80pub fn load(name: &str) -> Option<Rc<Tz>> {
81    if name.is_empty()
82        || name.starts_with('/')
83        || name
84            .split('/')
85            .any(|p| p.is_empty() || p == "." || p == "..")
86        || !name
87            .chars()
88            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '-' | '+'))
89    {
90        return None;
91    }
92    if let Some(hit) = CACHE.with(|c| c.borrow().get(name).cloned()) {
93        return hit;
94    }
95    let tz = [
96        "/usr/share/zoneinfo",
97        "/usr/lib/zoneinfo",
98        "/usr/share/lib/zoneinfo",
99    ]
100    .iter()
101    .find_map(|dir| std::fs::read(format!("{dir}/{name}")).ok())
102    .and_then(|bytes| parse(&bytes, name))
103    .map(Rc::new);
104    CACHE.with(|c| c.borrow_mut().insert(name.to_string(), tz.clone()));
105    tz
106}
107
108fn be32(b: &[u8], at: usize) -> Option<i64> {
109    Some(i32::from_be_bytes(b.get(at..at + 4)?.try_into().ok()?) as i64)
110}
111
112fn be64(b: &[u8], at: usize) -> Option<i64> {
113    Some(i64::from_be_bytes(b.get(at..at + 8)?.try_into().ok()?))
114}
115
116/// The six counts of a TZif header: isutcnt, isstdcnt, leapcnt, timecnt,
117/// typecnt, charcnt.
118fn counts(b: &[u8], at: usize) -> Option<[usize; 6]> {
119    let mut c = [0usize; 6];
120    for (i, slot) in c.iter_mut().enumerate() {
121        *slot = be32(b, at + 20 + i * 4)? as usize;
122    }
123    Some(c)
124}
125
126fn parse(b: &[u8], name: &str) -> Option<Tz> {
127    if b.get(..4)? != b"TZif" {
128        return None;
129    }
130    let version = *b.get(4)?;
131    let c1 = counts(b, 0)?;
132    let v1_len = c1[3] * 5 + c1[4] * 6 + c1[5] + c1[2] * 8 + c1[1] + c1[0];
133    // Version 2+ repeats the data with 64-bit times after the v1 block.
134    let (base, c, time_size) = if version >= b'2' {
135        let at = 44 + v1_len;
136        (at, counts(b, at)?, 8)
137    } else {
138        (0, c1, 4)
139    };
140    let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = c;
141    let mut p = base + 44;
142    let mut transitions = Vec::with_capacity(timecnt);
143    for i in 0..timecnt {
144        transitions.push(if time_size == 8 {
145            be64(b, p + i * 8)?
146        } else {
147            be32(b, p + i * 4)?
148        });
149    }
150    p += timecnt * time_size;
151    let type_of: Vec<usize> = b.get(p..p + timecnt)?.iter().map(|&x| x as usize).collect();
152    p += timecnt;
153    let mut raw_types = Vec::with_capacity(typecnt);
154    for i in 0..typecnt {
155        let at = p + i * 6;
156        raw_types.push((be32(b, at)?, *b.get(at + 5)? as usize));
157    }
158    p += typecnt * 6;
159    let chars = b.get(p..p + charcnt)?;
160    let types: Vec<LocalType> = raw_types
161        .into_iter()
162        .map(|(offset, idx)| {
163            let end = chars[idx.min(chars.len())..]
164                .iter()
165                .position(|&ch| ch == 0)
166                .map_or(chars.len(), |e| idx + e);
167            LocalType {
168                offset,
169                abbrev: String::from_utf8_lossy(&chars[idx.min(end)..end]).into_owned(),
170            }
171        })
172        .collect();
173    if types.is_empty() || type_of.iter().any(|&t| t >= types.len()) {
174        return None;
175    }
176    p += charcnt + leapcnt * (time_size + 4) + isstdcnt + isutcnt;
177    let rule = if version >= b'2' {
178        b.get(p..).and_then(|rest| {
179            let text = std::str::from_utf8(rest).ok()?;
180            let line = text.strip_prefix('\n')?.split('\n').next()?;
181            PosixRule::parse(line)
182        })
183    } else {
184        None
185    };
186    Some(Tz {
187        name: name.to_string(),
188        transitions,
189        type_of,
190        types,
191        rule,
192    })
193}
194
195impl PosixRule {
196    fn parse(s: &str) -> Option<PosixRule> {
197        let mut cur = s;
198        let std_name = take_name(&mut cur)?;
199        // POSIX offsets are WEST of Greenwich.
200        let std_off = -take_offset(&mut cur)?;
201        let std = LocalType {
202            offset: std_off,
203            abbrev: std_name,
204        };
205        if cur.is_empty() {
206            return Some(PosixRule { std, dst: None });
207        }
208        let dst_name = take_name(&mut cur)?;
209        let dst_off = if cur.starts_with(',') {
210            std_off + 3600
211        } else {
212            -take_offset(&mut cur)?
213        };
214        let rest = cur.strip_prefix(',')?;
215        let (start, end) = rest.split_once(',')?;
216        Some(PosixRule {
217            std,
218            dst: Some((
219                LocalType {
220                    offset: dst_off,
221                    abbrev: dst_name,
222                },
223                RuleDate::parse(start)?,
224                RuleDate::parse(end)?,
225            )),
226        })
227    }
228
229    fn at(&self, t: i64) -> LocalType {
230        let Some((dst, start, end)) = &self.dst else {
231            return self.std.clone();
232        };
233        // The transitions of the year `t` falls in (in standard time), as UTC
234        // instants: the start is read in standard time, the end in daylight.
235        let year = crate::stdlib::date::civil_from_days((t + self.std.offset).div_euclid(86_400)).0;
236        let begin = start.local_secs(year) - self.std.offset;
237        let finish = end.local_secs(year) - dst.offset;
238        let in_dst = if begin < finish {
239            t >= begin && t < finish
240        } else {
241            // Southern hemisphere: daylight time spans the new year.
242            !(t >= finish && t < begin)
243        };
244        if in_dst {
245            dst.clone()
246        } else {
247            self.std.clone()
248        }
249    }
250}
251
252/// A zone abbreviation: letters, or anything between `<` and `>`.
253fn take_name(cur: &mut &str) -> Option<String> {
254    if let Some(rest) = cur.strip_prefix('<') {
255        let end = rest.find('>')?;
256        let name = rest[..end].to_string();
257        *cur = &rest[end + 1..];
258        return Some(name);
259    }
260    let end = cur
261        .find(|c: char| !c.is_ascii_alphabetic())
262        .unwrap_or(cur.len());
263    if end < 3 {
264        return None;
265    }
266    let name = cur[..end].to_string();
267    *cur = &cur[end..];
268    Some(name)
269}
270
271/// `[+-]hh[:mm[:ss]]` in seconds.
272fn take_offset(cur: &mut &str) -> Option<i64> {
273    let (sign, rest) = match cur.as_bytes().first()? {
274        b'-' => (-1, &cur[1..]),
275        b'+' => (1, &cur[1..]),
276        _ => (1, *cur),
277    };
278    let end = rest
279        .find(|c: char| !(c.is_ascii_digit() || c == ':'))
280        .unwrap_or(rest.len());
281    let secs = hms(&rest[..end])?;
282    *cur = &rest[end..];
283    Some(sign * secs)
284}
285
286fn hms(s: &str) -> Option<i64> {
287    let mut total = 0;
288    let mut scale = 3600;
289    for part in s.split(':') {
290        if part.is_empty() || scale == 0 {
291            return None;
292        }
293        total += part.parse::<i64>().ok()? * scale;
294        scale /= 60;
295    }
296    Some(total)
297}
298
299impl RuleDate {
300    fn parse(s: &str) -> Option<RuleDate> {
301        let (date, time) = match s.split_once('/') {
302            Some((d, t)) => {
303                let (neg, t) = match t.strip_prefix('-') {
304                    Some(t) => (true, t),
305                    None => (false, t.strip_prefix('+').unwrap_or(t)),
306                };
307                let secs = hms(t)?;
308                (d, if neg { -secs } else { secs })
309            }
310            None => (s, 7200),
311        };
312        if let Some(m) = date.strip_prefix('M') {
313            let mut it = m.split('.').map(|x| x.parse::<i64>().ok());
314            let (month, week, day) = (it.next()??, it.next()??, it.next()??);
315            return Some(RuleDate::MonthWeekDay {
316                month,
317                week,
318                day,
319                time,
320            });
321        }
322        if let Some(n) = date.strip_prefix('J') {
323            return Some(RuleDate::Julian1 {
324                day: n.parse().ok()?,
325                time,
326            });
327        }
328        Some(RuleDate::Julian0 {
329            day: date.parse().ok()?,
330            time,
331        })
332    }
333
334    /// Seconds since the epoch, in LOCAL time, of this date in `year`.
335    fn local_secs(&self, year: i64) -> i64 {
336        use crate::stdlib::date::days_from_civil;
337        let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
338        let (days, time) = match *self {
339            RuleDate::MonthWeekDay {
340                month,
341                week,
342                day,
343                time,
344            } => {
345                let first = days_from_civil(year, month - 1, 1);
346                // 1970-01-01 was a Thursday (4).
347                let first_wd = (first + 4).rem_euclid(7);
348                let mut d = first + (day - first_wd).rem_euclid(7) + (week - 1) * 7;
349                let next_month = if month == 12 {
350                    days_from_civil(year + 1, 0, 1)
351                } else {
352                    days_from_civil(year, month, 1)
353                };
354                while d >= next_month {
355                    d -= 7;
356                }
357                (d, time)
358            }
359            RuleDate::Julian1 { day, time } => {
360                let skip = if leap && day >= 60 { 1 } else { 0 };
361                (days_from_civil(year, 0, 1) + day - 1 + skip, time)
362            }
363            RuleDate::Julian0 { day, time } => (days_from_civil(year, 0, 1) + day, time),
364        };
365        days * 86_400 + time
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn posix_rule_new_york() {
375        let r = PosixRule::parse("EST5EDT,M3.2.0,M11.1.0").unwrap();
376        // 2030-07-01T12:00Z is daylight time; 2030-01-01T12:00Z is not.
377        assert_eq!(r.at(1_909_137_600).abbrev, "EDT");
378        assert_eq!(r.at(1_893_499_200).abbrev, "EST");
379        let r = PosixRule::parse("<+0530>-5:30").unwrap();
380        assert_eq!(r.at(0).offset, 19_800);
381        assert_eq!(r.at(0).abbrev, "+0530");
382    }
383}