diff --git a/src/offset/local/tz_info/timezone.rs b/src/offset/local/tz_info/timezone.rs index dbb2def93..5ecbce88f 100644 --- a/src/offset/local/tz_info/timezone.rs +++ b/src/offset/local/tz_info/timezone.rs @@ -1,8 +1,5 @@ //! Types related to a time zone. -use std::fs::{self, File}; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; use std::{cmp::Ordering, fmt, str}; use super::rule::{AlternateTime, TransitionRule}; @@ -22,43 +19,8 @@ pub(crate) struct TimeZone { } impl TimeZone { - /// Returns local time zone. - /// - /// This method in not supported on non-UNIX platforms, and returns the UTC time zone instead. - pub(crate) fn local(env_tz: Option<&str>) -> Result { - match env_tz { - Some(tz) => Self::from_posix_tz(tz), - None => Self::from_posix_tz("localtime"), - } - } - /// Construct a time zone from a POSIX TZ string, as described in [the POSIX documentation of the `TZ` environment variable](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html). - fn from_posix_tz(tz_string: &str) -> Result { - if tz_string.is_empty() { - return Err(Error::InvalidTzString("empty TZ string")); - } - - if tz_string == "localtime" { - return Self::from_tz_data(&fs::read("/etc/localtime")?); - } - - // attributes are not allowed on if blocks in Rust 1.38 - #[cfg(target_os = "android")] - { - if let Ok(bytes) = android_tzdata::find_tz_data(tz_string) { - return Self::from_tz_data(&bytes); - } - } - - let mut chars = tz_string.chars(); - if chars.next() == Some(':') { - return Self::from_file(&mut find_tz_file(chars.as_str())?); - } - - if let Ok(mut file) = find_tz_file(tz_string) { - return Self::from_file(&mut file); - } - + pub(crate) fn from_tz_string(tz_string: &str) -> Result { // TZ string extensions are not allowed let tz_string = tz_string.trim_matches(|c: char| c.is_ascii_whitespace()); let rule = TransitionRule::from_tz_string(tz_string.as_bytes(), false)?; @@ -85,13 +47,6 @@ impl TimeZone { Ok(new) } - /// Construct a time zone from the contents of a time zone file - fn from_file(file: &mut File) -> Result { - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; - Self::from_tz_data(&bytes) - } - /// Construct a time zone from the contents of a time zone file /// /// Parse TZif data as described in [RFC 8536](https://datatracker.ietf.org/doc/html/rfc8536). @@ -606,34 +561,6 @@ impl LocalTimeType { pub(super) const UTC: LocalTimeType = Self { ut_offset: 0, is_dst: false, name: None }; } -/// Open the TZif file corresponding to a TZ string -fn find_tz_file(path: impl AsRef) -> Result { - // Don't check system timezone directories on non-UNIX platforms - #[cfg(not(unix))] - return Ok(File::open(path)?); - - #[cfg(unix)] - { - let path = path.as_ref(); - if path.is_absolute() { - return Ok(File::open(path)?); - } - - for folder in &ZONE_INFO_DIRECTORIES { - if let Ok(file) = File::open(PathBuf::from(folder).join(path)) { - return Ok(file); - } - } - - Err(Error::Io(io::ErrorKind::NotFound.into())) - } -} - -// Possible system timezone directories -#[cfg(unix)] -const ZONE_INFO_DIRECTORIES: [&str; 4] = - ["/usr/share/zoneinfo", "/share/zoneinfo", "/etc/zoneinfo", "/usr/share/lib/zoneinfo"]; - /// Number of seconds in one week pub(crate) const SECONDS_PER_WEEK: i64 = SECONDS_PER_DAY * DAYS_PER_WEEK; /// Number of seconds in 28 days @@ -844,34 +771,6 @@ mod tests { Ok(()) } - #[test] - fn test_time_zone_from_posix_tz() -> Result<(), Error> { - #[cfg(unix)] - { - // if the TZ var is set, this essentially _overrides_ the - // time set by the localtime symlink - // so just ensure that ::local() acts as expected - // in this case - if let Ok(tz) = std::env::var("TZ") { - let time_zone_local = TimeZone::local(Some(tz.as_str()))?; - let time_zone_local_1 = TimeZone::from_posix_tz(&tz)?; - assert_eq!(time_zone_local, time_zone_local_1); - } - - // `TimeZone::from_posix_tz("UTC")` will return `Error` if the environment does not have - // a time zone database, like for example some docker containers. - // In that case skip the test. - if let Ok(time_zone_utc) = TimeZone::from_posix_tz("UTC") { - assert_eq!(time_zone_utc.find_local_time_type(0)?.offset(), 0); - } - } - - assert!(TimeZone::from_posix_tz("EST5EDT,0/0,J365/25").is_err()); - assert!(TimeZone::from_posix_tz("").is_err()); - - Ok(()) - } - #[test] fn test_leap_seconds() -> Result<(), Error> { let time_zone = TimeZone::new( diff --git a/src/offset/local/unix.rs b/src/offset/local/unix.rs index c1942eba7..cbf0c65db 100644 --- a/src/offset/local/unix.rs +++ b/src/offset/local/unix.rs @@ -8,164 +8,344 @@ // option. This file may not be copied, modified, or distributed // except according to those terms. -use std::{cell::RefCell, collections::hash_map, env, fs, hash::Hasher, time::SystemTime}; +use std::cell::RefCell; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; use super::tz_info::TimeZone; use super::{FixedOffset, NaiveDateTime}; use crate::{Datelike, MappedLocalTime}; pub(super) fn offset_from_utc_datetime(utc: &NaiveDateTime) -> MappedLocalTime { - offset(utc, false) -} + TZ_INFO.with(|cache| { + let mut cache_ref = cache.borrow_mut(); + let tz_info = cache_ref.tz_info(); + let offset = tz_info + .find_local_time_type(utc.and_utc().timestamp()) + .expect("unable to select local time type") + .offset(); -pub(super) fn offset_from_local_datetime(local: &NaiveDateTime) -> MappedLocalTime { - offset(local, true) + match FixedOffset::east_opt(offset) { + Some(offset) => MappedLocalTime::Single(offset), + None => MappedLocalTime::None, + } + }) } -fn offset(d: &NaiveDateTime, local: bool) -> MappedLocalTime { - TZ_INFO.with(|maybe_cache| { - maybe_cache.borrow_mut().get_or_insert_with(Cache::default).offset(*d, local) +pub(super) fn offset_from_local_datetime(local: &NaiveDateTime) -> MappedLocalTime { + TZ_INFO.with(|cache| { + let mut cache_ref = cache.borrow_mut(); + let tz_info = cache_ref.tz_info(); + tz_info + .find_local_time_type_from_local(local.and_utc().timestamp(), local.year()) + .expect("unable to select local time type") + .and_then(|o| FixedOffset::east_opt(o.offset())) }) } -// we have to store the `Cache` in an option as it can't -// be initialized in a static context. -thread_local! { - static TZ_INFO: RefCell> = Default::default(); +struct CachedTzInfo { + zone: Option, + source: Source, + last_checked: SystemTime, + tz_var: Option, + tz_name: Option, + path: Option, + tzdb_dir: Option, } -enum Source { - LocalTime { mtime: SystemTime }, - Environment { hash: u64 }, -} +impl CachedTzInfo { + fn tz_info(&mut self) -> &TimeZone { + self.refresh_cache(); + self.zone.as_ref().unwrap() + } -impl Source { - fn new(env_tz: Option<&str>) -> Source { - match env_tz { - Some(tz) => { - let mut hasher = hash_map::DefaultHasher::new(); - hasher.write(tz.as_bytes()); - let hash = hasher.finish(); - Source::Environment { hash } + // Refresh our cached data if necessary. + // + // If the cache has been around for less than a second then we reuse it unconditionally. This is + // a reasonable tradeoff because the timezone generally won't be changing _that_ often, but if + // the time zone does change, it will reflect sufficiently quickly from an application user's + // perspective. + fn refresh_cache(&mut self) { + let now = SystemTime::now(); + if let Ok(d) = now.duration_since(self.last_checked) { + if d.as_secs() < 1 && self.source != Source::Uninitialized { + return; } - None => match fs::symlink_metadata("/etc/localtime") { - Ok(data) => Source::LocalTime { - // we have to pick a sensible default when the mtime fails - // by picking SystemTime::now() we raise the probability of - // the cache being invalidated if/when the mtime starts working - mtime: data.modified().unwrap_or_else(|_| SystemTime::now()), - }, - Err(_) => { - // as above, now() should be a better default than some constant - // TODO: see if we can improve caching in the case where the fallback is a valid timezone - Source::LocalTime { mtime: SystemTime::now() } - } - }, } + + if self.needs_update() { + self.read_tz_info(); + } + self.last_checked = now; } -} -struct Cache { - zone: TimeZone, - source: Source, - last_checked: SystemTime, -} + /// Check if any of the environment variables or files have changed, or the name of the current + /// time zone as determined by the `iana_time_zone` crate. + fn needs_update(&self) -> bool { + if self.tz_env_var_changed() { + return true; + } + if self.source == Source::TzEnvVar { + return false; // No need for further checks if the cached value came from the `TZ` var. + } + if self.symlink_changed() { + return true; + } + if self.source == Source::Localtime { + return false; // No need for further checks if the cached value came from the symlink. + } + if self.tz_name_changed() { + return true; + } + false + } + + /// Try to get the current time zone data. + /// + /// The following sources are tried in order: + /// - `TZ` environment variable, containing: + /// - the POSIX TZ rule + /// - an absolute path + /// - an IANA time zone name in combination with the platform time zone database + /// - the `/etc/localtime` symlink + /// - the global IANA time zone name in combination with the platform time zone database + /// - fall back to UTC if all else fails + fn read_tz_info(&mut self) { + let tz_var = TzEnvVar::get(); + match tz_var { + None => self.tz_var = None, + Some(tz_var) => { + if self.read_from_tz_env(&tz_var).is_ok() { + self.tz_var = Some(tz_var); + return; + } + } + } + #[cfg(not(target_os = "android"))] + if self.read_from_symlink().is_ok() { + return; + } + if self.read_with_tz_name().is_ok() { + return; + } + self.zone = Some(TimeZone::utc()); + self.source = Source::Utc; + } -#[cfg(target_os = "aix")] -const TZDB_LOCATION: &str = "/usr/share/lib/zoneinfo"; + /// Read the `TZ` environment variable or the TZif file that it points to. + fn read_from_tz_env(&mut self, tz_var: &TzEnvVar) -> Result<(), ()> { + match tz_var { + TzEnvVar::TzString(tz_string) => { + self.zone = Some(TimeZone::from_tz_string(tz_string).map_err(|_| ())?); + self.path = None; + } + TzEnvVar::Path(path) => { + let path = PathBuf::from(&path[1..]); + let tzif = fs::read(&path).map_err(|_| ())?; + self.zone = Some(TimeZone::from_tz_data(&tzif).map_err(|_| ())?); + self.path = Some(path); + } + TzEnvVar::TzName(tz_id) => self.read_tzif(&tz_id[1..])?, + #[cfg(not(target_os = "android"))] + TzEnvVar::LocaltimeSymlink => self.read_from_symlink()?, + }; + self.source = Source::TzEnvVar; + Ok(()) + } -#[cfg(not(any(target_os = "android", target_os = "aix")))] -const TZDB_LOCATION: &str = "/usr/share/zoneinfo"; + /// Check if the `TZ` environment variable has changed, or the file it points to. + fn tz_env_var_changed(&self) -> bool { + let tz_var = TzEnvVar::get(); + match (&self.tz_var, &tz_var) { + (None, None) => false, + (Some(TzEnvVar::TzString(a)), Some(TzEnvVar::TzString(b))) if a == b => false, + (Some(TzEnvVar::Path(a)), Some(TzEnvVar::Path(b))) if a == b => { + self.mtime_changed(self.path.as_deref()) + } + (Some(TzEnvVar::TzName(a)), Some(TzEnvVar::TzName(b))) if a == b => { + self.mtime_changed(self.path.as_deref()) || self.tzdb_dir_changed() + } + #[cfg(not(target_os = "android"))] + (Some(TzEnvVar::LocaltimeSymlink), Some(TzEnvVar::LocaltimeSymlink)) => { + self.symlink_changed() + } + _ => true, + } + } -fn fallback_timezone() -> Option { - let tz_name = iana_time_zone::get_timezone().ok()?; + /// Read the Tzif file that `/etc/localtime` is symlinked to. #[cfg(not(target_os = "android"))] - let bytes = fs::read(format!("{}/{}", TZDB_LOCATION, tz_name)).ok()?; - #[cfg(target_os = "android")] - let bytes = android_tzdata::find_tz_data(&tz_name).ok()?; - TimeZone::from_tz_data(&bytes).ok() -} + fn read_from_symlink(&mut self) -> Result<(), ()> { + let tzif = fs::read("/etc/localtime").map_err(|_| ())?; + self.zone = Some(TimeZone::from_tz_data(&tzif).map_err(|_| ())?); + self.source = Source::Localtime; + Ok(()) + } -impl Default for Cache { - fn default() -> Cache { - // default to UTC if no local timezone can be found - let env_tz = env::var("TZ").ok(); - let env_ref = env_tz.as_deref(); - Cache { - last_checked: SystemTime::now(), - source: Source::new(env_ref), - zone: current_zone(env_ref), - } + /// Check if the `/etc/localtime` symlink or its target has changed. + fn symlink_changed(&self) -> bool { + self.mtime_changed(Some(Path::new("/etc/localtime"))) } -} -fn current_zone(var: Option<&str>) -> TimeZone { - TimeZone::local(var).ok().or_else(fallback_timezone).unwrap_or_else(TimeZone::utc) -} + /// Get the IANA time zone name of the system by whichever means the `iana_time_zone` crate gets + /// it, and try to read the corresponding TZif data. + fn read_with_tz_name(&mut self) -> Result<(), ()> { + let tz_name = iana_time_zone::get_timezone().map_err(|_| ())?; + self.read_tzif(&tz_name)?; + self.tz_name = Some(tz_name); + self.source = Source::TimeZoneName; + Ok(()) + } -impl Cache { - fn offset(&mut self, d: NaiveDateTime, local: bool) -> MappedLocalTime { - let now = SystemTime::now(); + /// Check if the IANA time zone name has changed, or the file it points to. + fn tz_name_changed(&self) -> bool { + self.tz_name != iana_time_zone::get_timezone().ok() + || self.tzdb_dir_changed() + || self.mtime_changed(self.path.as_deref()) + } + + /// Try to read the TZif data for the specified time zone name. + fn read_tzif(&mut self, tz_name: &str) -> Result<(), ()> { + let (tzif, path) = self.read_tzif_inner(tz_name)?; + self.zone = Some(TimeZone::from_tz_data(&tzif).map_err(|_| ())?); + self.path = path; + Ok(()) + } + + #[cfg(not(target_os = "android"))] + fn read_tzif_inner(&mut self, tz_name: &str) -> Result<(Vec, Option), ()> { + let path = self.tzdb_dir()?.join(tz_name); + let tzif = fs::read(&path).map_err(|_| ())?; + Ok((tzif, Some(path))) + } + #[cfg(target_os = "android")] + fn read_tzif_inner(&mut self, tz_name: &str) -> Result<(Vec, Option), ()> { + let tzif = android_tzdata::find_tz_data(&tz_name).map_err(|_| ())?; + Ok((tzif, None)) + } + + /// Get the location of the time zone database directory with TZif files. + #[cfg(not(target_os = "android"))] + fn tzdb_dir(&mut self) -> Result { + // Possible system timezone directories + const ZONE_INFO_DIRECTORIES: [&str; 4] = + ["/usr/share/zoneinfo", "/share/zoneinfo", "/etc/zoneinfo", "/usr/share/lib/zoneinfo"]; - match now.duration_since(self.last_checked) { - // If the cache has been around for less than a second then we reuse it - // unconditionally. This is a reasonable tradeoff because the timezone - // generally won't be changing _that_ often, but if the time zone does - // change, it will reflect sufficiently quickly from an application - // user's perspective. - Ok(d) if d.as_secs() < 1 => (), - Ok(_) | Err(_) => { - let env_tz = env::var("TZ").ok(); - let env_ref = env_tz.as_deref(); - let new_source = Source::new(env_ref); - - let out_of_date = match (&self.source, &new_source) { - // change from env to file or file to env, must recreate the zone - (Source::Environment { .. }, Source::LocalTime { .. }) - | (Source::LocalTime { .. }, Source::Environment { .. }) => true, - // stay as file, but mtime has changed - (Source::LocalTime { mtime: old_mtime }, Source::LocalTime { mtime }) - if old_mtime != mtime => - { - true - } - // stay as env, but hash of variable has changed - (Source::Environment { hash: old_hash }, Source::Environment { hash }) - if old_hash != hash => - { - true - } - // cache can be reused - _ => false, - }; - - if out_of_date { - self.zone = current_zone(env_ref); + // Use the value of the `TZDIR` environment variable if set. + if let Some(tz_dir) = env::var_os("TZDIR") { + if !tz_dir.is_empty() { + let path = PathBuf::from(tz_dir); + if path.exists() { + return Ok(path); } + } + } + + // Use the cached value + if let Some(dir) = self.tzdb_dir.as_ref() { + return Ok(PathBuf::from(dir)); + } + + // No cached value yet, try the various possible system timezone directories. + for dir in &ZONE_INFO_DIRECTORIES { + let path = PathBuf::from(dir); + if path.exists() { + self.tzdb_dir = Some(path.clone()); + return Ok(path); + } + } + Err(()) + } - self.last_checked = now; - self.source = new_source; + /// Check if the location that the `TZDIR` environment variable points to has changed. + fn tzdb_dir_changed(&self) -> bool { + #[cfg(not(target_os = "android"))] + if let Some(tz_dir) = env::var_os("TZDIR") { + if !tz_dir.is_empty() + && Some(tz_dir.as_os_str()) != self.tzdb_dir.as_ref().map(|d| d.as_os_str()) + { + return true; } } + false + } - if !local { - let offset = self - .zone - .find_local_time_type(d.and_utc().timestamp()) - .expect("unable to select local time type") - .offset(); + /// Returns `true` if the modification time of the TZif file or symlink is more recent then + /// `self.last_checked`. + /// + /// Also returns `true` if there was an error getting the modification time. + /// If the file is a symlink this method checks the symlink and the final target. + fn mtime_changed(&self, path: Option<&Path>) -> bool { + fn inner(path: &Path, last_checked: SystemTime) -> Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.modified()? > last_checked { + return Ok(true); + } + if metadata.is_symlink() && fs::metadata(path)?.modified()? > last_checked { + return Ok(true); + } + Ok(false) + } + match path { + Some(path) => inner(path, self.last_checked).unwrap_or(true), + None => false, + } + } +} - return match FixedOffset::east_opt(offset) { - Some(offset) => MappedLocalTime::Single(offset), - None => MappedLocalTime::None, - }; +thread_local! { + static TZ_INFO: RefCell = const { RefCell::new( + CachedTzInfo { + zone: None, + source: Source::Uninitialized, + last_checked: SystemTime::UNIX_EPOCH, + tz_var: None, + tz_name: None, + path: None, + tzdb_dir: None, } + ) }; +} - // we pass through the year as the year of a local point in time must either be valid in that locale, or - // the entire time was skipped in which case we will return MappedLocalTime::None anyway. - self.zone - .find_local_time_type_from_local(d.and_utc().timestamp(), d.year()) - .expect("unable to select local time type") - .and_then(|o| FixedOffset::east_opt(o.offset())) +#[derive(PartialEq)] +enum Source { + TzEnvVar, + Localtime, + TimeZoneName, + Utc, + Uninitialized, +} + +/// Type of the `TZ` environment variable. +/// +/// Supported formats are: +/// - a POSIX TZ string +/// - an absolute path (starting with `:/`, as supported by glibc and others) +/// - a time zone name (starting with `:`, as supported by glibc and others) +/// - "localtime" (supported by Solaris and maybe others) +enum TzEnvVar { + TzString(String), + Path(String), // Value still starts with `:` + TzName(String), // Value still starts with `:` + #[cfg(not(target_os = "android"))] + LocaltimeSymlink, +} + +impl TzEnvVar { + /// Get the current value of the `TZ` environment variable and determine its format. + fn get() -> Option { + match env::var("TZ").ok() { + None => None, + Some(s) if s.is_empty() => None, + #[cfg(not(target_os = "android"))] + Some(s) if s == "localtime" => Some(TzEnvVar::LocaltimeSymlink), + Some(tz_var) => match tz_var.strip_prefix(':') { + Some(path) if Path::new(&path).is_absolute() => Some(TzEnvVar::Path(tz_var)), + Some(_) => Some(TzEnvVar::TzName(tz_var)), + None => Some(TzEnvVar::TzString(tz_var)), + }, + } } }