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}