jiff/tz/db/zoneinfo/
enabled.rs

1use alloc::{
2    string::{String, ToString},
3    vec,
4    vec::Vec,
5};
6
7use std::{
8    ffi::OsStr,
9    fs::File,
10    io::Read,
11    path::{Path, PathBuf},
12    sync::{
13        atomic::{AtomicUsize, Ordering},
14        Arc, RwLock,
15    },
16    time::Duration,
17};
18
19use crate::{
20    error::{err, Error},
21    timestamp::Timestamp,
22    tz::{
23        db::special_time_zone, tzif::is_possibly_tzif, TimeZone,
24        TimeZoneNameIter,
25    },
26    util::{self, cache::Expiration, parse, utf8},
27};
28
29const DEFAULT_TTL: Duration = Duration::new(5 * 60, 0);
30
31#[cfg(unix)]
32static ZONEINFO_DIRECTORIES: &[&str] =
33    &["/usr/share/zoneinfo", "/usr/share/lib/zoneinfo", "/etc/zoneinfo"];
34
35// In non-Unix environments, there is (as of 2025-05-17) no standard location
36// for the zoneinfo database. And we specifically do not search the Unix-style
37// directories because this can have weird and undesirable effects on Windows.
38//
39// Ref https://github.com/BurntSushi/jiff/issues/376
40#[cfg(not(unix))]
41static ZONEINFO_DIRECTORIES: &[&str] = &[];
42
43pub(crate) struct Database {
44    dir: Option<PathBuf>,
45    names: Option<ZoneInfoNames>,
46    zones: RwLock<CachedZones>,
47}
48
49impl Database {
50    pub(crate) fn from_env() -> Database {
51        if let Some(tzdir) = std::env::var_os("TZDIR") {
52            let tzdir = PathBuf::from(tzdir);
53            trace!("opening zoneinfo database at TZDIR={}", tzdir.display());
54            match Database::from_dir(&tzdir) {
55                Ok(db) => return db,
56                Err(_err) => {
57                    // This is a WARN because it represents a failure to
58                    // satisfy a more direct request, which should be louder
59                    // than failures related to auto-detection.
60                    warn!("failed opening TZDIR={}: {_err}", tzdir.display());
61                    // fall through to attempt default directories
62                }
63            }
64        }
65        for dir in ZONEINFO_DIRECTORIES {
66            let tzdir = Path::new(dir);
67            trace!("opening zoneinfo database at {}", tzdir.display());
68            match Database::from_dir(&tzdir) {
69                Ok(db) => return db,
70                Err(_err) => {
71                    trace!("failed opening {}: {_err}", tzdir.display());
72                }
73            }
74        }
75        debug!(
76            "could not find zoneinfo database at any of the following \
77             paths: {}",
78            ZONEINFO_DIRECTORIES.join(", "),
79        );
80        Database::none()
81    }
82
83    pub(crate) fn from_dir(dir: &Path) -> Result<Database, Error> {
84        let names = Some(ZoneInfoNames::new(dir)?);
85        let zones = RwLock::new(CachedZones::new());
86        Ok(Database { dir: Some(dir.to_path_buf()), names, zones })
87    }
88
89    /// Creates a "dummy" zoneinfo database in which all lookups fail.
90    pub(crate) fn none() -> Database {
91        let dir = None;
92        let names = None;
93        let zones = RwLock::new(CachedZones::new());
94        Database { dir, names, zones }
95    }
96
97    pub(crate) fn reset(&self) {
98        let mut zones = self.zones.write().unwrap();
99        if let Some(ref names) = self.names {
100            names.reset();
101        }
102        zones.reset();
103    }
104
105    pub(crate) fn get(&self, query: &str) -> Option<TimeZone> {
106        if let Some(tz) = special_time_zone(query) {
107            return Some(tz);
108        }
109        // If we couldn't build any time zone names, then every lookup will
110        // fail. So just bail now.
111        let names = self.names.as_ref()?;
112        // The fast path is when the query matches a pre-existing unexpired
113        // time zone.
114        {
115            let zones = self.zones.read().unwrap();
116            if let Some(czone) = zones.get(query) {
117                if !czone.is_expired() {
118                    trace!(
119                        "for time zone query `{query}`, \
120                         found cached zone `{}` \
121                         (expiration={}, last_modified={:?})",
122                        czone.tz.diagnostic_name(),
123                        czone.expiration,
124                        czone.last_modified,
125                    );
126                    return Some(czone.tz.clone());
127                }
128            }
129        }
130        // At this point, one of three possible cases is true:
131        //
132        // 1. The given query does not match any time zone in this database.
133        // 2. A time zone exists, but isn't cached.
134        // 3. A zime exists and is cached, but needs to be revalidated.
135        //
136        // While (3) is probably the common case since our TTLs are pretty
137        // short, both (2) and (3) require write access. Thus we rule out (1)
138        // before acquiring a write lock on the entire database. Plus, we'll
139        // need the zone info for case (2) and possibly for (3) if cache
140        // revalidation fails.
141        //
142        // I feel kind of bad about all this because it seems to me like there
143        // is too much work being done while holding on to the write lock.
144        // In particular, it seems like bad juju to do any I/O of any kind
145        // while holding any lock at all. I think I could design something
146        // that avoids doing I/O while holding a lock, but it seems a lot more
147        // complicated. (And what happens if the I/O becomes outdated by the
148        // time you acquire the lock?)
149        let info = names.get(query)?;
150        let mut zones = self.zones.write().unwrap();
151        let ttl = zones.ttl;
152        match zones.get_zone_index(query) {
153            Ok(i) => {
154                let czone = &mut zones.zones[i];
155                if czone.revalidate(&info, ttl) {
156                    // Metadata on the file didn't change, so we assume the
157                    // file hasn't either.
158                    return Some(czone.tz.clone());
159                }
160                // Revalidation failed. Re-read the TZif data.
161                let czone = match CachedTimeZone::new(&info, zones.ttl) {
162                    Ok(czone) => czone,
163                    Err(_err) => {
164                        warn!(
165                            "failed to re-cache time zone from file {}: {_err}",
166                            info.inner.full.display(),
167                        );
168                        return None;
169                    }
170                };
171                let tz = czone.tz.clone();
172                zones.zones[i] = czone;
173                Some(tz)
174            }
175            Err(i) => {
176                let czone = match CachedTimeZone::new(&info, ttl) {
177                    Ok(czone) => czone,
178                    Err(_err) => {
179                        warn!(
180                            "failed to cache time zone from file {}: {_err}",
181                            info.inner.full.display(),
182                        );
183                        return None;
184                    }
185                };
186                let tz = czone.tz.clone();
187                zones.zones.insert(i, czone);
188                Some(tz)
189            }
190        }
191    }
192
193    pub(crate) fn available<'d>(&'d self) -> TimeZoneNameIter<'d> {
194        let Some(names) = self.names.as_ref() else {
195            return TimeZoneNameIter::empty();
196        };
197        TimeZoneNameIter::from_iter(names.available().into_iter())
198    }
199
200    pub(crate) fn is_definitively_empty(&self) -> bool {
201        self.names.is_none()
202    }
203}
204
205impl core::fmt::Debug for Database {
206    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
207        write!(f, "ZoneInfo(")?;
208        if let Some(ref dir) = self.dir {
209            write!(f, "{}", dir.display())?;
210        } else {
211            write!(f, "unavailable")?;
212        }
213        write!(f, ")")
214    }
215}
216
217#[derive(Debug)]
218struct CachedZones {
219    zones: Vec<CachedTimeZone>,
220    ttl: Duration,
221}
222
223impl CachedZones {
224    const DEFAULT_TTL: Duration = DEFAULT_TTL;
225
226    fn new() -> CachedZones {
227        CachedZones { zones: vec![], ttl: CachedZones::DEFAULT_TTL }
228    }
229
230    fn get(&self, query: &str) -> Option<&CachedTimeZone> {
231        self.get_zone_index(query).ok().map(|i| &self.zones[i])
232    }
233
234    fn get_zone_index(&self, query: &str) -> Result<usize, usize> {
235        // The common case is that our query matches the time zone name case
236        // sensitively, so check for that first. It's a bit cheaper than doing
237        // a case insensitive search.
238        if let Ok(i) = self
239            .zones
240            .binary_search_by(|zone| zone.name.original().cmp(&query))
241        {
242            return Ok(i);
243        }
244        self.zones.binary_search_by(|zone| {
245            utf8::cmp_ignore_ascii_case(zone.name.lower(), query)
246        })
247    }
248
249    fn reset(&mut self) {
250        self.zones.clear();
251    }
252}
253
254#[derive(Clone, Debug)]
255struct CachedTimeZone {
256    tz: TimeZone,
257    name: ZoneInfoName,
258    expiration: Expiration,
259    last_modified: Option<Timestamp>,
260}
261
262impl CachedTimeZone {
263    /// Create a new cached time zone.
264    ///
265    /// The `info` says which time zone to create and where to find it. The
266    /// `ttl` says how long the cached time zone should minimally remain fresh
267    /// for.
268    fn new(
269        info: &ZoneInfoName,
270        ttl: Duration,
271    ) -> Result<CachedTimeZone, Error> {
272        fn imp(
273            info: &ZoneInfoName,
274            ttl: Duration,
275        ) -> Result<CachedTimeZone, Error> {
276            let path = info.path();
277            let mut file =
278                File::open(path).map_err(|e| Error::io(e).path(path))?;
279            let mut data = vec![];
280            file.read_to_end(&mut data)
281                .map_err(|e| Error::io(e).path(path))?;
282            let tz = TimeZone::tzif(&info.inner.original, &data)
283                .map_err(|e| e.path(path))?;
284            let name = info.clone();
285            let last_modified = util::fs::last_modified_from_file(path, &file);
286            let expiration = Expiration::after(ttl);
287            Ok(CachedTimeZone { tz, name, expiration, last_modified })
288        }
289
290        let result = imp(info, ttl);
291        info.set_validity(result.is_ok());
292        result
293    }
294
295    /// Returns true if this time zone has gone stale and should, at minimum,
296    /// be revalidated.
297    fn is_expired(&self) -> bool {
298        self.expiration.is_expired()
299    }
300
301    /// Attempts to revalidate this cached time zone.
302    ///
303    /// Upon successful revalidation (that is, the cached time zone is still
304    /// fresh and okay to use), this returns true. Otherwise, the cached time
305    /// zone should be considered stale and must be re-created.
306    ///
307    /// Note that technically another layer of revalidation could be done.
308    /// For example, we could keep a checksum of the TZif data, and only
309    /// consider rebuilding the time zone when the checksum changes. But I
310    /// think the last modified metadata will in practice be good enough, and
311    /// parsing a TZif file should be quite fast.
312    fn revalidate(&mut self, info: &ZoneInfoName, ttl: Duration) -> bool {
313        // If we started with no last modified timestamp, then I guess we
314        // should always fail revalidation? I suppose a case could be made to
315        // do the opposite: always pass revalidation.
316        let Some(old_last_modified) = self.last_modified else {
317            trace!(
318                "revalidation for {} failed because old last modified time \
319                 is unavailable",
320                info.inner.full.display(),
321            );
322            return false;
323        };
324        let Some(new_last_modified) =
325            util::fs::last_modified_from_path(info.path())
326        else {
327            trace!(
328                "revalidation for {} failed because new last modified time \
329                 is unavailable",
330                info.inner.full.display(),
331            );
332            return false;
333        };
334        // We consider any change to invalidate cache.
335        if old_last_modified != new_last_modified {
336            trace!(
337                "revalidation for {} failed because last modified times \
338                 do not match: old = {} != {} = new",
339                info.inner.full.display(),
340                old_last_modified,
341                new_last_modified,
342            );
343            return false;
344        }
345        trace!(
346            "revalidation for {} succeeded because last modified times \
347             match: old = {} == {} = new",
348            info.inner.full.display(),
349            old_last_modified,
350            new_last_modified,
351        );
352        self.expiration = Expiration::after(ttl);
353        true
354    }
355}
356
357/// A collection of time zone names extracted from a zoneinfo directory.
358///
359/// Each time zone name maps to a full path on the file system corresponding
360/// to the TZif formatted data file for that time zone.
361///
362/// This type is responsible not just for providing the names, but also for
363/// updating them periodically.
364#[derive(Debug)]
365struct ZoneInfoNames {
366    inner: RwLock<ZoneInfoNamesInner>,
367}
368
369#[derive(Debug)]
370struct ZoneInfoNamesInner {
371    /// The directory from which we collected time zone names.
372    dir: PathBuf,
373    /// All available names from the `zoneinfo` directory.
374    ///
375    /// Each name corresponds to the suffix of a file path
376    /// starting with `dir`. For example, `America/New_York` in
377    /// `/usr/share/zoneinfo/America/New_York`. Each name also has a normalized
378    /// lowercase version of the name for easy case insensitive lookup.
379    names: Vec<ZoneInfoName>,
380    /// The expiration time of this cached value.
381    ///
382    /// Note that this is a necessary but not sufficient criterion for
383    /// invalidating the cached value.
384    ttl: Duration,
385    /// The time at which the data in `names` becomes stale.
386    expiration: Expiration,
387}
388
389impl ZoneInfoNames {
390    /// The default amount of time to wait before checking for added/removed
391    /// time zones.
392    ///
393    /// Note that this TTL is a necessary but not sufficient criterion to
394    /// provoke cache invalidation. Namely, since we don't expect the set of
395    /// possible time zone names to change often, we only invalidate the cache
396    /// under these circumstances:
397    ///
398    /// 1. The TTL or more has passed since the last time the names were
399    /// attempted to be refreshed (even if it wasn't successful).
400    /// 2. A name lookup is attempted and it isn't found. This is required
401    /// because otherwise there isn't much point in refreshing the names.
402    ///
403    /// This logic does not deal as well with removals from the underlying time
404    /// zone database. That in turn is covered by the TTL on constructing the
405    /// `TimeZone` values themselves.
406    ///
407    /// We could just use the second criterion on its own, but we require the
408    /// TTL to expire out of "good sense." Namely, if there is something borked
409    /// in the environment, the TTL will prevent doing a full scan of the
410    /// zoneinfo directory for every missed time zone lookup.
411    const DEFAULT_TTL: Duration = DEFAULT_TTL;
412
413    /// Create a new collection of names from the zoneinfo database directory
414    /// given.
415    ///
416    /// If no names of time zones with corresponding TZif data files could be
417    /// found in the given directory, then an error is returned.
418    fn new(dir: &Path) -> Result<ZoneInfoNames, Error> {
419        let names = walk(dir)?;
420        let dir = dir.to_path_buf();
421        let ttl = ZoneInfoNames::DEFAULT_TTL;
422        let expiration = Expiration::after(ttl);
423        let inner = ZoneInfoNamesInner { dir, names, ttl, expiration };
424        Ok(ZoneInfoNames { inner: RwLock::new(inner) })
425    }
426
427    /// Attempts to find the name entry for the given query using a case
428    /// insensitive search.
429    ///
430    /// If no match is found and the data is stale, then the time zone names
431    /// are refreshed from the file system before doing another check.
432    fn get(&self, query: &str) -> Option<ZoneInfoName> {
433        {
434            let inner = self.inner.read().unwrap();
435            if let Some(zone_info_name) = inner.get(query) {
436                return Some(zone_info_name);
437            }
438            drop(inner); // unlock
439        }
440        let mut inner = self.inner.write().unwrap();
441        inner.attempt_refresh();
442        inner.get(query)
443    }
444
445    /// Returns all available time zone names after attempting a refresh of
446    /// the underlying data if it's stale.
447    fn available(&self) -> Vec<String> {
448        let mut inner = self.inner.write().unwrap();
449        inner.attempt_refresh();
450        inner.available()
451    }
452
453    fn reset(&self) {
454        self.inner.write().unwrap().reset();
455    }
456}
457
458impl ZoneInfoNamesInner {
459    /// Attempts to find the name entry for the given query using a case
460    /// insensitive search.
461    ///
462    /// `None` is returned if one isn't found.
463    fn get(&self, query: &str) -> Option<ZoneInfoName> {
464        self.names
465            .binary_search_by(|n| {
466                utf8::cmp_ignore_ascii_case(&n.inner.lower, query)
467            })
468            .ok()
469            .map(|i| self.names[i].clone())
470    }
471
472    /// Returns all available time zone names.
473    fn available(&self) -> Vec<String> {
474        self.names
475            .iter()
476            .filter(|n| n.is_valid())
477            .map(|n| n.inner.original.clone())
478            .collect()
479    }
480
481    /// Attempts a refresh, but only follows through if the TTL has been
482    /// exceeded.
483    ///
484    /// The caller must ensure that the other cache invalidation criteria
485    /// have been upheld. For example, this should only be called for a missed
486    /// zone name lookup.
487    fn attempt_refresh(&mut self) {
488        if self.expiration.is_expired() {
489            self.refresh();
490        }
491    }
492
493    /// Forcefully refreshes the cached names with possibly new data from disk.
494    /// If an error occurs when fetching the names, then no names are updated
495    /// (but the `expires_at` is updated). This will also emit a warning log on
496    /// failure.
497    fn refresh(&mut self) {
498        // PERF: Should we try to move this `walk` call to run outside of a
499        // lock? It probably happens pretty rarely, so it might not matter.
500        let result = walk(&self.dir);
501        self.expiration = Expiration::after(self.ttl);
502        match result {
503            Ok(names) => {
504                self.names = names;
505            }
506            Err(_err) => {
507                warn!(
508                    "failed to refresh zoneinfo time zone name cache \
509                     for {}: {_err}",
510                    self.dir.display(),
511                )
512            }
513        }
514    }
515
516    /// Resets the state such that the next lookup is guaranteed to force a
517    /// cache refresh, and that it is impossible for any data to be stale.
518    fn reset(&mut self) {
519        // This will force the next lookup to fail.
520        self.names.clear();
521        // And this will force the next failed lookup to result in a refresh.
522        self.expiration = Expiration::expired();
523    }
524}
525
526/// A single TZif entry in a zoneinfo database directory.
527#[derive(Clone, Debug)]
528struct ZoneInfoName {
529    inner: Arc<ZoneInfoNameInner>,
530}
531
532#[derive(Debug)]
533struct ZoneInfoNameInner {
534    /// A file path resolvable to the corresponding file relative to the
535    /// working directory of this program.
536    ///
537    /// Should we canonicalize this to a absolute path? I guess in practice it
538    /// is an absolute path in most cases.
539    full: PathBuf,
540    /// The original name of this time zone taken from the file path with
541    /// no additional changes.
542    original: String,
543    /// The lowercase version of `original`. This is how we determine name
544    /// equality.
545    lower: String,
546    /// The known validity state of this time zone name. `0` means unknown (and
547    /// thus we need to check), `1` means "presumably valid" and `2` means
548    /// "known invalid." The "presumably valid" means that the file has a
549    /// 4-byte TZif header and the odds of a false positive a low enough that
550    /// we can and should behave as if that file is actually TZif and thus a
551    /// valid IANA time zone identifier.
552    validity: AtomicUsize,
553}
554
555impl ZoneInfoName {
556    /// Create a new time zone info name.
557    ///
558    /// `base` should corresponding to the zoneinfo directory from which the
559    /// suffix `time_zone_name` path was returned.
560    fn new(base: &Path, time_zone_name: &Path) -> Result<ZoneInfoName, Error> {
561        let full = base.join(time_zone_name);
562        let original = parse::os_str_utf8(time_zone_name.as_os_str())
563            .map_err(|err| err.path(base))?;
564        let lower = original.to_ascii_lowercase();
565        let inner = ZoneInfoNameInner {
566            full,
567            original: original.to_string(),
568            lower,
569            validity: AtomicUsize::new(ZONE_INFO_NAME_UNKNOWN),
570        };
571        Ok(ZoneInfoName { inner: Arc::new(inner) })
572    }
573
574    /// Returns the path to the corresponding (presumed) TZif file.
575    fn path(&self) -> &Path {
576        &self.inner.full
577    }
578
579    /// Returns the original name of this time zone.
580    fn original(&self) -> &str {
581        &self.inner.original
582    }
583
584    /// Returns the lowercase name of this time zone.
585    fn lower(&self) -> &str {
586        &self.inner.lower
587    }
588
589    /// Returns true if it is presumed that this points to a valid TZif file.
590    ///
591    /// The result of this function may use a cached value.
592    fn is_valid(&self) -> bool {
593        let validity = self.inner.validity.load(Ordering::Relaxed);
594        if validity == ZONE_INFO_NAME_VALID {
595            return true;
596        } else if validity == ZONE_INFO_NAME_INVALID {
597            return false;
598        }
599        if self.is_valid_impl() {
600            self.inner.validity.store(ZONE_INFO_NAME_VALID, Ordering::Relaxed);
601            true
602        } else {
603            self.inner
604                .validity
605                .store(ZONE_INFO_NAME_INVALID, Ordering::Relaxed);
606            false
607        }
608    }
609
610    /// Marks this zone info name as known to be valid or invalid.
611    ///
612    /// e.g., After doing a successful time zone lookup.
613    fn set_validity(&self, is_valid: bool) {
614        let validity = if is_valid {
615            ZONE_INFO_NAME_VALID
616        } else {
617            ZONE_INFO_NAME_INVALID
618        };
619        self.inner.validity.store(validity, Ordering::Relaxed);
620    }
621
622    /// Performs the actual validity check.
623    ///
624    /// This is generally only needed for APIs like
625    /// `TimeZoneDatabase::available()`, where we don't want to load every time
626    /// zone into memory but we also don't want to return IANA time zone ids
627    /// like `zone.tab`. (Because a `/usr/share/zoneinfo` directory might have
628    /// random junk in it.)
629    fn is_valid_impl(&self) -> bool {
630        let path = self.path();
631        let mut f = match File::open(path) {
632            Ok(f) => f,
633            Err(_err) => {
634                trace!("failed to open {}: {_err}", path.display());
635                return false;
636            }
637        };
638        let mut buf = [0; 4];
639        if let Err(_err) = f.read_exact(&mut buf) {
640            trace!(
641                "failed to read first 4 bytes of {}: {_err}",
642                path.display()
643            );
644            return false;
645        }
646        if !is_possibly_tzif(&buf) {
647            // This is a trace because it's perfectly normal for a
648            // non-TZif file to be in a zoneinfo directory. But it could
649            // still be potentially useful debugging info.
650            trace!(
651                "found file {} that isn't TZif since its first \
652                 four bytes are {:?}",
653                path.display(),
654                crate::util::escape::Bytes(&buf),
655            );
656            return false;
657        }
658        true
659    }
660}
661
662impl Eq for ZoneInfoName {}
663
664impl PartialEq for ZoneInfoName {
665    fn eq(&self, rhs: &ZoneInfoName) -> bool {
666        self.inner.lower == rhs.inner.lower
667    }
668}
669
670impl Ord for ZoneInfoName {
671    fn cmp(&self, rhs: &ZoneInfoName) -> core::cmp::Ordering {
672        self.inner.lower.cmp(&rhs.inner.lower)
673    }
674}
675
676impl PartialOrd for ZoneInfoName {
677    fn partial_cmp(&self, rhs: &ZoneInfoName) -> Option<core::cmp::Ordering> {
678        Some(self.cmp(rhs))
679    }
680}
681
682impl core::hash::Hash for ZoneInfoName {
683    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
684        self.inner.lower.hash(state);
685    }
686}
687
688static ZONE_INFO_NAME_UNKNOWN: usize = 0;
689static ZONE_INFO_NAME_VALID: usize = 1;
690static ZONE_INFO_NAME_INVALID: usize = 2;
691
692/// Recursively walks the given directory and returns the names of all time
693/// zones found.
694///
695/// This is guaranteed to return either one or more time zone names OR an
696/// error. That is, `Ok(vec![])` is an impossible result.
697///
698/// This will attempt to collect as many names as possible, even if some I/O
699/// operations fail.
700///
701/// The names returned are sorted in lexicographic order according to the
702/// lowercase form of each name.
703///
704/// # Performance
705///
706/// Note that this routine is written in a way that, at least on Unix, we
707/// should not be doing a syscall for every file. We need to do one for every
708/// directory, but that should be comparatively rare. It's done this way to
709/// avoid long initialization times when `/usr/share/zoneinfo` is on a slow
710/// file system.
711///
712/// See: https://github.com/BurntSushi/jiff/issues/366
713fn walk(start: &Path) -> Result<Vec<ZoneInfoName>, Error> {
714    struct StackEntry {
715        dir: PathBuf,
716        depth: usize,
717    }
718
719    let mut first_err: Option<Error> = None;
720    let mut seterr = |path: &Path, err: Error| {
721        if first_err.is_none() {
722            first_err = Some(err.path(path));
723        }
724    };
725
726    let mut names = vec![];
727    let mut stack = vec![StackEntry { dir: start.to_path_buf(), depth: 0 }];
728    while let Some(StackEntry { dir, depth }) = stack.pop() {
729        let readdir = match dir.read_dir() {
730            Ok(readdir) => readdir,
731            Err(err) => {
732                info!(
733                    "error when reading {} as a directory: {err}",
734                    dir.display()
735                );
736                seterr(&dir, Error::io(err));
737                continue;
738            }
739        };
740        for result in readdir {
741            let dent = match result {
742                Ok(dent) => dent,
743                Err(err) => {
744                    info!(
745                        "error when reading directory entry from {}: {err}",
746                        dir.display()
747                    );
748                    seterr(&dir, Error::io(err));
749                    continue;
750                }
751            };
752            let file_type = match dent.file_type() {
753                Ok(file_type) => file_type,
754                Err(err) => {
755                    let path = dent.path();
756                    info!(
757                        "error when reading file type from {}: {err}",
758                        path.display()
759                    );
760                    seterr(&path, Error::io(err));
761                    continue;
762                }
763            };
764            let path = dent.path();
765            if file_type.is_dir() {
766                // We ignore the `posix` and `right` directories because Jiff
767                // doesn't care about them. They tend to bloat the output of
768                // `TimeZoneDatabase::available()` for no appreciable reason.
769                // If callers want to use them, they can do, e.g.,
770                // `TZDIR=/usr/share/zoneinfo/posix`. Moreover, they slow down
771                // initialization time in environments with very slow file
772                // systems.
773                if depth == 0
774                    && (dent.file_name() == OsStr::new("posix")
775                        || dent.file_name() == OsStr::new("right"))
776                {
777                    continue;
778                }
779                stack.push(StackEntry {
780                    dir: path,
781                    depth: depth.saturating_add(1),
782                });
783                continue;
784            }
785            trace!(
786                "zoneinfo database initialization visiting {path}",
787                path = path.display(),
788            );
789            // We assume symlinks are files, although this may not be
790            // appropriate. If we need to also handle the case when they're
791            // directories, then we'll need to add symlink loop detection.
792
793            let time_zone_name = match path.strip_prefix(start) {
794                Ok(time_zone_name) => time_zone_name,
795                Err(err) => {
796                    trace!(
797                        "failed to extract time zone name from {} \
798                         using {} as a base: {err}",
799                        path.display(),
800                        start.display(),
801                    );
802                    seterr(&path, Error::adhoc(err));
803                    continue;
804                }
805            };
806            let zone_info_name =
807                match ZoneInfoName::new(&start, time_zone_name) {
808                    Ok(zone_info_name) => zone_info_name,
809                    Err(err) => {
810                        seterr(&path, err);
811                        continue;
812                    }
813                };
814            names.push(zone_info_name);
815        }
816    }
817    if names.is_empty() {
818        let err = first_err
819            .take()
820            .unwrap_or_else(|| err!("{}: no TZif files", start.display()));
821        Err(err)
822    } else {
823        // If we found at least one valid name, then we declare success and
824        // drop any error we might have found. They do all get logged above
825        // though.
826        names.sort();
827        Ok(names)
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    /// DEBUG COMMAND
836    ///
837    /// Takes environment variable `JIFF_DEBUG_ZONEINFO_DIR` as input and
838    /// prints a list of all time zone names in the directory (one per line).
839    ///
840    /// Callers may also set `RUST_LOG` to get extra debugging output.
841    #[test]
842    fn debug_zoneinfo_walk() -> anyhow::Result<()> {
843        let _ = crate::logging::Logger::init();
844
845        const ENV: &str = "JIFF_DEBUG_ZONEINFO_DIR";
846        let Some(val) = std::env::var_os(ENV) else { return Ok(()) };
847        let dir = PathBuf::from(val);
848        let names = walk(&dir)?;
849        for n in names {
850            std::eprintln!("{}", n.inner.original);
851        }
852        Ok(())
853    }
854}