use alloc::{
string::{String, ToString},
vec,
vec::Vec,
};
use std::{
ffi::OsStr,
fs::File,
io::Read,
path::{Path, PathBuf},
sync::{
atomic::{AtomicUsize, Ordering},
Arc, RwLock,
},
time::Duration,
};
use crate::{
error::{tz::db::Error as E, Error},
timestamp::Timestamp,
tz::{
db::special_time_zone, tzif::is_possibly_tzif, TimeZone,
TimeZoneNameIter,
},
util::{self, cache::Expiration, parse, utf8},
};
const DEFAULT_TTL: Duration = Duration::new(5 * 60, 0);
#[cfg(unix)]
static ZONEINFO_DIRECTORIES: &[&str] =
&["/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/etc/zoneinfo"];
#[cfg(not(unix))]
static ZONEINFO_DIRECTORIES: &[&str] = &[];
pub(crate) struct Database {
dir: Option<PathBuf>,
names: Option<ZoneInfoNames>,
zones: RwLock<CachedZones>,
}
impl Database {
pub(crate) fn from_env() -> Database {
if let Some(tzdir) = std::env::var_os("TZDIR") {
let tzdir = PathBuf::from(tzdir);
trace!("opening zoneinfo database at TZDIR={}", tzdir.display());
match Database::from_dir(&tzdir) {
Ok(db) => return db,
Err(_err) => {
warn!("failed opening TZDIR={}: {_err}", tzdir.display());
}
}
}
for dir in ZONEINFO_DIRECTORIES {
let tzdir = Path::new(dir);
trace!("opening zoneinfo database at {}", tzdir.display());
match Database::from_dir(&tzdir) {
Ok(db) => return db,
Err(_err) => {
trace!("failed opening {}: {_err}", tzdir.display());
}
}
}
debug!(
"could not find zoneinfo database at any of the following \
paths: {}",
ZONEINFO_DIRECTORIES.join(", "),
);
Database::none()
}
pub(crate) fn from_dir(dir: &Path) -> Result<Database, Error> {
let names = Some(ZoneInfoNames::new(dir)?);
let zones = RwLock::new(CachedZones::new());
Ok(Database { dir: Some(dir.to_path_buf()), names, zones })
}
pub(crate) fn none() -> Database {
let dir = None;
let names = None;
let zones = RwLock::new(CachedZones::new());
Database { dir, names, zones }
}
pub(crate) fn reset(&self) {
let mut zones = self.zones.write().unwrap();
if let Some(ref names) = self.names {
names.reset();
}
zones.reset();
}
pub(crate) fn get(&self, query: &str) -> Option<TimeZone> {
if let Some(tz) = special_time_zone(query) {
return Some(tz);
}
let names = self.names.as_ref()?;
{
let zones = self.zones.read().unwrap();
if let Some(czone) = zones.get(query) {
if !czone.is_expired() {
trace!(
"for time zone query `{query}`, \
found cached zone `{}` \
(expiration={}, last_modified={:?})",
czone.tz.diagnostic_name(),
czone.expiration,
czone.last_modified,
);
return Some(czone.tz.clone());
}
}
}
let info = names.get(query)?;
let mut zones = self.zones.write().unwrap();
let ttl = zones.ttl;
match zones.get_zone_index(query) {
Ok(i) => {
let czone = &mut zones.zones[i];
if czone.revalidate(&info, ttl) {
return Some(czone.tz.clone());
}
let czone = match CachedTimeZone::new(&info, zones.ttl) {
Ok(czone) => czone,
Err(_err) => {
warn!(
"failed to re-cache time zone from file {}: {_err}",
info.inner.full.display(),
);
return None;
}
};
let tz = czone.tz.clone();
zones.zones[i] = czone;
Some(tz)
}
Err(i) => {
let czone = match CachedTimeZone::new(&info, ttl) {
Ok(czone) => czone,
Err(_err) => {
warn!(
"failed to cache time zone from file {}: {_err}",
info.inner.full.display(),
);
return None;
}
};
let tz = czone.tz.clone();
zones.zones.insert(i, czone);
Some(tz)
}
}
}
pub(crate) fn available<'d>(&'d self) -> TimeZoneNameIter<'d> {
let Some(names) = self.names.as_ref() else {
return TimeZoneNameIter::empty();
};
TimeZoneNameIter::from_iter(names.available().into_iter())
}
pub(crate) fn is_definitively_empty(&self) -> bool {
self.names.is_none()
}
}
impl core::fmt::Debug for Database {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
f.write_str("ZoneInfo(")?;
if let Some(ref dir) = self.dir {
core::fmt::Display::fmt(&dir.display(), f)?;
} else {
f.write_str("unavailable")?;
}
f.write_str(")")
}
}
#[derive(Debug)]
struct CachedZones {
zones: Vec<CachedTimeZone>,
ttl: Duration,
}
impl CachedZones {
const DEFAULT_TTL: Duration = DEFAULT_TTL;
fn new() -> CachedZones {
CachedZones { zones: vec![], ttl: CachedZones::DEFAULT_TTL }
}
fn get(&self, query: &str) -> Option<&CachedTimeZone> {
self.get_zone_index(query).ok().map(|i| &self.zones[i])
}
fn get_zone_index(&self, query: &str) -> Result<usize, usize> {
if let Ok(i) = self
.zones
.binary_search_by(|zone| zone.name.original().cmp(&query))
{
return Ok(i);
}
self.zones.binary_search_by(|zone| {
utf8::cmp_ignore_ascii_case(zone.name.lower(), query)
})
}
fn reset(&mut self) {
self.zones.clear();
}
}
#[derive(Clone, Debug)]
struct CachedTimeZone {
tz: TimeZone,
name: ZoneInfoName,
expiration: Expiration,
last_modified: Option<Timestamp>,
}
impl CachedTimeZone {
fn new(
info: &ZoneInfoName,
ttl: Duration,
) -> Result<CachedTimeZone, Error> {
fn imp(
info: &ZoneInfoName,
ttl: Duration,
) -> Result<CachedTimeZone, Error> {
let path = info.path();
let mut file =
File::open(path).map_err(|e| Error::io(e).path(path))?;
let mut data = vec![];
file.read_to_end(&mut data)
.map_err(|e| Error::io(e).path(path))?;
let tz = TimeZone::tzif(&info.inner.original, &data)
.map_err(|e| e.path(path))?;
let name = info.clone();
let last_modified = util::fs::last_modified_from_file(path, &file);
let expiration = Expiration::after(ttl);
Ok(CachedTimeZone { tz, name, expiration, last_modified })
}
let result = imp(info, ttl);
info.set_validity(result.is_ok());
result
}
fn is_expired(&self) -> bool {
self.expiration.is_expired()
}
fn revalidate(&mut self, info: &ZoneInfoName, ttl: Duration) -> bool {
let Some(old_last_modified) = self.last_modified else {
trace!(
"revalidation for {} failed because old last modified time \
is unavailable",
info.inner.full.display(),
);
return false;
};
let Some(new_last_modified) =
util::fs::last_modified_from_path(info.path())
else {
trace!(
"revalidation for {} failed because new last modified time \
is unavailable",
info.inner.full.display(),
);
return false;
};
if old_last_modified != new_last_modified {
trace!(
"revalidation for {} failed because last modified times \
do not match: old = {} != {} = new",
info.inner.full.display(),
old_last_modified,
new_last_modified,
);
return false;
}
trace!(
"revalidation for {} succeeded because last modified times \
match: old = {} == {} = new",
info.inner.full.display(),
old_last_modified,
new_last_modified,
);
self.expiration = Expiration::after(ttl);
true
}
}
#[derive(Debug)]
struct ZoneInfoNames {
inner: RwLock<ZoneInfoNamesInner>,
}
#[derive(Debug)]
struct ZoneInfoNamesInner {
dir: PathBuf,
names: Vec<ZoneInfoName>,
ttl: Duration,
expiration: Expiration,
}
impl ZoneInfoNames {
const DEFAULT_TTL: Duration = DEFAULT_TTL;
fn new(dir: &Path) -> Result<ZoneInfoNames, Error> {
let names = walk(dir)?;
let dir = dir.to_path_buf();
let ttl = ZoneInfoNames::DEFAULT_TTL;
let expiration = Expiration::after(ttl);
let inner = ZoneInfoNamesInner { dir, names, ttl, expiration };
Ok(ZoneInfoNames { inner: RwLock::new(inner) })
}
fn get(&self, query: &str) -> Option<ZoneInfoName> {
{
let inner = self.inner.read().unwrap();
if let Some(zone_info_name) = inner.get(query) {
return Some(zone_info_name);
}
drop(inner); }
let mut inner = self.inner.write().unwrap();
inner.attempt_refresh();
inner.get(query)
}
fn available(&self) -> Vec<String> {
let mut inner = self.inner.write().unwrap();
inner.attempt_refresh();
inner.available()
}
fn reset(&self) {
self.inner.write().unwrap().reset();
}
}
impl ZoneInfoNamesInner {
fn get(&self, query: &str) -> Option<ZoneInfoName> {
self.names
.binary_search_by(|n| {
utf8::cmp_ignore_ascii_case(&n.inner.lower, query)
})
.ok()
.map(|i| self.names[i].clone())
}
fn available(&self) -> Vec<String> {
self.names
.iter()
.filter(|n| n.is_valid())
.map(|n| n.inner.original.clone())
.collect()
}
fn attempt_refresh(&mut self) {
if self.expiration.is_expired() {
self.refresh();
}
}
fn refresh(&mut self) {
let result = walk(&self.dir);
self.expiration = Expiration::after(self.ttl);
match result {
Ok(names) => {
self.names = names;
}
Err(_err) => {
warn!(
"failed to refresh zoneinfo time zone name cache \
for {}: {_err}",
self.dir.display(),
)
}
}
}
fn reset(&mut self) {
self.names.clear();
self.expiration = Expiration::expired();
}
}
#[derive(Clone, Debug)]
struct ZoneInfoName {
inner: Arc<ZoneInfoNameInner>,
}
#[derive(Debug)]
struct ZoneInfoNameInner {
full: PathBuf,
original: String,
lower: String,
validity: AtomicUsize,
}
impl ZoneInfoName {
fn new(base: &Path, time_zone_name: &Path) -> Result<ZoneInfoName, Error> {
let full = base.join(time_zone_name);
let original = parse::os_str_utf8(time_zone_name.as_os_str())
.map_err(|err| Error::from(err).path(base))?;
let lower = original.to_ascii_lowercase();
let inner = ZoneInfoNameInner {
full,
original: original.to_string(),
lower,
validity: AtomicUsize::new(ZONE_INFO_NAME_UNKNOWN),
};
Ok(ZoneInfoName { inner: Arc::new(inner) })
}
fn path(&self) -> &Path {
&self.inner.full
}
fn original(&self) -> &str {
&self.inner.original
}
fn lower(&self) -> &str {
&self.inner.lower
}
fn is_valid(&self) -> bool {
let validity = self.inner.validity.load(Ordering::Relaxed);
if validity == ZONE_INFO_NAME_VALID {
return true;
} else if validity == ZONE_INFO_NAME_INVALID {
return false;
}
if self.is_valid_impl() {
self.inner.validity.store(ZONE_INFO_NAME_VALID, Ordering::Relaxed);
true
} else {
self.inner
.validity
.store(ZONE_INFO_NAME_INVALID, Ordering::Relaxed);
false
}
}
fn set_validity(&self, is_valid: bool) {
let validity = if is_valid {
ZONE_INFO_NAME_VALID
} else {
ZONE_INFO_NAME_INVALID
};
self.inner.validity.store(validity, Ordering::Relaxed);
}
fn is_valid_impl(&self) -> bool {
let path = self.path();
let mut f = match File::open(path) {
Ok(f) => f,
Err(_err) => {
trace!("failed to open {}: {_err}", path.display());
return false;
}
};
let mut buf = [0; 4];
if let Err(_err) = f.read_exact(&mut buf) {
trace!(
"failed to read first 4 bytes of {}: {_err}",
path.display()
);
return false;
}
if !is_possibly_tzif(&buf) {
trace!(
"found file {} that isn't TZif since its first \
four bytes are {:?}",
path.display(),
crate::util::escape::Bytes(&buf),
);
return false;
}
true
}
}
impl Eq for ZoneInfoName {}
impl PartialEq for ZoneInfoName {
fn eq(&self, rhs: &ZoneInfoName) -> bool {
self.inner.lower == rhs.inner.lower
}
}
impl Ord for ZoneInfoName {
fn cmp(&self, rhs: &ZoneInfoName) -> core::cmp::Ordering {
self.inner.lower.cmp(&rhs.inner.lower)
}
}
impl PartialOrd for ZoneInfoName {
fn partial_cmp(&self, rhs: &ZoneInfoName) -> Option<core::cmp::Ordering> {
Some(self.cmp(rhs))
}
}
impl core::hash::Hash for ZoneInfoName {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.inner.lower.hash(state);
}
}
static ZONE_INFO_NAME_UNKNOWN: usize = 0;
static ZONE_INFO_NAME_VALID: usize = 1;
static ZONE_INFO_NAME_INVALID: usize = 2;
fn walk(start: &Path) -> Result<Vec<ZoneInfoName>, Error> {
struct StackEntry {
dir: PathBuf,
depth: usize,
}
let mut first_err: Option<Error> = None;
let mut seterr = |path: &Path, err: Error| {
if first_err.is_none() {
first_err = Some(err.path(path));
}
};
let mut names = vec![];
let mut stack = vec![StackEntry { dir: start.to_path_buf(), depth: 0 }];
while let Some(StackEntry { dir, depth }) = stack.pop() {
let readdir = match dir.read_dir() {
Ok(readdir) => readdir,
Err(err) => {
info!(
"error when reading {} as a directory: {err}",
dir.display()
);
seterr(&dir, Error::io(err));
continue;
}
};
for result in readdir {
let dent = match result {
Ok(dent) => dent,
Err(err) => {
info!(
"error when reading directory entry from {}: {err}",
dir.display()
);
seterr(&dir, Error::io(err));
continue;
}
};
let file_type = match dent.file_type() {
Ok(file_type) => file_type,
Err(err) => {
let path = dent.path();
info!(
"error when reading file type from {}: {err}",
path.display()
);
seterr(&path, Error::io(err));
continue;
}
};
let path = dent.path();
if file_type.is_dir() {
if depth == 0
&& (dent.file_name() == OsStr::new("posix")
|| dent.file_name() == OsStr::new("right"))
{
continue;
}
stack.push(StackEntry {
dir: path,
depth: depth.saturating_add(1),
});
continue;
}
trace!(
"zoneinfo database initialization visiting {path}",
path = path.display(),
);
let time_zone_name = match path.strip_prefix(start) {
Ok(time_zone_name) => time_zone_name,
Err(_err) => {
trace!(
"failed to extract time zone name from {} \
using {} as a base: {_err}",
path.display(),
start.display(),
);
seterr(&path, Error::from(E::ZoneInfoStripPrefix));
continue;
}
};
let zone_info_name =
match ZoneInfoName::new(&start, time_zone_name) {
Ok(zone_info_name) => zone_info_name,
Err(err) => {
seterr(&path, err);
continue;
}
};
names.push(zone_info_name);
}
}
if names.is_empty() {
let err = first_err
.take()
.unwrap_or_else(|| Error::from(E::ZoneInfoNoTzifFiles));
Err(err)
} else {
names.sort();
Ok(names)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_zoneinfo_walk() -> anyhow::Result<()> {
let _ = crate::logging::Logger::init();
const ENV: &str = "JIFF_DEBUG_ZONEINFO_DIR";
let Some(val) = std::env::var_os(ENV) else { return Ok(()) };
let dir = PathBuf::from(val);
let names = walk(&dir)?;
for n in names {
std::eprintln!("{}", n.inner.original);
}
Ok(())
}
}