use std::path::{Path, PathBuf};
use std::sync::OnceLock;
enum Source {
Utc,
File(PathBuf),
}
const ZONE_DIR: &str = "/usr/share/zoneinfo";
fn source(tz: Option<&str>, tzdir: Option<&str>) -> Option<Source> {
let Some(tz) = tz else {
return Some(Source::File(PathBuf::from("/etc/localtime")));
};
let name = tz.strip_prefix(':').unwrap_or(tz);
if name.is_empty() {
return Some(Source::Utc);
}
if name.starts_with('/') {
return Some(Source::File(PathBuf::from(name)));
}
let sane = name.split('/').all(|part| !part.is_empty() && part != "." && part != "..");
sane.then(|| Source::File(PathBuf::from(tzdir.unwrap_or(ZONE_DIR)).join(name)))
}
enum Zone {
Utc,
Tzif(Vec<u8>),
Unknown,
}
fn zone() -> &'static Zone {
static ZONE: OnceLock<Zone> = OnceLock::new();
ZONE.get_or_init(|| {
let tz = std::env::var("TZ").ok();
let tzdir = std::env::var("TZDIR").ok();
match source(tz.as_deref(), tzdir.as_deref()) {
Some(Source::Utc) => Zone::Utc,
Some(Source::File(path)) => load(&path),
None => Zone::Unknown,
}
})
}
const MAX_ZONE_FILE: u64 = 256 * 1024;
fn load(path: &Path) -> Zone {
use std::io::Read as _;
let plain = std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() <= MAX_ZONE_FILE);
if !plain {
return Zone::Unknown;
}
let mut data = Vec::new();
let read = std::fs::File::open(path).and_then(|file| file.take(MAX_ZONE_FILE + 1).read_to_end(&mut data));
match read {
Ok(_) if u64::try_from(data.len()).is_ok_and(|len| len <= MAX_ZONE_FILE) => Zone::Tzif(data),
_ => Zone::Unknown,
}
}
pub(super) fn offset_minutes(unix: i64) -> Option<i16> {
let seconds = match zone() {
Zone::Utc => 0,
Zone::Tzif(data) => offset_seconds(data, unix)?,
Zone::Unknown => return None,
};
i16::try_from(seconds / 60).ok()
}
struct Header {
version: u8,
isutcnt: usize,
isstdcnt: usize,
leapcnt: usize,
timecnt: usize,
typecnt: usize,
charcnt: usize,
}
fn count(data: &[u8], at: usize) -> Option<usize> {
let bytes: [u8; 4] = data.get(at..at.checked_add(4)?)?.try_into().ok()?;
usize::try_from(u32::from_be_bytes(bytes)).ok()
}
fn header(data: &[u8], at: usize) -> Option<Header> {
if data.get(at..at.checked_add(44)?)?.get(..4)? != b"TZif" {
return None;
}
Some(Header {
version: *data.get(at + 4)?,
isutcnt: count(data, at + 20)?,
isstdcnt: count(data, at + 24)?,
leapcnt: count(data, at + 28)?,
timecnt: count(data, at + 32)?,
typecnt: count(data, at + 36)?,
charcnt: count(data, at + 40)?,
})
}
fn block_size(header: &Header, time: usize) -> Option<usize> {
let sizes = [
header.timecnt.checked_mul(time)?,
header.timecnt,
header.typecnt.checked_mul(6)?,
header.charcnt,
header.leapcnt.checked_mul(time.checked_add(4)?)?,
header.isstdcnt,
header.isutcnt,
];
sizes.iter().try_fold(0usize, |total, size| total.checked_add(*size))
}
fn offset_seconds(data: &[u8], unix: i64) -> Option<i32> {
let first = header(data, 0)?;
let (header, at, time) = if first.version >= b'2' {
let second = 44usize.checked_add(block_size(&first, 4)?)?;
(header(data, second)?, second.checked_add(44)?, 8usize)
} else {
(first, 44usize, 4usize)
};
if header.typecnt == 0 {
return None;
}
let transitions = data.get(at..at.checked_add(header.timecnt.checked_mul(time)?)?)?;
let indices_at = at.checked_add(transitions.len())?;
let indices = data.get(indices_at..indices_at.checked_add(header.timecnt)?)?;
let types_at = indices_at.checked_add(header.timecnt)?;
let types = data.get(types_at..types_at.checked_add(header.typecnt.checked_mul(6)?)?)?;
let mut chosen = None;
for (index, start) in indices.iter().enumerate() {
if instant(transitions, index, time)? > unix {
break;
}
chosen = Some(usize::from(*start));
}
let chosen = chosen.unwrap_or_else(|| types.chunks_exact(6).position(|entry| entry[4] == 0).unwrap_or(0));
let entry = types.get(chosen.checked_mul(6)?..)?.get(..4)?;
let bytes: [u8; 4] = entry.try_into().ok()?;
Some(i32::from_be_bytes(bytes))
}
fn instant(transitions: &[u8], index: usize, time: usize) -> Option<i64> {
let at = index.checked_mul(time)?;
let bytes = transitions.get(at..at.checked_add(time)?)?;
match time {
4 => Some(i64::from(i32::from_be_bytes(bytes.try_into().ok()?))),
8 => Some(i64::from_be_bytes(bytes.try_into().ok()?)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tzif(offsets: [(i32, u8); 2], transitions: &[(i64, u8)]) -> Vec<u8> {
let mut data = Vec::new();
let mut header = |timecnt: usize, typecnt: usize| {
data.extend_from_slice(b"TZif2");
data.extend_from_slice(&[0; 15]);
for value in [0usize, 0, 0, timecnt, typecnt, 0] {
data.extend_from_slice(&u32::try_from(value).expect("small count").to_be_bytes());
}
};
header(0, 0);
header(transitions.len(), offsets.len());
for (instant, _) in transitions {
data.extend_from_slice(&instant.to_be_bytes());
}
for (_, index) in transitions {
data.push(*index);
}
for (offset, daylight) in offsets {
data.extend_from_slice(&offset.to_be_bytes());
data.push(daylight);
data.push(0);
}
data
}
fn eastern_europe() -> Vec<u8> {
tzif([(7_200, 0), (10_800, 1)], &[(1_774_486_800, 1), (1_793_235_600, 0)])
}
#[test]
fn reads_the_offset_of_each_side_of_a_transition() {
let zone = eastern_europe();
assert_eq!(offset_seconds(&zone, 1_774_486_799), Some(7_200), "the second before the change");
assert_eq!(offset_seconds(&zone, 1_774_486_800), Some(10_800), "summer time starts");
assert_eq!(offset_seconds(&zone, 1_780_000_000), Some(10_800));
assert_eq!(offset_seconds(&zone, 1_793_235_600), Some(7_200), "and ends");
assert_eq!(offset_seconds(&zone, 4_000_000_000), Some(7_200), "after the last change it stays");
}
#[test]
fn before_the_first_transition_the_first_standard_type_is_used() {
let zone = tzif([(10_800, 1), (7_200, 0)], &[(1_774_486_800, 0)]);
assert_eq!(offset_seconds(&zone, 0), Some(7_200));
assert_eq!(offset_seconds(&zone, 1_774_486_800), Some(10_800));
}
#[test]
fn a_zone_with_no_transitions_still_has_an_offset() {
let zone = tzif([(-18_000, 0), (0, 0)], &[]);
assert_eq!(offset_seconds(&zone, 1_774_486_800), Some(-18_000));
}
#[test]
fn broken_files_are_refused_instead_of_guessed() {
assert_eq!(offset_seconds(b"", 0), None);
assert_eq!(offset_seconds(b"not a zone file at all, not even close", 0), None);
let zone = eastern_europe();
for cut in [0, 10, 44, 60, 100, zone.len() - 1] {
assert_eq!(offset_seconds(&zone[..cut], 0), None, "truncated to {cut} bytes");
}
let mut huge = zone.clone();
huge[44 + 32..44 + 36].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(offset_seconds(&huge, 0), None);
let lying = tzif([(7_200, 0), (10_800, 1)], &[(1_774_486_800, 2)]);
assert_eq!(offset_seconds(&lying, 1_774_486_800), None);
assert_eq!(offset_seconds(&lying, 0), Some(7_200), "before it the file is still readable");
}
#[test]
fn minutes_come_from_the_seconds() {
let zone = eastern_europe();
assert_eq!(offset_seconds(&zone, 1_780_000_000).map(|seconds| seconds / 60), Some(180));
}
#[test]
fn the_source_follows_the_tz_variable() {
let path = |tz, tzdir| match source(tz, tzdir) {
Some(Source::File(path)) => Some(path.display().to_string()),
Some(Source::Utc) => Some("UTC".to_owned()),
None => None,
};
assert_eq!(path(None, None).as_deref(), Some("/etc/localtime"));
assert_eq!(path(Some(""), None).as_deref(), Some("UTC"));
assert_eq!(path(Some(":"), None).as_deref(), Some("UTC"));
assert_eq!(path(Some("Europe/Istanbul"), None).as_deref(), Some("/usr/share/zoneinfo/Europe/Istanbul"));
assert_eq!(path(Some(":Europe/Istanbul"), None).as_deref(), Some("/usr/share/zoneinfo/Europe/Istanbul"));
assert_eq!(path(Some("UTC"), Some("/opt/zones")).as_deref(), Some("/opt/zones/UTC"));
assert_eq!(path(Some("/etc/localtime"), None).as_deref(), Some("/etc/localtime"));
assert_eq!(path(Some("../../etc/shadow"), None), None, "a name cannot leave the zone directory");
assert_eq!(path(Some("Europe//Istanbul"), None), None);
}
#[test]
fn only_a_plain_file_of_a_believable_size_is_read() {
let dir = std::env::temp_dir().join(format!("quvyta-zone-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("test directory");
let good = dir.join("good");
std::fs::write(&good, eastern_europe()).expect("write a zone");
assert!(matches!(load(&good), Zone::Tzif(data) if data == eastern_europe()));
let mut huge = eastern_europe();
huge.resize(4 * 1024 * 1024, 0);
let big = dir.join("big");
std::fs::write(&big, &huge).expect("write a huge file");
assert!(matches!(load(&big), Zone::Unknown), "a 4 MiB zone file is not believed");
assert!(matches!(load(&dir), Zone::Unknown));
assert!(matches!(load(&dir.join("absent")), Zone::Unknown));
std::fs::remove_dir_all(&dir).expect("clean");
}
#[cfg(unix)]
#[test]
fn a_device_is_not_read_as_a_zone() {
assert!(matches!(load(Path::new("/dev/zero")), Zone::Unknown));
}
#[test]
fn this_machine_reports_a_believable_offset() {
let Some(minutes) = offset_minutes(1_780_000_000) else {
assert!(matches!(zone(), Zone::Unknown), "a zone was read but gave no offset");
return;
};
assert!((-720..=840).contains(&minutes), "{minutes} minutes is no time zone offset");
}
}