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}