use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq)]
pub struct Zone {
pub name: String,
transitions: Vec<i64>,
indices: Vec<u8>,
types: Vec<i32>,
footer: Option<Rule>,
}
#[derive(Debug, Clone, PartialEq)]
struct Rule {
std_offset: i32,
dst: Option<DstRule>,
}
#[derive(Debug, Clone, PartialEq)]
struct DstRule {
offset: i32,
start: (DateRule, i32),
end: (DateRule, i32),
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum DateRule {
MonthWeekDay(u32, u32, u32),
Julian1(u32),
Julian0(u32),
}
impl Zone {
pub fn utc() -> Zone {
Zone {
name: "UTC".into(),
transitions: vec![],
indices: vec![],
types: vec![0],
footer: None,
}
}
pub fn load(dir: &Path, name: &str) -> Result<Zone, String> {
if name == "UTC" || name == "Etc/UTC" {
return Ok(Zone::utc());
}
let valid = !name.is_empty()
&& !name.contains("..")
&& !name.starts_with('/')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || "/_+-".contains(c));
if !valid {
return Err(format!("invalid time zone name {name:?}"));
}
let bytes = std::fs::read(dir.join(name)).map_err(|e| format!("time zone {name}: {e}"))?;
Zone::parse(name, &bytes)
}
pub fn parse(name: &str, b: &[u8]) -> Result<Zone, String> {
let err = |m: &str| format!("time zone {name}: {m}");
let header = |b: &[u8]| -> Result<(u8, [usize; 6]), String> {
if b.len() < 44 || &b[..4] != b"TZif" {
return Err(err("not a TZif file"));
}
let mut counts = [0usize; 6];
for (i, c) in counts.iter_mut().enumerate() {
*c = u32::from_be_bytes(b[20 + i * 4..24 + i * 4].try_into().unwrap()) as usize;
}
Ok((b[4], counts))
};
let (version, c) = header(b)?;
let [isut, isstd, leap, time, typ, chars] = c;
let v1_len = 44 + time * 5 + typ * 6 + chars + leap * 8 + isstd + isut;
let (data, time_size, counts) = if version >= b'2' {
let rest = b.get(v1_len..).ok_or_else(|| err("truncated"))?;
let (_, c2) = header(rest)?;
(&rest[44..], 8, c2)
} else {
(&b[44..], 4, c)
};
let [isut, isstd, leap, time, typ, chars] = counts;
let need =
time * time_size + time + typ * 6 + chars + leap * (time_size + 4) + isstd + isut;
if data.len() < need {
return Err(err("truncated data block"));
}
let mut pos = 0;
let mut transitions = Vec::with_capacity(time);
for _ in 0..time {
let t = if time_size == 8 {
i64::from_be_bytes(data[pos..pos + 8].try_into().unwrap())
} else {
i32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()) as i64
};
transitions.push(t);
pos += time_size;
}
let indices = data[pos..pos + time].to_vec();
pos += time;
let mut types = Vec::with_capacity(typ);
for _ in 0..typ {
types.push(i32::from_be_bytes(data[pos..pos + 4].try_into().unwrap()));
pos += 6;
}
if indices.iter().any(|&i| i as usize >= types.len()) || types.is_empty() {
return Err(err("invalid type index"));
}
pos += chars + leap * (time_size + 4) + isstd + isut;
let footer = if version >= b'2' {
let rest = &data[pos..];
let text = std::str::from_utf8(rest).unwrap_or("").trim_matches('\n');
if text.is_empty() {
None
} else {
Some(parse_rule(text).ok_or_else(|| err("invalid footer rule"))?)
}
} else {
None
};
Ok(Zone {
name: name.into(),
transitions,
indices,
types,
footer,
})
}
pub fn from_posix(s: &str) -> Option<Zone> {
let rule = parse_rule(s)?;
Some(Zone {
name: s.into(),
transitions: vec![],
indices: vec![],
types: vec![rule.std_offset],
footer: Some(rule),
})
}
pub fn offset_at(&self, t: i64) -> i32 {
match self.transitions.partition_point(|&x| x <= t) {
0 if !self.transitions.is_empty() => self.types[0],
i if i == self.transitions.len() => match &self.footer {
Some(rule) => rule.offset_at(t),
None => self
.indices
.last()
.map(|&k| self.types[k as usize])
.unwrap_or(self.types[0]),
},
i => self.types[self.indices[i - 1] as usize],
}
}
pub fn next_transition_after(&self, t: i64) -> Option<i64> {
let i = self.transitions.partition_point(|&x| x <= t);
if i < self.transitions.len() {
return Some(self.transitions[i]);
}
let rule = self.footer.as_ref()?;
let dst = rule.dst.as_ref()?;
let (year, ..) = ocpp_core::DateTime::from_unix_seconds(t).to_utc_fields();
let mut candidates: Vec<i64> = Vec::new();
for y in year - 1..=year + 2 {
candidates
.push(dst.start.0.local_seconds(y) + dst.start.1 as i64 - rule.std_offset as i64);
candidates.push(dst.end.0.local_seconds(y) + dst.end.1 as i64 - dst.offset as i64);
}
candidates.into_iter().filter(|&c| c > t).min()
}
}
impl Rule {
fn offset_at(&self, t: i64) -> i32 {
let Some(dst) = &self.dst else {
return self.std_offset;
};
let (year, ..) =
ocpp_core::DateTime::from_unix_seconds(t + self.std_offset as i64).to_utc_fields();
let start = dst.start.0.local_seconds(year) + dst.start.1 as i64 - self.std_offset as i64;
let end = dst.end.0.local_seconds(year) + dst.end.1 as i64 - dst.offset as i64;
let in_dst = if start < end {
t >= start && t < end
} else {
!(t >= end && t < start)
};
if in_dst { dst.offset } else { self.std_offset }
}
}
impl DateRule {
fn local_seconds(self, year: i64) -> i64 {
let day = |m: u32, d: u32| {
ocpp_core::DateTime::from_utc_fields(year, m, d, 0, 0, 0, 0)
.map(|t| t.unix_seconds())
.unwrap_or(0)
};
match self {
DateRule::MonthWeekDay(m, w, d) => {
let first = day(m, 1);
let first_wd = ((first.div_euclid(86_400) + 4).rem_euclid(7)) as u32; let mut dom = 1 + (d + 7 - first_wd) % 7 + (w.saturating_sub(1)) * 7;
while dom > days_in_month(year, m) {
dom -= 7;
}
first + (dom as i64 - 1) * 86_400
}
DateRule::Julian1(n) => {
let leap = leap_extra(year) > 0;
let n = if leap && n >= 60 { n } else { n - 1 };
day(1, 1) + n as i64 * 86_400
}
DateRule::Julian0(n) => day(1, 1) + n as i64 * 86_400,
}
}
}
fn days_in_month(year: i64, m: u32) -> u32 {
match m {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ if leap_extra(year) > 0 => 29,
_ => 28,
}
}
fn leap_extra(year: i64) -> i64 {
if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 {
86_400
} else {
0
}
}
fn parse_rule(s: &str) -> Option<Rule> {
let mut p = Parser {
s: s.as_bytes(),
i: 0,
};
p.name()?;
let std_offset = -p.offset()?;
if p.done() {
return Some(Rule {
std_offset,
dst: None,
});
}
p.name()?;
let dst_offset = if p.peek().is_some_and(|c| c != b',') {
-p.offset()?
} else {
std_offset + 3600
};
if p.done() {
return Some(Rule {
std_offset,
dst: Some(DstRule {
offset: dst_offset,
start: (DateRule::MonthWeekDay(3, 2, 0), 7200),
end: (DateRule::MonthWeekDay(11, 1, 0), 7200),
}),
});
}
p.expect(b',')?;
let start = p.date_time()?;
p.expect(b',')?;
let end = p.date_time()?;
p.done().then_some(Rule {
std_offset,
dst: Some(DstRule {
offset: dst_offset,
start,
end,
}),
})
}
struct Parser<'a> {
s: &'a [u8],
i: usize,
}
impl Parser<'_> {
fn peek(&self) -> Option<u8> {
self.s.get(self.i).copied()
}
fn done(&self) -> bool {
self.i >= self.s.len()
}
fn expect(&mut self, c: u8) -> Option<()> {
(self.peek()? == c).then(|| self.i += 1)
}
fn name(&mut self) -> Option<()> {
if self.peek()? == b'<' {
let end = self.s[self.i..].iter().position(|&c| c == b'>')?;
self.i += end + 1;
} else {
let start = self.i;
while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
self.i += 1;
}
if self.i - start < 3 {
return None;
}
}
Some(())
}
fn number(&mut self) -> Option<i64> {
let start = self.i;
while self.peek().is_some_and(|c| c.is_ascii_digit()) {
self.i += 1;
}
std::str::from_utf8(&self.s[start..self.i])
.ok()?
.parse()
.ok()
}
fn offset(&mut self) -> Option<i32> {
let sign = match self.peek()? {
b'-' => {
self.i += 1;
-1
}
b'+' => {
self.i += 1;
1
}
_ => 1,
};
let mut secs = self.number()? * 3600;
if self.peek() == Some(b':') {
self.i += 1;
secs += self.number()? * 60;
if self.peek() == Some(b':') {
self.i += 1;
secs += self.number()?;
}
}
Some(sign * secs as i32)
}
fn date_time(&mut self) -> Option<(DateRule, i32)> {
let rule = match self.peek()? {
b'M' => {
self.i += 1;
let m = self.number()? as u32;
self.expect(b'.')?;
let w = self.number()? as u32;
self.expect(b'.')?;
let d = self.number()? as u32;
((1..=12).contains(&m) && (1..=5).contains(&w) && d <= 6)
.then_some(DateRule::MonthWeekDay(m, w, d))?
}
b'J' => {
self.i += 1;
DateRule::Julian1(self.number()? as u32)
}
_ => DateRule::Julian0(self.number()? as u32),
};
let time = if self.peek() == Some(b'/') {
self.i += 1;
self.offset()?
} else {
7200
};
Some((rule, time))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Local {
pub year: i64,
pub month: u32,
pub day: u32,
pub second_of_day: u32,
pub weekday: u32,
}
impl Zone {
pub fn local(&self, t: i64) -> Local {
let lt = t + self.offset_at(t) as i64;
let (year, month, day, h, m, s, _) =
ocpp_core::DateTime::from_unix_seconds(lt).to_utc_fields();
Local {
year,
month,
day,
second_of_day: h * 3600 + m * 60 + s,
weekday: ((lt.div_euclid(86_400) + 3).rem_euclid(7)) as u32,
}
}
}
#[derive(Default)]
pub struct Zones {
cache: Mutex<HashMap<String, Arc<Zone>>>,
}
impl Zones {
pub fn get(&self, dir: &Path, name: &str) -> Arc<Zone> {
if let Some(z) = self
.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(name)
{
return z.clone();
}
let zone = Arc::new(Zone::load(dir, name).unwrap_or_else(|e| {
tracing::warn!(error = %e, "using UTC instead");
Zone::utc()
}));
self.cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(name.to_owned(), zone.clone());
zone
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t(s: &str) -> i64 {
ocpp_core::DateTime::parse(s).unwrap().unix_seconds()
}
#[test]
fn posix_rules() {
let paris = Zone::from_posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
assert_eq!(paris.offset_at(t("2024-03-31T00:59:59Z")), 3600);
assert_eq!(paris.offset_at(t("2024-03-31T01:00:00Z")), 7200);
assert_eq!(paris.offset_at(t("2024-10-27T00:59:59Z")), 7200);
assert_eq!(paris.offset_at(t("2024-10-27T01:00:00Z")), 3600);
assert_eq!(
paris.next_transition_after(t("2024-06-01T00:00:00Z")),
Some(t("2024-10-27T01:00:00Z"))
);
let ny = Zone::from_posix("EST5EDT,M3.2.0,M11.1.0").unwrap();
assert_eq!(ny.offset_at(t("2024-03-10T06:59:59Z")), -5 * 3600);
assert_eq!(ny.offset_at(t("2024-03-10T07:00:00Z")), -4 * 3600);
let syd = Zone::from_posix("AEST-10AEDT,M10.1.0,M4.1.0/3").unwrap();
assert_eq!(syd.offset_at(t("2024-01-15T00:00:00Z")), 11 * 3600);
assert_eq!(syd.offset_at(t("2024-06-15T00:00:00Z")), 10 * 3600);
assert_eq!(
Zone::from_posix("<+0330>-3:30").unwrap().offset_at(0),
3 * 3600 + 1800
);
let l = paris.local(t("2024-07-01T22:30:00Z"));
assert_eq!(
(l.month, l.day, l.second_of_day, l.weekday),
(7, 2, 1800, 1)
); }
#[test]
fn system_zoneinfo() {
let dir = Path::new("/usr/share/zoneinfo");
if !dir.join("Europe/Paris").exists() {
return;
}
let paris = Zone::load(dir, "Europe/Paris").unwrap();
assert_eq!(paris.offset_at(t("2024-01-15T12:00:00Z")), 3600);
assert_eq!(paris.offset_at(t("2024-07-15T12:00:00Z")), 7200);
assert_eq!(paris.offset_at(t("2100-07-15T12:00:00Z")), 7200);
assert_eq!(paris.offset_at(t("1999-01-01T00:00:00Z")), 3600);
assert_eq!(paris.offset_at(t("1999-08-01T00:00:00Z")), 7200);
assert!(Zone::load(dir, "../etc/passwd").is_err());
}
}