jiff/tz/system/
mod.rs

1use std::{sync::RwLock, time::Duration};
2
3use alloc::string::ToString;
4
5use crate::{
6    error::{err, Error, ErrorContext},
7    tz::{posix::PosixTzEnv, TimeZone, TimeZoneDatabase},
8    util::cache::Expiration,
9};
10
11#[cfg(all(unix, not(target_os = "android")))]
12#[path = "unix.rs"]
13mod sys;
14
15#[cfg(all(unix, target_os = "android"))]
16#[path = "android.rs"]
17mod sys;
18
19#[cfg(windows)]
20#[path = "windows/mod.rs"]
21mod sys;
22
23#[cfg(all(
24    feature = "js",
25    any(target_arch = "wasm32", target_arch = "wasm64"),
26    target_os = "unknown"
27))]
28#[path = "wasm_js.rs"]
29mod sys;
30
31#[cfg(not(any(
32    unix,
33    windows,
34    all(
35        feature = "js",
36        any(target_arch = "wasm32", target_arch = "wasm64"),
37        target_os = "unknown"
38    )
39)))]
40mod sys {
41    use crate::tz::{TimeZone, TimeZoneDatabase};
42
43    pub(super) fn get(_db: &TimeZoneDatabase) -> Option<TimeZone> {
44        warn!("getting system time zone on this platform is unsupported");
45        None
46    }
47
48    pub(super) fn read(
49        _db: &TimeZoneDatabase,
50        path: &str,
51    ) -> Option<TimeZone> {
52        match super::read_unnamed_tzif_file(path) {
53            Ok(tz) => Some(tz),
54            Err(_err) => {
55                debug!("failed to read {path} as unnamed time zone: {_err}");
56                None
57            }
58        }
59    }
60}
61
62/// The duration of time that a cached time zone should be considered valid.
63static TTL: Duration = Duration::new(5 * 60, 0);
64
65/// A cached time zone.
66///
67/// When there's a cached time zone that hasn't expired, then we return what's
68/// in the cache. This is because determining the time zone can be mildly
69/// expensive. For example, doing syscalls and potentially parsing TZif data.
70///
71/// We could use a `thread_local!` for this instead which may perhaps be
72/// faster.
73///
74/// Note that our cache here is somewhat simplistic because we lean on the
75/// fact that: 1) in the vast majority of cases, our platform specific code is
76/// limited to finding a time zone name, and 2) looking up a time zone name in
77/// `TimeZoneDatabase` has its own cache. The main cases this doesn't really
78/// cover are when we can't find a time zone name. In which case, we might be
79/// re-parsing POSIX TZ strings or TZif data unnecessarily. But it's not clear
80/// this matters much. It might matter more if we shrink our TTL though.
81static CACHE: RwLock<Cache> = RwLock::new(Cache::empty());
82
83/// A simple global mutable cache of the most recently created system
84/// `TimeZone`.
85///
86/// This gets clearer periodically where subsequent calls to `get` must
87/// re-create the time zone. This is likely wasted work in the vast majority
88/// of cases, but the TTL should ensure it doesn't happen too often.
89///
90/// Of course, in those cases where you want it to happen faster, we provide
91/// a way to reset this cache and force a re-creation of the time zone.
92struct Cache {
93    tz: Option<TimeZone>,
94    expiration: Expiration,
95}
96
97impl Cache {
98    /// Create an empty cache. The default state.
99    const fn empty() -> Cache {
100        Cache { tz: None, expiration: Expiration::expired() }
101    }
102}
103
104/// Retrieve the "system" time zone.
105///
106/// If there is a cached time zone that isn't stale, then that is returned
107/// instead.
108///
109/// If there is no cached time zone, then this tries to determine the system
110/// time zone in a platform specific manner. This may involve reading files
111/// or making system calls. If that fails then an error is returned.
112///
113/// Note that the `TimeZone` returned may not have an IANA name! In some cases,
114/// it is just impractical to determine the time zone name. For example, when
115/// `/etc/localtime` is a hard link to a TZif file instead of a symlink and
116/// when the time zone name isn't recorded in any of the other obvious places.
117pub(crate) fn get(db: &TimeZoneDatabase) -> Result<TimeZone, Error> {
118    {
119        let cache = CACHE.read().unwrap();
120        if let Some(ref tz) = cache.tz {
121            if !cache.expiration.is_expired() {
122                return Ok(tz.clone());
123            }
124        }
125    }
126    let tz = get_force(db)?;
127    {
128        // It's okay that we race here. We basically assume that any
129        // sufficiently close but approximately simultaneous detection of
130        // "system" time will lead to the same result. Of course, this is not
131        // strictly true, but since we invalidate the cache after a TTL, it
132        // will eventually be true in any sane environment.
133        let mut cache = CACHE.write().unwrap();
134        cache.tz = Some(tz.clone());
135        cache.expiration = Expiration::after(TTL);
136    }
137    Ok(tz)
138}
139
140/// Always attempt retrieve the system time zone. This never uses a cache.
141pub(crate) fn get_force(db: &TimeZoneDatabase) -> Result<TimeZone, Error> {
142    match get_env_tz(db) {
143        Ok(Some(tz)) => {
144            debug!("checked TZ environment variable and found {tz:?}");
145            return Ok(tz);
146        }
147        Ok(None) => {
148            debug!("TZ environment variable is not set");
149        }
150        Err(err) => {
151            return Err(err.context(
152                "TZ environment variable set, but failed to read value",
153            ));
154        }
155    }
156    if let Some(tz) = sys::get(db) {
157        return Ok(tz);
158    }
159    Err(err!("failed to find system time zone"))
160}
161
162/// Materializes a `TimeZone` from a `TZ` environment variable.
163///
164/// Basically, `TZ` is usually just an IANA Time Zone Database name like
165/// `TZ=America/New_York` or `TZ=UTC`. But it can also be a POSIX time zone
166/// transition string like `TZ=EST5EDTM3.2.0,M11.1.0` or it can be a file path
167/// (absolute or relative) to a TZif file.
168///
169/// We try very hard to extract a time zone name from `TZ` and use that to look
170/// it up via `TimeZoneDatabase`. But we will fall back to unnamed TZif
171/// `TimeZone` if necessary.
172///
173/// This routine only returns `Ok(None)` when `TZ` is not set. If it is set
174/// but a `TimeZone` could not be extracted, then an error is returned.
175fn get_env_tz(db: &TimeZoneDatabase) -> Result<Option<TimeZone>, Error> {
176    // This routine is pretty Unix-y, but there's no reason it can't
177    // partially work on Windows. For example, setting TZ=America/New_York
178    // should work totally fine on Windows. I don't see a good reason not to
179    // support it anyway.
180
181    let Some(tzenv) = std::env::var_os("TZ") else { return Ok(None) };
182    // It is commonly agreed (across GNU and BSD tooling at least), but
183    // not standard, that setting an empty `TZ=` is indistinguishable from
184    // `TZ=UTC`.
185    if tzenv.is_empty() {
186        debug!(
187            "TZ environment variable set to empty value, \
188             assuming TZ=UTC in order to conform to \
189             widespread convention among Unix tooling",
190        );
191        return Ok(Some(TimeZone::UTC));
192    }
193    let tz_name_or_path = match PosixTzEnv::parse_os_str(&tzenv) {
194        Err(_err) => {
195            debug!(
196                "failed to parse {tzenv:?} as POSIX TZ rule \
197                 (attempting to treat it as an IANA time zone): {_err}",
198            );
199            tzenv
200                .to_str()
201                .ok_or_else(|| {
202                    err!(
203                        "failed to parse {tzenv:?} as a POSIX TZ transition \
204                         string, or as valid UTF-8",
205                    )
206                })?
207                .to_string()
208        }
209        Ok(PosixTzEnv::Implementation(string)) => string.to_string(),
210        Ok(PosixTzEnv::Rule(tz)) => {
211            return Ok(Some(TimeZone::from_posix_tz(tz)))
212        }
213    };
214    // At this point, TZ is set to something that is definitively not a
215    // POSIX TZ transition string. Some possible values at this point are:
216    //
217    //   TZ=America/New_York
218    //   TZ=:America/New_York
219    //   TZ=EST5EDT
220    //   TZ=:EST5EDT
221    //   TZ=/usr/share/zoneinfo/America/New_York
222    //   TZ=:/usr/share/zoneinfo/America/New_York
223    //   TZ=../zoneinfo/America/New_York
224    //   TZ=:../zoneinfo/America/New_York
225    //
226    // `zoneinfo` is the common thread here. So we look for that first. If we
227    // can't find it, then we assume the entire string is a time zone name
228    // that we can look up in the system zoneinfo database.
229    let needle = "zoneinfo/";
230    let Some(rpos) = tz_name_or_path.rfind(needle) else {
231        // No zoneinfo means this is probably a IANA Time Zone name. But...
232        // it could just be a file path.
233        debug!(
234            "could not find {needle:?} in TZ={tz_name_or_path:?}, \
235             therefore attempting lookup in {db:?}",
236        );
237        return match db.get(&tz_name_or_path) {
238            Ok(tz) => Ok(Some(tz)),
239            Err(_err) => {
240                debug!(
241                    "using TZ={tz_name_or_path:?} as time zone name failed, \
242                     could not find time zone in zoneinfo database {db:?} \
243                     (continuing to try and read `{tz_name_or_path}` as \
244                      a TZif file)",
245                );
246                sys::read(db, &tz_name_or_path)
247                    .ok_or_else(|| {
248                        err!(
249                            "failed to read TZ={tz_name_or_path:?} \
250                             as a TZif file after attempting a tzdb \
251                             lookup for `{tz_name_or_path}`",
252                        )
253                    })
254                    .map(Some)
255            }
256        };
257    };
258    // We now try to be a little cute here and extract the IANA time zone name
259    // from what we now believe is a file path by taking everything after
260    // `zoneinfo/`. Once we have that, we try to look it up in our tzdb.
261    let name = &tz_name_or_path[rpos + needle.len()..];
262    debug!(
263        "extracted {name:?} from TZ={tz_name_or_path:?} \
264         and assuming it is an IANA time zone name",
265    );
266    match db.get(&name) {
267        Ok(tz) => return Ok(Some(tz)),
268        Err(_err) => {
269            debug!(
270                "using {name:?} from TZ={tz_name_or_path:?}, \
271                 could not find time zone in zoneinfo database {db:?} \
272                 (continuing to try and use {tz_name_or_path:?})",
273            );
274        }
275    }
276    // At this point, we have tried our hardest but we just cannot seem to
277    // extract an IANA time zone name out of the `TZ` environment variable.
278    // The only thing left for us to do is treat the value as a file path
279    // and read the data as TZif. This will give us time zone data if it works,
280    // but without a name.
281    sys::read(db, &tz_name_or_path)
282        .ok_or_else(|| {
283            err!(
284                "failed to read TZ={tz_name_or_path:?} \
285                 as a TZif file after attempting a tzdb \
286                 lookup for `{name}`",
287            )
288        })
289        .map(Some)
290}
291
292/// Returns the given file path as TZif data without a time zone name.
293///
294/// Normally we require TZif time zones to have a name associated with it.
295/// But because there are likely platforms that hardlink /etc/localtime and
296/// perhaps have no other way to get a time zone name, we choose to support
297/// that use case. Although I cannot actually name such a platform...
298fn read_unnamed_tzif_file(path: &str) -> Result<TimeZone, Error> {
299    let data = std::fs::read(path)
300        .map_err(Error::io)
301        .with_context(|| err!("failed to read {path:?} as TZif file"))?;
302    let tz = TimeZone::tzif_system(&data)
303        .with_context(|| err!("found invalid TZif data at {path:?}"))?;
304    Ok(tz)
305}