use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LocalType {
offset: i32,
is_dst: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TimeZone {
transitions: Vec<i64>,
types: Vec<LocalType>,
indices: Vec<u8>,
}
impl TimeZone {
pub(crate) fn utc() -> Self {
Self {
transitions: Vec::new(),
types: vec![LocalType {
offset: 0,
is_dst: false,
}],
indices: Vec::new(),
}
}
pub(crate) fn local() -> &'static Self {
static LOCAL: OnceLock<TimeZone> = OnceLock::new();
LOCAL.get_or_init(|| {
local_zone_path()
.and_then(|path| std::fs::read(path).ok())
.and_then(|bytes| Self::parse(&bytes))
.unwrap_or_else(Self::utc)
})
}
pub(crate) fn parse(bytes: &[u8]) -> Option<Self> {
let header = Header::parse(bytes, 0)?;
if header.version >= b'2' {
let second = header.data_end(TimeWidth::Bits32);
let header = Header::parse(bytes, second)?;
Self::parse_block(bytes, &header, second + Header::LEN, TimeWidth::Bits64)
} else {
Self::parse_block(bytes, &header, Header::LEN, TimeWidth::Bits32)
}
}
fn parse_block(bytes: &[u8], header: &Header, start: usize, width: TimeWidth) -> Option<Self> {
let stride = width.bytes();
let mut pos = start;
let mut transitions = Vec::with_capacity(header.timecnt);
for _ in 0..header.timecnt {
let raw = bytes.get(pos..pos.checked_add(stride)?)?;
transitions.push(match width {
TimeWidth::Bits32 => i64::from(i32::from_be_bytes(raw.try_into().ok()?)),
TimeWidth::Bits64 => i64::from_be_bytes(raw.try_into().ok()?),
});
pos += stride;
}
let indices = bytes.get(pos..pos.checked_add(header.timecnt)?)?.to_vec();
pos += header.timecnt;
let mut types = Vec::with_capacity(header.typecnt);
for _ in 0..header.typecnt {
let record = bytes.get(pos..pos.checked_add(6)?)?;
types.push(LocalType {
offset: i32::from_be_bytes(record[..4].try_into().ok()?),
is_dst: record[4] != 0,
});
pos += 6;
}
if types.is_empty() || indices.iter().any(|&i| usize::from(i) >= types.len()) {
return None;
}
if transitions.windows(2).any(|pair| pair[0] > pair[1]) {
return None;
}
Some(Self {
transitions,
types,
indices,
})
}
fn type_at(&self, unix_seconds: i64) -> LocalType {
let after = self
.transitions
.partition_point(|&transition| transition <= unix_seconds);
match after.checked_sub(1) {
Some(index) => self.types[usize::from(self.indices[index])],
None => self
.types
.iter()
.find(|local| !local.is_dst)
.copied()
.unwrap_or(self.types[0]),
}
}
pub(crate) fn offset_at(&self, unix_seconds: i64) -> i32 {
self.type_at(unix_seconds).offset
}
pub(crate) fn offset_for_local(&self, local_seconds: i64) -> i32 {
let mut best: Option<LocalType> = None;
for candidate in self.candidates(local_seconds) {
if self.type_at(local_seconds - i64::from(candidate.offset)) != candidate {
continue;
}
let better = match best {
None => true,
Some(current) => current.is_dst && !candidate.is_dst,
};
if better {
best = Some(candidate);
}
}
best.or_else(|| {
self.candidates(local_seconds)
.into_iter()
.find(|local| !local.is_dst)
})
.unwrap_or_else(|| self.type_at(local_seconds))
.offset
}
fn candidates(&self, local_seconds: i64) -> Vec<LocalType> {
const DAY: i64 = 86_400;
let mut found: Vec<LocalType> = Vec::new();
for probe in [local_seconds - DAY, local_seconds, local_seconds + DAY] {
let local = self.type_at(probe);
if !found.contains(&local) {
found.push(local);
}
}
found
}
}
fn local_zone_path() -> Option<PathBuf> {
const DEFAULT_DIR: &str = "/usr/share/zoneinfo";
let Some(name) = std::env::var_os("TZ") else {
return Some(PathBuf::from("/etc/localtime"));
};
let name = name.to_str()?;
let name = name.strip_prefix(':').unwrap_or(name);
if name.is_empty() {
return None;
}
if Path::new(name).is_absolute() {
return Some(PathBuf::from(name));
}
if name
.split('/')
.any(|part| part.is_empty() || part == "." || part == "..")
{
return None;
}
let dir = std::env::var_os("TZDIR").map_or_else(|| PathBuf::from(DEFAULT_DIR), PathBuf::from);
Some(dir.join(name))
}
#[derive(Clone, Copy)]
enum TimeWidth {
Bits32,
Bits64,
}
impl TimeWidth {
fn bytes(self) -> usize {
match self {
Self::Bits32 => 4,
Self::Bits64 => 8,
}
}
}
struct Header {
version: u8,
isutcnt: usize,
isstdcnt: usize,
leapcnt: usize,
timecnt: usize,
typecnt: usize,
charcnt: usize,
}
impl Header {
const LEN: usize = 44;
fn parse(bytes: &[u8], at: usize) -> Option<Self> {
let header = bytes.get(at..at.checked_add(Self::LEN)?)?;
if &header[..4] != b"TZif" {
return None;
}
let counts: Vec<usize> = header[20..44]
.chunks_exact(4)
.map(|chunk| u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) as usize)
.collect();
Some(Self {
version: header[4],
isutcnt: counts[0],
isstdcnt: counts[1],
leapcnt: counts[2],
timecnt: counts[3],
typecnt: counts[4],
charcnt: counts[5],
})
}
fn data_end(&self, width: TimeWidth) -> usize {
let leap_size = width.bytes() + 4;
Self::LEN
+ self.timecnt * width.bytes()
+ self.timecnt
+ self.typecnt * 6
+ self.charcnt
+ self.leapcnt * leap_size
+ self.isstdcnt
+ self.isutcnt
}
}
#[cfg(test)]
mod tests {
use super::{LocalType, TimeZone};
const LONDON: &[u8] = include_bytes!("../tests/fixtures/tz/Europe_London");
const NEW_YORK: &[u8] = include_bytes!("../tests/fixtures/tz/America_New_York");
const KOLKATA: &[u8] = include_bytes!("../tests/fixtures/tz/Asia_Kolkata");
const LORD_HOWE: &[u8] = include_bytes!("../tests/fixtures/tz/Australia_Lord_Howe");
const GMT_MINUS_5: &[u8] = include_bytes!("../tests/fixtures/tz/Etc_GMT_5");
fn zone(name: &str) -> TimeZone {
let bytes = match name {
"Europe/London" => LONDON,
"America/New_York" => NEW_YORK,
"Asia/Kolkata" => KOLKATA,
"Australia/Lord_Howe" => LORD_HOWE,
"Etc/GMT+5" => GMT_MINUS_5,
other => panic!("no fixture for {other}"),
};
TimeZone::parse(bytes).expect("fixture parses")
}
#[test]
fn offset_at_matches_the_zone_database() {
let cases = [
("Europe/London", 1_768_478_400, 0), ("Europe/London", 1_784_116_800, 3_600), ("America/New_York", 1_768_478_400, -18_000), ("America/New_York", 1_784_116_800, -14_400), ("Asia/Kolkata", 1_784_116_800, 19_800), ("Australia/Lord_Howe", 1_768_478_400, 39_600), ("Australia/Lord_Howe", 1_784_116_800, 37_800), ("Etc/GMT+5", 1_784_116_800, -18_000), ("Europe/London", 486_475_200, 3_600), ("America/New_York", 946_681_200, -18_000), ];
for (name, instant, expected) in cases {
assert_eq!(
zone(name).offset_at(instant),
expected,
"{name} at {instant}"
);
}
}
#[test]
fn offset_for_local_matches_the_zone_database() {
let cases = [
("Europe/London", 1_768_478_400, 0), ("Europe/London", 1_784_127_600, 3_600), ("America/New_York", 1_784_109_600, -14_400), ("Asia/Kolkata", 1_784_143_800, 19_800), ("Australia/Lord_Howe", 1_784_154_600, 37_800), ("Etc/GMT+5", 1_784_098_800, -18_000), ("Europe/London", 2_131_272_000, 3_600), ];
for (name, reading, expected) in cases {
assert_eq!(
zone(name).offset_for_local(reading),
expected,
"{name} reading {reading}"
);
}
}
#[test]
fn the_two_directions_round_trip() {
for name in [
"Europe/London",
"America/New_York",
"Asia/Kolkata",
"Australia/Lord_Howe",
"Etc/GMT+5",
] {
let zone = zone(name);
let mut instant = 1_767_225_600; while instant < 1_798_761_600 {
let reading = instant + i64::from(zone.offset_at(instant));
let back = reading - i64::from(zone.offset_for_local(reading));
let back_reading = back + i64::from(zone.offset_at(back));
assert_eq!(back_reading, reading, "{name} at {instant}");
instant += 21_600;
}
}
}
#[test]
fn ambiguous_and_skipped_readings_resolve_to_standard_time() {
assert_eq!(zone("Europe/London").offset_for_local(1_792_891_800), 0);
assert_eq!(zone("Europe/London").offset_for_local(1_774_747_800), 0);
assert_eq!(
zone("America/New_York").offset_for_local(1_793_496_600),
-18_000
);
}
#[test]
fn a_zone_with_no_transitions_still_has_its_offset() {
let zone = zone("Etc/GMT+5");
assert!(zone.transitions.is_empty());
assert_eq!(zone.offset_at(0), -18_000);
assert_eq!(zone.offset_at(i64::MAX / 2), -18_000);
}
#[test]
fn utc_is_the_fallback_and_has_no_offset() {
let zone = TimeZone::utc();
assert_eq!(zone.offset_at(1_784_116_800), 0);
assert_eq!(zone.offset_for_local(1_784_116_800), 0);
assert_eq!(
zone.types,
vec![LocalType {
offset: 0,
is_dst: false
}]
);
}
#[test]
fn rejects_files_that_are_not_a_usable_zone() {
assert!(TimeZone::parse(b"").is_none());
assert!(TimeZone::parse(b"not a tzif file at all").is_none());
assert!(TimeZone::parse(&LONDON[..20]).is_none(), "truncated header");
assert!(TimeZone::parse(&LONDON[..200]).is_none(), "truncated body");
let mut corrupt = LONDON.to_vec();
let indices_start = corrupt.len() - 40;
corrupt[indices_start] = 0xff;
let _ = TimeZone::parse(&corrupt);
}
}