1use std::collections::HashMap;
5use std::path::Path;
6use std::sync::{LazyLock, Mutex};
7
8use tz::{TimeZone, TimeZoneSettings};
9
10#[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
19pub fn local() -> &'static Zone {
21 &LOCAL
22}
23
24static NAMED: LazyLock<Mutex<HashMap<String, &'static Zone>>> = LazyLock::new(Mutex::default);
27
28pub 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 zone.name()?;
43 Some(*cache.entry(key).or_insert(zone))
44}
45
46fn 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 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 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 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 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 pub fn name(&self) -> Option<&str> {
129 self.name.as_deref()
130 }
131
132 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 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 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 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 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 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}