Skip to main content

yapi_core/
zone.rs

1//! The local time zone, chosen as Node chooses it: the zone `TZ` names when
2//! it is set, else the system's zone.
3
4use std::collections::HashMap;
5use std::path::Path;
6use std::sync::{LazyLock, Mutex};
7
8use tz::{TimeZone, TimeZoneSettings};
9
10/// A time zone and its IANA name, when it has one.
11#[derive(Debug)]
12pub struct Zone {
13    zone: TimeZone,
14    name: Option<String>,
15}
16
17static LOCAL: LazyLock<Zone> = LazyLock::new(|| Zone::from_tz(std::env::var("TZ").ok().as_deref()));
18
19/// The process's local zone, loaded on first use.
20pub fn local() -> &'static Zone {
21    &LOCAL
22}
23
24/// Zones found by name, keyed by lowercased name. At most one entry per
25/// zone file, so it stays small.
26static NAMED: LazyLock<Mutex<HashMap<String, &'static Zone>>> = LazyLock::new(Mutex::default);
27
28/// The zone IANA name `name` spells, matched without regard to case as
29/// `Intl` does, or `None` when no zone file has that name. The zone is
30/// shared and lives for the process.
31pub fn named(name: &str) -> Option<&'static Zone> {
32    let key = name.to_ascii_lowercase();
33    let mut cache = NAMED
34        .lock()
35        .unwrap_or_else(|poisoned| poisoned.into_inner());
36    if let Some(zone) = cache.get(&key) {
37        return Some(zone);
38    }
39    let canonical = canonical_name(name)?;
40    let zone = Box::leak(Box::new(Zone::from_tz(Some(&canonical))));
41    // `from_tz` falls back to UTC for what it cannot read, which is no zone.
42    zone.name()?;
43    Some(*cache.entry(key).or_insert(zone))
44}
45
46/// The name of the zone file that `name` matches without regard to case,
47/// searched in the zoneinfo directories.
48fn canonical_name(name: &str) -> Option<String> {
49    if name.is_empty()
50        || name.starts_with('/')
51        || name.split('/').any(|part| part == ".." || part == ".")
52    {
53        return None;
54    }
55    TimeZoneSettings::DEFAULT_DIRECTORIES
56        .iter()
57        .find_map(|root| {
58            let mut path = Path::new(root).to_path_buf();
59            let mut found = Vec::new();
60            for part in name.split('/') {
61                let entry = std::fs::read_dir(&path).ok()?.flatten().find(|entry| {
62                    entry
63                        .file_name()
64                        .to_string_lossy()
65                        .eq_ignore_ascii_case(part)
66                })?;
67                found.push(entry.file_name().to_string_lossy().into_owned());
68                path = entry.path();
69            }
70            path.is_file().then(|| found.join("/"))
71        })
72}
73
74impl Zone {
75    /// The zone for the value of `TZ`. Without one it is the system's zone,
76    /// from `/etc/localtime`. A value names a zone file or spells a POSIX
77    /// rule. A value that does neither means UTC, as in Node, and so does an
78    /// empty value or a system without a zone.
79    pub fn from_tz(tz: Option<&str>) -> Zone {
80        let Some(tz) = tz else {
81            let name = std::fs::read_link("/etc/localtime")
82                .ok()
83                .and_then(|target| {
84                    let target = target.to_string_lossy();
85                    target
86                        .rsplit_once("zoneinfo/")
87                        .map(|(_, name)| name.to_owned())
88                });
89            return Zone::new(TimeZone::local().ok(), name);
90        };
91        if tz.is_empty() {
92            // Node's name for the zone of an empty value.
93            return Zone {
94                zone: TimeZone::utc(),
95                name: Some("Etc/Unknown".to_owned()),
96            };
97        }
98        let file = tz.strip_prefix(':').unwrap_or(tz);
99        // Node names only zones it finds by name, not files given by path.
100        let named = !file.starts_with('/')
101            && TimeZoneSettings::DEFAULT_DIRECTORIES
102                .iter()
103                .any(|dir| Path::new(dir).join(file).is_file());
104        Zone::new(
105            TimeZone::from_posix_tz(tz).ok(),
106            named.then(|| file.to_owned()),
107        )
108    }
109
110    fn new(zone: Option<TimeZone>, name: Option<String>) -> Zone {
111        let Some(zone) = zone else {
112            return Zone {
113                zone: TimeZone::utc(),
114                name: None,
115            };
116        };
117        // Node reports the UTC zone's aliases by its canonical name.
118        let name = name.map(|name| match name.as_str() {
119            "Etc/UTC" | "Etc/UCT" | "UCT" | "Etc/Universal" | "Universal" | "Etc/Zulu" | "Zulu" => {
120                "UTC".to_owned()
121            }
122            _ => name,
123        });
124        Zone { zone, name }
125    }
126
127    /// The zone's IANA name, such as `Europe/London`, when it has one.
128    pub fn name(&self) -> Option<&str> {
129        self.name.as_deref()
130    }
131
132    /// The zone's offset from UTC at `unix_seconds`, in seconds east.
133    pub fn offset(&self, unix_seconds: i64) -> i32 {
134        self.zone
135            .find_local_time_type(unix_seconds)
136            .map_or(0, |kind| kind.ut_offset())
137    }
138
139    /// The offset that turns wall-clock time `local`, in seconds as if UTC,
140    /// into UTC, as Node chooses it: the earlier instant of a time that
141    /// occurs twice, and the offset before the change for a skipped time.
142    pub fn local_offset(&self, local: i64) -> i32 {
143        const DAY: i64 = 86_400;
144        let before = self.offset(local.saturating_sub(DAY));
145        let after = self.offset(local.saturating_add(DAY));
146        [before, after]
147            .into_iter()
148            .find(|&offset| self.offset(local.saturating_sub(i64::from(offset))) == offset)
149            .unwrap_or(before)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    /// 2026-01-15 and 2026-07-15, at noon UTC.
158    const WINTER: i64 = 1_768_478_400;
159    const SUMMER: i64 = 1_784_116_800;
160
161    #[test]
162    fn named_zone_lookup() {
163        let tokyo = named("asia/TOKYO").expect("Asia/Tokyo is in zoneinfo");
164        assert_eq!(tokyo.name(), Some("Asia/Tokyo"));
165        assert_eq!(tokyo.offset(WINTER), 9 * 3600);
166        assert_eq!(
167            named("America/New_York").map(|zone| zone.offset(SUMMER)),
168            Some(-4 * 3600)
169        );
170        for name in [
171            "Foo/Bar",
172            "",
173            "Asia",
174            "../Asia/Tokyo",
175            "/etc/localtime",
176            "EST5EDT,M3.2.0,M11.1.0",
177        ] {
178            assert!(named(name).is_none(), "{name:?}");
179        }
180    }
181
182    #[test]
183    fn invalid_tz_means_utc() {
184        for tz in ["Invalid/Zone", "", ":", "Asia/Tokyo ", "/no/such/file"] {
185            let zone = Zone::from_tz(Some(tz));
186            assert_eq!((zone.offset(WINTER), zone.offset(SUMMER)), (0, 0), "{tz:?}");
187            let name = tz.is_empty().then_some("Etc/Unknown");
188            assert_eq!(zone.name(), name, "{tz:?}");
189        }
190    }
191
192    #[test]
193    fn unnamed_posix_rules() {
194        let zone = Zone::from_tz(Some("CET-1CEST,M3.5.0,M10.5.0/3"));
195        assert_eq!((zone.offset(WINTER), zone.offset(SUMMER)), (3600, 7200));
196        assert_eq!(zone.name(), None);
197        assert_eq!(Zone::from_tz(Some("EST5")).offset(SUMMER), -5 * 3600);
198    }
199
200    #[test]
201    fn wall_clock_like_node() {
202        const HOUR: i32 = 3600;
203        let new_york = Zone::from_tz(Some("EST5EDT,M3.2.0,M11.1.0"));
204        // 2026-03-08 02:30 is skipped, 03:30 follows the change, 2026-11-01
205        // 01:30 occurs twice and 02:30 follows the change.
206        assert_eq!(new_york.local_offset(1_772_937_000), -5 * HOUR);
207        assert_eq!(new_york.local_offset(1_772_940_600), -4 * HOUR);
208        assert_eq!(new_york.local_offset(1_793_496_600), -4 * HOUR);
209        assert_eq!(new_york.local_offset(1_793_500_200), -5 * HOUR);
210        // East of UTC the day of the change starts before it: 2026-10-04 00:00,
211        // the skipped 02:30, and 03:30.
212        let sydney = Zone::from_tz(Some("AEST-10AEDT,M10.1.0,M4.1.0/3"));
213        assert_eq!(sydney.local_offset(1_791_072_000), 10 * HOUR);
214        assert_eq!(sydney.local_offset(1_791_081_000), 10 * HOUR);
215        assert_eq!(sydney.local_offset(1_791_084_600), 11 * HOUR);
216    }
217
218    #[test]
219    fn zone_files_keep_name() {
220        // Skipped on systems without a time zone database.
221        if !Path::new("/usr/share/zoneinfo/America/New_York").is_file() {
222            return;
223        }
224        let zone = Zone::from_tz(Some("America/New_York"));
225        assert_eq!(
226            (zone.offset(WINTER), zone.offset(SUMMER)),
227            (-5 * 3600, -4 * 3600)
228        );
229        assert_eq!(zone.name(), Some("America/New_York"));
230        assert_eq!(
231            Zone::from_tz(Some(":America/New_York")).name(),
232            Some("America/New_York")
233        );
234        assert_eq!(Zone::from_tz(Some("Etc/UTC")).name(), Some("UTC"));
235        assert_eq!(
236            Zone::from_tz(Some("/usr/share/zoneinfo/America/New_York")).name(),
237            None
238        );
239    }
240}