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::PosixTz, 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                trace!("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            trace!("checked TZ environment variable but found nothing");
149        }
150        Err(_err) => {
151            trace!("checked TZ environment variable but got error: {_err}");
152        }
153    }
154    if let Some(tz) = sys::get(db) {
155        return Ok(tz);
156    }
157    Err(err!("failed to find system time zone"))
158}
159
160/// Materializes a `TimeZone` from a `TZ` environment variable.
161///
162/// Basically, `TZ` is usually just an IANA Time Zone Database name like
163/// `TZ=America/New_York` or `TZ=UTC`. But it can also be a POSIX time zone
164/// transition string like `TZ=EST5EDT` or it can be a file path (absolute
165/// or relative) to a TZif file.
166///
167/// We try very hard to extract a time zone name from `TZ` and use that to look
168/// it up via `TimeZoneDatabase`. But we will fall back to unnamed TZif
169/// `TimeZone` if necessary.
170fn get_env_tz(db: &TimeZoneDatabase) -> Result<Option<TimeZone>, Error> {
171    // This routine is pretty Unix-y, but there's no reason it can't
172    // partially work on Windows. For example, setting TZ=America/New_York
173    // should work totally fine on Windows. I don't see a good reason not to
174    // support it anyway.
175
176    let Some(tzenv) = std::env::var_os("TZ") else { return Ok(None) };
177    if tzenv.is_empty() {
178        return Ok(None);
179    }
180    let tz_name_or_path = match PosixTz::parse_os_str(&tzenv) {
181        Err(_err) => {
182            trace!(
183                "failed to parse {tzenv:?} as POSIX TZ rule \
184                 (attempting to treat it as an IANA time zone): {_err}",
185            );
186            tzenv
187                .to_str()
188                .ok_or_else(|| {
189                    err!(
190                        "failed to parse {tzenv:?} as a POSIX TZ transition \
191                         string, or as valid UTF-8 \
192                         (therefore ignoring TZ environment variable)",
193                    )
194                })?
195                .to_string()
196        }
197        Ok(PosixTz::Implementation(string)) => string.to_string(),
198        Ok(PosixTz::Rule(tz)) => match tz.reasonable() {
199            Ok(reasonable_posix_tz) => {
200                return Ok(Some(TimeZone::from_reasonable_posix_tz(
201                    reasonable_posix_tz,
202                )));
203            }
204            Err(_) => {
205                warn!(
206                    "parsed {tzenv:?} as POSIX TZ transition string, \
207                     but Jiff considers it unreasonable since \
208                     it specifies DST but without a rule \
209                     (therefore ignoring TZ environment variable)",
210                );
211                return Ok(None);
212            }
213        },
214    };
215    // At this point, TZ is set to something that is definitively not a
216    // POSIX TZ transition string. Some possible values at this point are:
217    //
218    //   TZ=America/New_York
219    //   TZ=:America/New_York
220    //   TZ=/usr/share/zoneinfo/America/New_York
221    //   TZ=:/usr/share/zoneinfo/America/New_York
222    //   TZ=../zoneinfo/America/New_York
223    //   TZ=:../zoneinfo/America/New_York
224    //
225    // `zoneinfo` is the common thread here. So we look for that first. If we
226    // can't find it, then we assume the entire string is a time zone name
227    // that we can look up in the system zoneinfo database.
228    let needle = "zoneinfo/";
229    let Some(rpos) = tz_name_or_path.rfind(needle) else {
230        // No zoneinfo means this is probably a IANA Time Zone name. But...
231        // it could just be a file path.
232        trace!(
233            "could not find {needle:?} in TZ={tz_name_or_path:?}, \
234             therefore attempting lookup in {db:?}",
235        );
236        return match db.get(&tz_name_or_path) {
237            Ok(tz) => Ok(Some(tz)),
238            Err(_err) => {
239                trace!(
240                    "using TZ={tz_name_or_path:?} as time zone name failed, \
241                     could not find time zone in zoneinfo database {db:?} \
242                     (continuing to try and use {tz_name_or_path:?}",
243                );
244                Ok(sys::read(db, &tz_name_or_path))
245            }
246        };
247    };
248    // We now try to be a little cute here and extract the IANA time zone name
249    // from what we now believe is a file path by taking everything after
250    // `zoneinfo/`. Once we have that, we try to look it up in our tzdb.
251    let name = &tz_name_or_path[rpos + needle.len()..];
252    trace!(
253        "extracted {name:?} from TZ={tz_name_or_path:?} \
254         and assuming it is an IANA time zone name",
255    );
256    match db.get(&name) {
257        Ok(tz) => return Ok(Some(tz)),
258        Err(_err) => {
259            trace!(
260                "using {name:?} from TZ={tz_name_or_path:?}, \
261                 could not find time zone in zoneinfo database {db:?} \
262                 (continuing to try and use {tz_name_or_path:?})",
263            );
264        }
265    }
266    // At this point, we have tried our hardest but we just cannot seem to
267    // extract an IANA time zone name out of the `TZ` environment variable.
268    // The only thing left for us to do is treat the value as a file path
269    // and read the data as TZif. This will give us time zone data if it works,
270    // but without a name.
271    Ok(sys::read(db, &tz_name_or_path))
272}
273
274/// Returns the given file path as TZif data without a time zone name.
275///
276/// Normally we require TZif time zones to have a name associated with it.
277/// But because there are likely platforms that hardlink /etc/localtime and
278/// perhaps have no other way to get a time zone name, we choose to support
279/// that use case. Although I cannot actually name such a platform...
280fn read_unnamed_tzif_file(path: &str) -> Result<TimeZone, Error> {
281    let data = std::fs::read(path)
282        .map_err(Error::io)
283        .with_context(|| err!("failed to read {path:?} as TZif file"))?;
284    let tz = TimeZone::tzif_system(&data)
285        .with_context(|| err!("found invalid TZif data at {path:?}"))?;
286    Ok(tz)
287}