//! Wall-clock `HH:MM:SS` in local time, from std alone. //! //! The Python logs `time.strftime('%H:%M:%S')`, i.e. *local* time, and the //! launcher interleaves these lines with its own log, so UTC would misreport the //! timestamps by the machine's offset. std has no local-time support, so the //! UTC offset is taken from the system's TZif database (`TZ` or `/etc/localtime`), //! parsed here — a bounded, well-specified format (RFC 8536). //! //! LIMITATION, stated rather than hidden: only the TZif transition table is //! evaluated, not the trailing POSIX-TZ footer string. With the "fat" tzdata //! that Debian-family systems ship, transitions run to 2037, so the offset — //! including DST — is exact. Two cases fall back to the last known transition's //! offset (so a DST-observing zone could read one hour off) and one falls back //! to UTC: //! * "slim" tzdata, or dates past the last transition -> last transition; //! * `TZ` holding a bare POSIX rule (`EST5EDT`) with no such zone file, or an //! unreadable/corrupt zone file -> UTC. //! The timestamp is diagnostic; no line the launcher parses carries a time. use std::fs; use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; /// Resolved UTC offsets over time: an offset before the first transition, then /// `(transition instant, offset from that instant on)` in ascending order. #[derive(Debug, PartialEq, Eq)] pub struct TzData { initial: i32, transitions: Vec<(i64, i32)>, } impl TzData { /// UTC offset in seconds applying at `unix_secs`. pub fn offset_at(&self, unix_secs: i64) -> i32 { let idx = self .transitions .partition_point(|(start, _)| *start <= unix_secs); if idx == 0 { self.initial } else { self.transitions[idx - 1].1 } } } /// `[HH:MM:SS]`-worthy time-of-day for a Unix timestamp at a given UTC offset. pub fn hms(unix_secs: i64, utc_offset_secs: i32) -> (u32, u32, u32) { let local = unix_secs + i64::from(utc_offset_secs); // rem_euclid keeps pre-epoch and negative-offset instants on a sane clock. let day = local.rem_euclid(86_400); ((day / 3600) as u32, (day % 3600 / 60) as u32, (day % 60) as u32) } /// Current local time of day, `(hour, minute, second)`. pub fn now_hms() -> (u32, u32, u32) { let unix = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) // Before 1970 the clock is broken anyway; keep logging rather than panic. .unwrap_or(0); hms(unix, local_offset_at(unix)) } /// UTC offset in seconds for `unix_secs`, or 0 when no zone data is usable. /// /// The zone file is read and parsed once; the offset is then recomputed per call /// so a DST transition during a long-running session is picked up. pub fn local_offset_at(unix_secs: i64) -> i32 { static TZ: LazyLock> = LazyLock::new(load_system_tz); TZ.as_ref().map_or(0, |tz| tz.offset_at(unix_secs)) } /// Read and parse the zone file named by `TZ`, else `/etc/localtime`. fn load_system_tz() -> Option { let path = match std::env::var("TZ") { Ok(tz) if !tz.is_empty() => { // glibc accepts a leading ':' and either an absolute path or a name // relative to the zoneinfo directory. let name = tz.strip_prefix(':').unwrap_or(&tz); if name.starts_with('/') { name.to_string() } else { format!("/usr/share/zoneinfo/{name}") } } _ => "/etc/localtime".to_string(), }; parse_tzif(&fs::read(path).ok()?) } /// Parse a TZif (RFC 8536) file into resolved offsets. /// /// For version 2+ files the 64-bit data block is used; the legacy 32-bit block /// is skipped, because modern tzdata leaves it minimal. pub fn parse_tzif(bytes: &[u8]) -> Option { let (version, counts) = parse_header(bytes, 0)?; if version >= b'2' { // Skip the v1 header + v1 data block, then re-read the 64-bit header. let v1_end = 44 + data_block_len(&counts, 4)?; let (_, counts64) = parse_header(bytes, v1_end)?; parse_data(bytes, v1_end + 44, &counts64, 8) } else { parse_data(bytes, 44, &counts, 4) } } /// `(isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt)`. type Counts = [u32; 6]; fn parse_header(bytes: &[u8], off: usize) -> Option<(u8, Counts)> { let head = bytes.get(off..off + 44)?; if &head[0..4] != b"TZif" { return None; } let version = head[4]; let mut counts = [0u32; 6]; for (i, slot) in counts.iter_mut().enumerate() { let at = 20 + i * 4; *slot = u32::from_be_bytes(head[at..at + 4].try_into().ok()?); } Some((version, counts)) } /// Byte length of a data block with `time_len`-wide transition times. fn data_block_len(counts: &Counts, time_len: usize) -> Option { let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = counts.map(|c| c as usize); Some( timecnt * time_len + timecnt + typecnt * 6 + charcnt + leapcnt * (time_len + 4) + isstdcnt + isutcnt, ) } fn parse_data(bytes: &[u8], off: usize, counts: &Counts, time_len: usize) -> Option { let [_, _, _, timecnt, typecnt, _] = counts.map(|c| c as usize); if typecnt == 0 { return None; } let block = bytes.get(off..off + data_block_len(counts, time_len)?)?; let times = block.get(..timecnt * time_len)?; let type_idx = block.get(timecnt * time_len..timecnt * time_len + timecnt)?; let ttinfo_at = timecnt * time_len + timecnt; let ttinfo = block.get(ttinfo_at..ttinfo_at + typecnt * 6)?; // utoff + isdst per local-time type. let mut offsets = Vec::with_capacity(typecnt); for i in 0..typecnt { let rec = &ttinfo[i * 6..i * 6 + 6]; let utoff = i32::from_be_bytes(rec[0..4].try_into().ok()?); offsets.push((utoff, rec[4] != 0)); } // Before the first transition, RFC 8536 says to use the first non-DST type, // falling back to the first type. This is also the whole answer for a // fixed-offset zone (typecnt 1, timecnt 0), e.g. Etc/UTC. let initial = offsets .iter() .find(|(_, isdst)| !*isdst) .unwrap_or(&offsets[0]) .0; let mut transitions = Vec::with_capacity(timecnt); for i in 0..timecnt { let raw = ×[i * time_len..(i + 1) * time_len]; let at = if time_len == 8 { i64::from_be_bytes(raw.try_into().ok()?) } else { i64::from(i32::from_be_bytes(raw.try_into().ok()?)) }; let (utoff, _) = *offsets.get(*type_idx.get(i)? as usize)?; transitions.push((at, utoff)); } Some(TzData { initial, transitions, }) } #[cfg(test)] mod tests { use super::*; /// Minimal TZif builder: `transitions` are `(instant, type index)`. fn tzif(version: u8, types: &[(i32, bool)], transitions: &[(i64, u8)]) -> Vec { fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec { let mut out = Vec::new(); for (at, _) in transitions { if time_len == 8 { out.extend_from_slice(&at.to_be_bytes()); } else { out.extend_from_slice(&(*at as i32).to_be_bytes()); } } for (_, idx) in transitions { out.push(*idx); } for (utoff, isdst) in types { out.extend_from_slice(&utoff.to_be_bytes()); out.push(u8::from(*isdst)); out.push(0); // abbreviation index } out.push(0); // one NUL abbreviation byte out } fn header(version: u8, types: usize, transitions: usize) -> Vec { let mut out = Vec::from(*b"TZif"); out.push(version); out.extend_from_slice(&[0u8; 15]); for count in [0u32, 0, 0, transitions as u32, types as u32, 1] { out.extend_from_slice(&count.to_be_bytes()); } out } let mut out = header(version, types.len(), transitions.len()); if version >= b'2' { // Modern "slim-ish" shape: an empty v1 block, then the 64-bit block. out.truncate(0); out.extend(header(version, types.len(), 0)); out.extend(block(types, &[], 4)); out.extend(header(version, types.len(), transitions.len())); out.extend(block(types, transitions, 8)); } else { out.extend(block(types, transitions, 4)); } out } #[test] fn fixed_offset_zone_has_no_transitions() { let tz = parse_tzif(&tzif(b'2', &[(0, false)], &[])).unwrap(); assert_eq!(tz.offset_at(0), 0); assert_eq!(tz.offset_at(1_800_000_000), 0); let kolkata = parse_tzif(&tzif(b'2', &[(19_800, false)], &[])).unwrap(); assert_eq!(kolkata.offset_at(1_800_000_000), 19_800); } #[test] fn dst_transitions_select_the_right_offset() { // CET/CEST with two transitions. let tz = parse_tzif(&tzif( b'2', &[(3600, false), (7200, true)], &[(1_000_000_000, 1), (1_100_000_000, 0)], )) .unwrap(); assert_eq!(tz.offset_at(999_999_999), 3600); // before the first transition assert_eq!(tz.offset_at(1_000_000_000), 7200); // exactly at it assert_eq!(tz.offset_at(1_050_000_000), 7200); assert_eq!(tz.offset_at(1_100_000_000), 3600); assert_eq!(tz.offset_at(i64::MAX), 3600); // past the table: last known } #[test] fn version_1_files_parse_from_the_32_bit_block() { let tz = parse_tzif(&tzif(b'\0', &[(-18_000, false)], &[(100, 0)])).unwrap(); assert_eq!(tz.offset_at(0), -18_000); assert_eq!(tz.offset_at(1_000), -18_000); } #[test] fn initial_offset_skips_a_leading_dst_type() { let tz = parse_tzif(&tzif(b'2', &[(7200, true), (3600, false)], &[])).unwrap(); assert_eq!(tz.offset_at(0), 3600); } #[test] fn garbage_is_rejected_rather_than_guessed() { assert_eq!(parse_tzif(b""), None); assert_eq!(parse_tzif(b"not a tzif file at all, truncated"), None); let mut truncated = tzif(b'2', &[(3600, false)], &[(1, 0)]); truncated.truncate(truncated.len() - 5); assert_eq!(parse_tzif(&truncated), None); // Well-formed header claiming zero local-time types is unusable. assert_eq!(parse_tzif(&tzif(b'2', &[], &[])), None); } #[test] fn hms_matches_known_instants() { assert_eq!(hms(0, 0), (0, 0, 0)); // 2026-08-18T04:52:08Z assert_eq!(hms(1_787_028_728, 0), (4, 52, 8)); // …the same instant at +02:00 and at -05:00 (the latter is the day before). assert_eq!(hms(1_787_028_728, 7200), (6, 52, 8)); assert_eq!(hms(1_787_028_728, -18_000), (23, 52, 8)); // Offsets that cross midnight in either direction stay on the clock. assert_eq!(hms(86_399, 1), (0, 0, 0)); assert_eq!(hms(0, -1), (23, 59, 59)); } #[test] fn the_system_zone_resolves_to_a_plausible_offset() { // Whatever this machine's zone is, the offset must be a real one. let offset = local_offset_at(1_786_697_528); assert!((-50_400..=50_400).contains(&offset), "implausible {offset}"); assert_eq!(offset % 60, 0); } }