jiff/tz/system/
unix.rs

1use crate::tz::{TimeZone, TimeZoneDatabase};
2
3static UNIX_LOCALTIME_PATH: &str = "/etc/localtime";
4
5/// Attempts to find the default "system" time zone.
6///
7/// In the happy path, this looks at `/etc/localtime`, assumes it is a
8/// symlink to something in `/usr/share/zoneinfo` and extracts out the IANA
9/// time zone name. From there, it looks up that time zone name in the given
10/// time zone database.
11///
12/// When `/etc/localtime` isn't a symlink, or if the time zone couldn't be
13/// found in the given time zone database, then `/etc/localtime` is read
14/// directly as a TZif data file describing a time zone. The reason why we
15/// try to avoid this is because a TZif data file does not contain the time
16/// zone name. And we *really* want the time zone name as it is the only
17/// standardized way to roundtrip a datetime in a particular time zone.
18pub(super) fn get(db: &TimeZoneDatabase) -> Option<TimeZone> {
19    read(db, UNIX_LOCALTIME_PATH)
20}
21
22/// Given a path to a system default TZif file, return its corresponding
23/// time zone.
24///
25/// In Unix, we attempt to read it as a symlink and extract an IANA time zone
26/// identifier. If that ID exists in the tzdb, we return that. Otherwise, we
27/// read the TZif file as an unnamed time zone.
28pub(super) fn read(db: &TimeZoneDatabase, path: &str) -> Option<TimeZone> {
29    if let Some(tz) = read_link_to_zoneinfo(db, path) {
30        return Some(tz);
31    }
32    trace!(
33        "failed to find time zone name using Unix-specific heuristics, \
34         attempting to read {UNIX_LOCALTIME_PATH} as unnamed time zone",
35    );
36    match super::read_unnamed_tzif_file(path) {
37        Ok(tz) => Some(tz),
38        Err(_err) => {
39            trace!("failed to read {path} as unnamed time zone: {_err}");
40            None
41        }
42    }
43}
44
45/// Attempt to determine the time zone name from the symlink path given.
46///
47/// If the path isn't a symlink or if its target wasn't recognized as
48/// pointing into a known zoneinfo database, then this returns `None` (and
49/// emits some log messages).
50fn read_link_to_zoneinfo(
51    db: &TimeZoneDatabase,
52    path: &str,
53) -> Option<TimeZone> {
54    let target = match std::fs::read_link(path) {
55        Ok(target) => target,
56        Err(_err) => {
57            trace!("failed to read {path} as symbolic link: {_err}");
58            return None;
59        }
60    };
61    let Some(target) = target.to_str() else {
62        trace!("symlink target {target:?} for {path:?} is not valid UTF-8");
63        return None;
64    };
65    let needle = "zoneinfo/";
66    let Some(rpos) = target.rfind(needle) else {
67        trace!(
68            "could not find {needle:?} in symlink target {target:?} \
69             for path {path:?}, so could not determine time zone name \
70             from symlink",
71        );
72        return None;
73    };
74    let name = &target[rpos + needle.len()..];
75    trace!(
76        "extracted {name:?} from symlink target {target:?} \
77         for path {path:?} and assuming it is an IANA time zone name",
78    );
79    let tz = match db.get(&name) {
80        Ok(tz) => tz,
81        Err(_err) => {
82            trace!(
83                "using {name:?} symlink target {target:?} \
84                 for path {path:?} as time zone name, \
85                 but failed to find time zone with that name in \
86                 zoneinfo database {db:?}",
87            );
88            return None;
89        }
90    };
91    Some(tz)
92}
93
94#[cfg(not(miri))]
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn get_time_zone_name_etc_localtime() {
101        let _ = crate::logging::Logger::init();
102
103        let db = crate::tz::db();
104        if crate::tz::db().is_definitively_empty() {
105            return;
106        }
107        let path = std::path::Path::new("/etc/localtime");
108        if !path.exists() {
109            return;
110        }
111        // It's hard to assert much other than that a time zone could be
112        // successfully constructed. Presumably this may fail in certain
113        // environments, but hopefully the `is_definitively_empty` check above
114        // will filter most out.
115        assert!(get(db).is_some());
116    }
117}