1use std::sync::Arc;
8
9#[derive(Clone, Debug, PartialEq)]
25pub enum Zone {
26 Utc,
28 Local,
31 Fixed(i32),
33 Rules(Arc<Rules>),
36}
37
38#[derive(Debug, PartialEq)]
41pub struct Rules {
42 transitions: Vec<(i64, i32)>,
44 before: i32,
46 rule: Option<Posix>,
48}
49
50#[derive(Clone, Debug, PartialEq)]
53struct Posix {
54 std: i32,
56 dst: Option<Summer>,
57}
58
59#[derive(Clone, Debug, PartialEq)]
60struct Summer {
61 offset: i32,
62 start: (Day, i32),
63 end: (Day, i32),
64}
65
66#[derive(Clone, Copy, Debug, PartialEq)]
68enum Day {
69 Julian(u32),
71 Zero(u32),
73 Month(u32, u32, u32),
75}
76
77impl Zone {
78 pub fn offset_at(&self, unix: i64) -> i32 {
81 match self {
82 Zone::Utc => 0,
83 Zone::Fixed(offset) => *offset,
84 Zone::Local => local_offset(unix),
85 Zone::Rules(rules) => rules.offset_at(unix),
86 }
87 }
88
89 pub fn posix(rule: &str) -> Result<Zone, String> {
93 let posix = parse_posix(rule).ok_or_else(|| format!("{rule:?} is not a POSIX TZ rule"))?;
94 Ok(Zone::Rules(Arc::new(Rules {
95 transitions: Vec::new(),
96 before: posix.std,
97 rule: Some(posix),
98 })))
99 }
100
101 pub fn tzif(bytes: &[u8]) -> Result<Zone, String> {
104 parse_tzif(bytes)
105 .map(|rules| Zone::Rules(Arc::new(rules)))
106 .ok_or_else(|| "not a TZif file".to_string())
107 }
108
109 pub fn named(name: &str) -> Result<Zone, String> {
113 if name.split('/').any(|part| part == ".." || part.is_empty()) {
114 return Err(format!("{name:?} is not a zone name"));
115 }
116 let dir = std::env::var_os("TZDIR")
117 .filter(|d| !d.is_empty())
118 .map(std::path::PathBuf::from)
119 .unwrap_or_else(|| "/usr/share/zoneinfo".into());
120 let bytes = std::fs::read(dir.join(name)).map_err(|e| format!("{name}: {e}"))?;
121 Zone::tzif(&bytes)
122 }
123}
124
125impl Rules {
126 fn offset_at(&self, unix: i64) -> i32 {
127 match self.transitions.partition_point(|&(at, _)| at <= unix) {
128 0 if !self.transitions.is_empty() => self.before,
129 n if n < self.transitions.len() => self.transitions[n - 1].1,
130 n => match &self.rule {
131 Some(rule) => rule.offset_at(unix),
132 None => self
133 .transitions
134 .get(n.wrapping_sub(1))
135 .map_or(self.before, |t| t.1),
136 },
137 }
138 }
139}
140
141impl Posix {
142 fn offset_at(&self, unix: i64) -> i32 {
143 let Some(dst) = &self.dst else {
144 return self.std;
145 };
146 let year =
148 crate::datetime::Date::from_days((unix + self.std as i64).div_euclid(86_400)).year;
149 let start = day_start(year, dst.start.0) + dst.start.1 as i64 - self.std as i64;
151 let end = day_start(year, dst.end.0) + dst.end.1 as i64 - dst.offset as i64;
152 let summer = if start < end {
153 unix >= start && unix < end
154 } else {
155 !(unix >= end && unix < start)
157 };
158 if summer { dst.offset } else { self.std }
159 }
160}
161
162fn day_start(year: i32, day: Day) -> i64 {
164 use crate::datetime::Date;
165 let jan1 = Date::new(year, 1, 1).days();
166 let days = match day {
167 Day::Julian(n) => {
168 let n = n.clamp(1, 365) as i64;
169 jan1 + n - 1 + (Date::is_leap_year(year) && n >= 60) as i64
171 }
172 Day::Zero(n) => jan1 + n.min(365) as i64,
173 Day::Month(month, week, weekday) => {
174 let month = month.clamp(1, 12);
175 let first = Date::new(year, month, 1);
176 let first_wd = (first.weekday() + 1) % 7;
178 let mut day = 1 + (weekday + 7 - first_wd) % 7 + (week.clamp(1, 5) - 1) * 7;
179 let last = Date::days_in_month(year, month);
180 while day > last {
181 day -= 7;
182 }
183 Date::new(year, month, day).days()
184 }
185 };
186 days * 86_400
187}
188
189fn parse_posix(text: &str) -> Option<Posix> {
191 let mut s = text.trim().trim_start_matches(':');
192 let _std_name = take_name(&mut s)?;
193 let std = -take_offset(&mut s)?;
194 if s.is_empty() {
195 return Some(Posix { std, dst: None });
196 }
197 let _dst_name = take_name(&mut s)?;
198 let offset = if s.starts_with(',') || s.is_empty() {
199 std + 3600
200 } else {
201 -take_offset(&mut s)?
202 };
203 let (start, end) = if s.is_empty() {
204 ((Day::Month(3, 2, 0), 7200), (Day::Month(11, 1, 0), 7200))
206 } else {
207 s = s.strip_prefix(',')?;
208 let start = take_rule_day(&mut s)?;
209 s = s.strip_prefix(',')?;
210 let end = take_rule_day(&mut s)?;
211 if !s.is_empty() {
212 return None;
213 }
214 (start, end)
215 };
216 Some(Posix {
217 std,
218 dst: Some(Summer { offset, start, end }),
219 })
220}
221
222fn take_name<'a>(s: &mut &'a str) -> Option<&'a str> {
224 if let Some(rest) = s.strip_prefix('<') {
225 let end = rest.find('>')?;
226 let name = &rest[..end];
227 *s = &rest[end + 1..];
228 return Some(name);
229 }
230 let end = s
231 .find(|c: char| !c.is_ascii_alphabetic())
232 .unwrap_or(s.len());
233 if end < 3 {
234 return None;
235 }
236 let name = &s[..end];
237 *s = &s[end..];
238 Some(name)
239}
240
241fn take_offset(s: &mut &str) -> Option<i32> {
243 let (sign, rest) = match s.as_bytes().first() {
244 Some(b'-') => (-1, &s[1..]),
245 Some(b'+') => (1, &s[1..]),
246 _ => (1, *s),
247 };
248 let end = rest
249 .find(|c: char| !(c.is_ascii_digit() || c == ':'))
250 .unwrap_or(rest.len());
251 let mut total = 0i32;
252 for (i, part) in rest[..end].split(':').enumerate() {
253 if i > 2 || part.is_empty() || part.len() > 3 {
254 return None;
255 }
256 let n: i32 = part.parse().ok()?;
257 total += n * [3600, 60, 1][i];
258 }
259 *s = &rest[end..];
260 Some(sign * total)
261}
262
263fn take_rule_day(s: &mut &str) -> Option<(Day, i32)> {
265 let end = s.find([',', '/']).unwrap_or(s.len());
266 let part = &s[..end];
267 let day = if let Some(n) = part.strip_prefix('J') {
268 Day::Julian(n.parse().ok().filter(|n| (1..=365).contains(n))?)
269 } else if let Some(m) = part.strip_prefix('M') {
270 let mut it = m.split('.').map(|p| p.parse::<u32>().ok());
271 let (month, week, day) = (it.next()??, it.next()??, it.next()??);
272 if it.next().is_some() || !(1..=12).contains(&month) || !(1..=5).contains(&week) || day > 6
273 {
274 return None;
275 }
276 Day::Month(month, week, day)
277 } else {
278 Day::Zero(part.parse().ok().filter(|n| *n <= 365)?)
279 };
280 *s = &s[end..];
281 let time = match s.strip_prefix('/') {
282 Some(rest) => {
283 *s = rest;
284 take_offset(s)?
285 }
286 None => 7200,
287 };
288 Some((day, time))
289}
290
291fn parse_tzif(bytes: &[u8]) -> Option<Rules> {
293 struct Header {
294 version: u8,
295 isut: usize,
296 isstd: usize,
297 leap: usize,
298 time: usize,
299 types: usize,
300 chars: usize,
301 }
302 fn header(b: &[u8]) -> Option<Header> {
303 if b.len() < 44 || &b[..4] != b"TZif" {
304 return None;
305 }
306 let count =
307 |i: usize| u32::from_be_bytes(b[20 + i * 4..24 + i * 4].try_into().unwrap()) as usize;
308 Some(Header {
309 version: b[4],
310 isut: count(0),
311 isstd: count(1),
312 leap: count(2),
313 time: count(3),
314 types: count(4),
315 chars: count(5),
316 })
317 }
318 fn block_len(h: &Header, time_size: usize) -> usize {
319 h.time * time_size
320 + h.time
321 + h.types * 6
322 + h.chars
323 + h.leap * (time_size + 4)
324 + h.isstd
325 + h.isut
326 }
327 let first = header(bytes)?;
328 let (h, data, time_size) = if first.version >= b'2' {
329 let second_at = 44 + block_len(&first, 4);
330 let second = header(bytes.get(second_at..)?)?;
331 (second, bytes.get(second_at + 44..)?, 8)
332 } else {
333 (first, &bytes[44..], 4)
334 };
335 let len = block_len(&h, time_size);
336 if data.len() < len || h.types == 0 {
337 return None;
338 }
339 let times = &data[..h.time * time_size];
340 let indices = &data[h.time * time_size..h.time * time_size + h.time];
341 let types_at = h.time * time_size + h.time;
342 let utoff = |i: usize| -> Option<i32> {
343 let at = types_at + i * 6;
344 Some(i32::from_be_bytes(data.get(at..at + 4)?.try_into().ok()?))
345 };
346 let mut transitions = Vec::with_capacity(h.time);
347 for i in 0..h.time {
348 let at = if time_size == 8 {
349 i64::from_be_bytes(times[i * 8..i * 8 + 8].try_into().ok()?)
350 } else {
351 i32::from_be_bytes(times[i * 4..i * 4 + 4].try_into().ok()?) as i64
352 };
353 let index = indices[i] as usize;
354 if index >= h.types {
355 return None;
356 }
357 transitions.push((at, utoff(index)?));
358 }
359 let before = utoff(0)?;
361 let rule = (time_size == 8)
363 .then(|| {
364 let footer = data.get(len..)?;
365 let text = std::str::from_utf8(footer).ok()?;
366 let text = text.strip_prefix('\n')?;
367 let line = &text[..text.find('\n')?];
368 (!line.is_empty()).then(|| parse_posix(line)).flatten()
369 })
370 .flatten();
371 Some(Rules {
372 transitions,
373 before,
374 rule,
375 })
376}
377
378pub(crate) fn local_offset(unix: i64) -> i32 {
380 platform::offset(unix).unwrap_or_else(|| fallback().offset_at(unix))
381}
382
383fn fallback() -> &'static Zone {
386 static ZONE: std::sync::OnceLock<Zone> = std::sync::OnceLock::new();
387 ZONE.get_or_init(|| {
388 if let Some(tz) = std::env::var("TZ").ok().filter(|t| !t.is_empty()) {
389 let tz = tz.trim_start_matches(':');
390 if let Ok(zone) = Zone::named(tz) {
391 return zone;
392 }
393 if let Ok(zone) = Zone::posix(tz) {
394 return zone;
395 }
396 }
397 std::fs::read("/etc/localtime")
398 .ok()
399 .and_then(|bytes| Zone::tzif(&bytes).ok())
400 .unwrap_or(Zone::Utc)
401 })
402}
403
404#[cfg(any(
405 target_os = "linux",
406 target_os = "android",
407 target_os = "macos",
408 target_os = "ios",
409 target_os = "freebsd",
410 target_os = "netbsd",
411 target_os = "openbsd",
412 target_os = "dragonfly"
413))]
414mod platform {
415 #[allow(unsafe_code)]
416 unsafe extern "C" {
417 fn tzset();
418 }
419
420 #[allow(unsafe_code)]
423 pub(super) fn offset(unix: i64) -> Option<i32> {
424 static TZSET: std::sync::Once = std::sync::Once::new();
425 unsafe {
429 TZSET.call_once(|| tzset());
430 #[allow(clippy::useless_conversion)]
432 let time: libc::time_t = unix.try_into().ok()?;
433 let mut tm: libc::tm = std::mem::zeroed();
434 if libc::localtime_r(&time, &mut tm).is_null() {
435 return None;
436 }
437 i32::try_from(tm.tm_gmtoff).ok()
438 }
439 }
440}
441
442#[cfg(windows)]
443mod platform {
444 use windows_sys::Win32::Foundation::SYSTEMTIME;
445 use windows_sys::Win32::System::Time::SystemTimeToTzSpecificLocalTime;
446
447 use crate::datetime::{Date, DateTime, Time};
448
449 #[allow(unsafe_code)]
453 pub(super) fn offset(unix: i64) -> Option<i32> {
454 if std::env::var_os("TZ").is_some_and(|tz| !tz.is_empty()) {
455 return None;
456 }
457 let utc = DateTime::from_unix(unix, &super::Zone::Utc);
458 let st = |at: DateTime| SYSTEMTIME {
459 wYear: at.date.year as u16,
460 wMonth: at.date.month as u16,
461 wDayOfWeek: 0,
462 wDay: at.date.day as u16,
463 wHour: at.time.hour as u16,
464 wMinute: at.time.minute as u16,
465 wSecond: at.time.second as u16,
466 wMilliseconds: 0,
467 };
468 let universal = st(utc);
469 let mut local = st(utc);
470 let ok =
473 unsafe { SystemTimeToTzSpecificLocalTime(std::ptr::null(), &universal, &mut local) };
474 if ok == 0 {
475 return None;
476 }
477 let local = DateTime::new(
478 Date::new(local.wYear as i32, local.wMonth as u32, local.wDay as u32),
479 Time::new(
480 local.wHour as u32,
481 local.wMinute as u32,
482 local.wSecond as u32,
483 ),
484 );
485 i32::try_from(local.to_unix(&super::Zone::Utc) - unix).ok()
486 }
487}
488
489#[cfg(not(any(
490 windows,
491 target_os = "linux",
492 target_os = "android",
493 target_os = "macos",
494 target_os = "ios",
495 target_os = "freebsd",
496 target_os = "netbsd",
497 target_os = "openbsd",
498 target_os = "dragonfly"
499)))]
500mod platform {
501 pub(super) fn offset(_unix: i64) -> Option<i32> {
502 None
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::datetime::{Date, DateTime, Time};
510
511 fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> i64 {
512 DateTime::new(Date::new(y, m, d), Time::new(h, min, 0)).to_unix(&Zone::Utc)
513 }
514
515 #[test]
516 fn posix_rules_follow_summer_time() {
517 let cet = Zone::posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
518 assert_eq!(cet.offset_at(at(2026, 3, 29, 0, 59)), 3600);
520 assert_eq!(cet.offset_at(at(2026, 3, 29, 1, 0)), 7200);
521 assert_eq!(cet.offset_at(at(2026, 10, 25, 0, 59)), 7200);
522 assert_eq!(cet.offset_at(at(2026, 10, 25, 1, 0)), 3600);
523 let us = Zone::posix("EST5EDT").unwrap();
524 assert_eq!(us.offset_at(at(2026, 3, 8, 6, 59)), -5 * 3600);
526 assert_eq!(us.offset_at(at(2026, 3, 8, 7, 0)), -4 * 3600);
527 assert_eq!(us.offset_at(at(2026, 11, 1, 5, 59)), -4 * 3600);
528 assert_eq!(us.offset_at(at(2026, 11, 1, 6, 0)), -5 * 3600);
529 let nz = Zone::posix("NZST-12NZDT,M9.5.0,M4.1.0/3").unwrap();
531 assert_eq!(nz.offset_at(at(2026, 1, 10, 0, 0)), 13 * 3600);
532 assert_eq!(nz.offset_at(at(2026, 7, 10, 0, 0)), 12 * 3600);
533 let tehran = Zone::posix("<+0330>-3:30").unwrap();
534 assert_eq!(tehran.offset_at(0), 3 * 3600 + 1800);
535 for bad in [
536 "",
537 "X",
538 "CET",
539 "CET-1CEST,M13.5.0,M10.5.0",
540 "CET-1CEST,M3.5.0",
541 ] {
542 assert!(Zone::posix(bad).is_err(), "{bad:?}");
543 }
544 }
545
546 fn tzif() -> Vec<u8> {
548 fn header(out: &mut Vec<u8>, version: u8, time: u32, types: u32, chars: u32) {
549 out.extend(b"TZif");
550 out.push(version);
551 out.extend([0; 15]);
552 for n in [0, 0, 0, time, types, chars] {
553 out.extend(n.to_be_bytes());
554 }
555 }
556 let mut out = Vec::new();
557 header(&mut out, b'2', 0, 1, 4);
558 out.extend(3600i32.to_be_bytes());
559 out.extend([0, 0]);
560 out.extend(b"CET\0");
561 header(&mut out, b'2', 1, 2, 9);
562 out.extend(at(2026, 3, 29, 1, 0).to_be_bytes());
563 out.push(1);
564 out.extend(3600i32.to_be_bytes());
565 out.extend([0, 0]);
566 out.extend(7200i32.to_be_bytes());
567 out.extend([1, 4]);
568 out.extend(b"CET\0CEST\0");
569 out.extend(b"\nCET-1CEST,M3.5.0,M10.5.0/3\n");
570 out
571 }
572
573 #[test]
574 fn tzif_files_read_with_their_footer() {
575 let zone = Zone::tzif(&tzif()).unwrap();
576 assert_eq!(zone.offset_at(at(2026, 3, 1, 0, 0)), 3600);
577 assert_eq!(zone.offset_at(at(2026, 3, 29, 1, 0)), 7200);
578 assert_eq!(zone.offset_at(at(2026, 10, 25, 1, 0)), 3600);
580 assert_eq!(zone.offset_at(at(2030, 7, 1, 0, 0)), 7200);
581 assert!(Zone::tzif(b"nope").is_err());
582 if let Ok(london) = Zone::named("Europe/London") {
584 assert_eq!(london.offset_at(at(2026, 1, 15, 12, 0)), 0);
585 assert_eq!(london.offset_at(at(2026, 7, 15, 12, 0)), 3600);
586 assert_eq!(london.offset_at(at(2040, 7, 15, 12, 0)), 3600);
587 }
588 assert!(Zone::named("../etc/passwd").is_err());
589 }
590}