zic-rs
A memory-safe Rust timezone compiler for IANA tzdata, producing TZif files with
deterministic output and reference-zic comparison.
What this is
zic-rs compiles a declared subset of IANA tzdata source files (Zone / Rule /
Link lines) into binary TZif zoneinfo files, as specified by
RFC 9636 / tzfile(5). It is built as a small
compiler with a hostile reference oracle: every supported construct is checked against the
canonical C zic.
It is library-first — the compiler lives in the tzcompile library; the zic-rs binary
is a thin CLI over it.
A producer, not a datetime library. Rust already has timezone consumers and bundlers
(tz-rs, tzdb,
jiff/jiff-tzdb). zic-rs sits upstream of them: it compiles
and audits the TZif those consumers read. If you need to read local time in an app, use a
consumer; if you need to compile IANA source into verified TZif for a pinned release, that's
zic-rs. See docs/rust-ecosystem.md and the
generated-data contract.
Current trust ladder
zic-rs is best read not as "a Rust rewrite of zic" but as a reference-admitted TZif compiler
candidate whose claims are typed, witnessed, report-backed, and bounded. Each stage below answers a
distinct trust question and is sealed with a close receipt; start at
docs/reviewer-orientation.md before the details.
| Stage | Trust question | State |
|---|---|---|
| T12 | what source / build / profile evidence backs the artifact? | ✅ closed (manifest schema v8) |
| T13 | what diagnostics are emitted, by class/severity/verbosity/platform? | ✅ closed (ZIC001–ZIC020) |
| T14 | how does it behave under malformed / hostile input? | ✅ closed (ZIC021–ZIC025; found+removed a panic) |
| T15 | can a reviewer verify the conformance claim from machine-readable reports? | ✅ closed (ZIC026; 15 guard-enforced non-claims; no global verdict) |
| T16 | which release / reference / platform / table / vendor-receipt does a claim belong to? | ◐ in progress — T16.1–T16.6 sealed (inventory · ReferenceBuildProfile · locator+trust · aux-table validator · vendor-oracle receipt admission + ingestion · release-diff + doctor · 17 ecology rows / 19 receipts (18 admitted + 1 typed non-admission) — full BSD sweep (FreeBSD · OpenBSD · NetBSD · DragonFly) + illumos triad (OmniOS · SmartOS · OpenIndiana) + Linux (Alpine/musl-tzcode · Debian/NixOS/Ubuntu/AlmaLinux glibc · Gentoo/tzcode · openSUSE/tzcode-RPM) + Yocto/Poky (build-host tzcode-native 2026b admitted + target-runtime non-admission) + SLES 15-SP7 (commercial SUSE, container; tzcode-zic via timezone RPM ≡ openSUSE Leap) + Arch (rolling; tzcode-zic via the tzdata pacman pkg, glibc 2.43 unused); each admitted row rejects every fixture (safe), 3–4/5 class+location match + declared divergences; measured a real vendor lag spectrum, an old-fork input-compat gap, complete illumos convergence-on-three-independent-builds, two zic implementation lineages — IANA tzcode zic vs glibc's own zic, the glibc lineage version-stratified with a bracketed inflection (glibc 2.34/2.39 old-fork-like + fat < 2.40/2.41 modern + slim, transition between 2.39 and 2.40), that the zic lineage is a distro packaging choice independent of libc and package format (Gentoo runs tzcode-zic via timezone-data despite glibc 2.42; openSUSE runs tzcode-zic via the timezone RPM where AlmaLinux's RPM gives glibc-zic — so RPM ≠ glibc-zic), that bloat-default is its own packaging axis (fat in old-glibc + Gentoo-tzcode; slim in Alpine/openSUSE-tzcode + modern-glibc), that some artifacts ship no on-device zic (the Yocto/Poky target image = TZif consumer — precompiled data, compiler off-device on the build host), and that the embedded build-host producer ≠ the target-runtime consumer (Poky's tzcode-native owns zic; the produced image ships only data → non-admission); the two non-admissions prove vendor-oracle-admit rejects-by-rule, handling negative results as first-class). T16.6 ✅ sealed — operator tooling: release-diff (per-identifier OLD↔NEW classification, typed ReleaseChangeKind, structural always + behavioural via two zdump year-windows, oracle absence visible) + doctor (read-only host probe, typed ToolStatus, always exit 0); both additive/read-only. The six-axis vendor-oracle matrix renderer stays T16.6.x (on demand) |
| T17 | can the Rust implementation itself be made hard to embarrass? | ✅ closed — reports/t17-close-receipt.md. T17.1a parse bounds-guard + panic-policy.md · T17.1b ResourceLimits input caps (bucket-3 Error::config) · T17.2 CONTRACT.TYPING (6 free strings → typed enums, no schema bump) · T17.3 doctor/release-diff failure taxonomy (ToolVersionStatus/HashReadStatus → doctor v2 · OracleFailureScope · --split exclusive seam) · T17.4 install/materialization hardening (per-file crash-durable publish + leaf TOCTOU closed; install-materialization-contract.md) · T17.5 CountArithmeticVerdict (checked count/size/offset arithmetic + pre-allocation bound; risk-register.md) · T17.6 schema/CLI stability policy (schema-compatibility-policy.md + cli-compatibility-policy.md) · T17.FUZZ scaffold (fuzz/ — 9 receipt-bearing targets, runs pending_capture, no fuzz-run claim) · T17.7 verify-here remainder (tests/reliability.rs · SECURITY.md/CONTRIBUTING.md/CHANGELOG.md/RELEASE.md · schemas/ + registry/drift test); 480 tests, CORE.1 341/0/0 |
| T18 | where does each claim's external knowledge come from? | ◐ in progress — docs/zic-knowledge-index.md + source-ledger.tsv/archive-ledger.tsv + claim-source-map.md + copyright-non-republication-policy.md (13 sources, S1–S13, typed SourceLedgerEntry/AuthorityKind; archival capture pass COMPLETE — all 10 external sources captured, operator pass 2026-06-02; breadth→40 + T18.3 ledgers continue) |
| T19 | how does a reviewer/packager/security person decide it is safe to depend on? | ✅ sealed — the trust front door: TRUST.md (the evidence-court entrypoint) + docs/effect-boundary-map.md (per-module effect class) + docs/not-yet-ready.md (the loud refusal surface) + docs/replacement-readiness-ladder.md (RRL-0…6; zic-rs at RRL-1→2) + docs/drop-in-compatibility-contract.md (surface-by-surface — NOT an argv drop-in by design) + docs/misuse-resistance-ledger.md (misuse · guard · test · non-claim) + docs/audit-readiness.md (the packet, not a performed audit) + docs/maintenance-policy.md (MSRV · cadence · emergency-tzdb · refresh); docs-only — 480 tests, CORE.1 341/0/0 |
| T20 | what does the rewrite remove, and what may each audience conclude / NOT conclude? | ✅ sealed — docs/security-rewrite-evaluation.md (threat table · resource model · supply-chain [SBOM/SLSA/signing planned, not present] · Scorecard-readiness · the honest rewrite-safety delta) + docs/security-personas.md (10 personas × fears/evidence/commands/may-conclude/may-NOT-conclude/risks/owner); consumes risk-register + SECURITY.md + T19; docs-only — 480 tests, CORE.1 341/0/0 |
| T21 | minimal deterministic timezone bundles for containers/embedded | ◐ first cut — docs/container-embedded-builder.md (bundle contract · container recipe · no-host-contamination · copy-mode baseline · producer≠consumer) + zic-rs size-report (footprint + deterministic bundle_hash; zic-rs-size-report-v1); named profiles/--link-policy/reader-gauntlet planned — 485 tests, CORE.1 341/0/0 |
| T22 | is the resource cost measured and bounded? | ◐ first cut — docs/perf-ledger.md + reports/perf/ (a real measured receipt: 107 KB tzdata.zi → 598 TZif files in ≈12 ms / ≈22 MiB RSS, far inside the ResourceLimits caps); measured + bounded, not a speed claim; deterministic anchors (bytes/counts/bundle_hash) are the regression contract, timings are host/run weather |
| T23 | what does each audit tool witness — and what is actually run? | ◐ first cut + external batch run — audits/ (the exact authoritative list: 17 tool folders + README + index.html, each receipt-bearing; a folder existing is not a result). 7 real results (2026-06-02): cargo-audit ✅ clean (0 vulns / 67 deps), cargo-geiger ✅ (zic-rs own 0 unsafe; dep surface mapped), cargo-auditable ✅, miri ✅ clean over the tzif core (0 UB), panic-analysis ✅, dsfb-gray ✅, and kani ✅ — 6 bounded formal proofs (T23.kani.1 checked_block_len_never_panics — the T17.5 count-arithmetic guard; + T23.kani.2 Cursor take/skip/skip-within-remaining — the byte-cursor bounds invariants + the soundness of the ≤-remaining pre-allocation bound; + T23.kani.3a type_index_guard_is_sound — the type_index < typecnt OOB-index invariant; + T23.kani.3b abbr_index_guard_prevents_oob_slice — the abbreviation-index/charcnt bound; all 0-fail over their full symbolic input spaces). cargo-vet ◐ T23.cargo-vet.3 (4 first-party audits + 23 trusted-import + 39 honestly exempted) · cargo-valgrind ◐ env-incompatible (valgrind SIGILLs on /bin/true); kani's broader full-parser harness (T23.kani.3) ◐ timed out; the rest honest pending/experimental — no status upgraded without a receipt; not all audits are green |
Public reports (all --format json, hand-rolled + deterministic): support-report ·
structural-report · semantic-report · tzif-validate · aux-table-validate · release-diff · doctor · size-report · compile --manifest. Each carries an
oracle_mode (oracle absence is never silent), negative_capabilities (what is not claimed, each with
its enforcing guard), and a bounded conformance_status (never an unqualified compatible).
The live behaviour claim (never inflated): zic-rs behaviour-matches reference zic/zdump for all
341/341 canonical zones in tzdata.zi 2026b over 1900..2040 — 0 mismatch · 0 fail-closed (CORE.1).
Structural / byte / diagnostic / operational parity are separate axes, never collapsed into one verdict.
What this is not
- It is not yet a full replacement for reference
zic. - It does not install into
/usr/share/zoneinfo; all output goes under an explicit--outdirectory. - It refuses unsupported syntax with a clear diagnostic rather than emitting an approximate timezone file. Correctness is measured, not asserted.
- It does not decide timezone policy or curate timezone history. It compiles IANA tzdata
according to reference-
zicbehaviour; it never editorialises or "normalises" civil-time rules beyond whatzicdoes. (The tz database is maintained through IANA's process — RFC 6557 / BCP 175.)
Current implementation status
Implemented and verified:
- parsing of
Zone/Rule/Linkrecords, continuation lines, comments, quoting, and input-hardening limits; - compilation of fixed-offset zones (
RULES=-), finite rule-driven DST zones, recurring (TO = maximum) DST zones with a synthesised recurring POSIX footer (e.g.EST5EDT,M3.2.0,M11.1.0), inline-save eras (RULES= a clock value → a constantSTDOFF + SAVEtype, literal or%z), and multi-era zones (UNTILcontinuations) with correct cross-era state — includinglastSun/Sun>=N/Sun<=Nday forms, wall/standard/UTATsuffixes, and%s/STD/DST/%zformats; - real IANA-zone slices (tzdb 2026b):
Europe/London(zdump-matched over1830..2045; final EU era effectively recurring-only) andAmerica/New_York(1883..2040; a genuinely mixed-in-era final era — Rule US finite DST1967..2006plus recurring2007..max); Links (copy or symlink), with--zone <alias>resolving to the canonical zone;- a safe output tree (path-traversal-proof, atomic exclusive create, no clobber without
--force); - a two-mode oracle (
compare):zdumpbehaviour over a declared horizon (default; the real correctness criterion, used for DST/recurring zones) and decoded-TZif structural comparison (fixed-offset/debugging). Byte parity is reported only against pinned blobs.
For the fixed-offset fixtures (Etc/UTC, Test/Fixed), our output is byte-for-byte
identical to the reference zic blobs pinned in fixtures/expected/ (produced with
tzcode 2026b; see fixtures/MANIFEST.toml). For everything else the binding contract is
semantic parity, checked by zic-rs compare against your local reference zic via the
zdump behaviour oracle over a declared year horizon. Finite-DST, recurring-DST, and
multi-era zones all match the oracle (see docs/compatibility.md).
Source is read in both the canonical Rule/Zone/Link spelling and the
zishrink-abbreviated R/Z/L form, so the installed single-file
/usr/share/zoneinfo/tzdata.zi parses directly (record keywords match as zic-style
unambiguous prefixes, like month/weekday names).
Whole-database status. As of tzdata.zi 2026b, zic-rs compiles all 341 / 341 canonical zones
and behaviour-matches reference zic/zdump for every one over 1900..2040 — a comprehensive
per-zone sweep with 0 mismatches and 0 fail-closed (compile-clean and behaviour-match are
still tracked separately as different claims). The entire canonical-zone behaviour frontier is
closed. This is reference-verified behaviour for the canonical zones over a declared horizon —
not a full zic replacement and not infinity: leap-second modes, CLI/install policy,
right//posix/, backzone/rearguard, warning parity, slim/-r/-R/-b emission, and TZif v4
remain a declared operational roadmap (see docs/roadmap.md, T9–T17).
Structural parity is a separate, measured axis (campaign T8). The structural-report command
inventories how our TZif bytes differ from reference zic: across all 341 zones,
isutcnt/isstdcnt/leapcnt/typecnt/version/footer are at full parity — ttisstd/ttisut
is not a gap (both emit zero in this slim-default build); version+footer match 341/341 after
T8-v3 pinned zic.c's compat >= 2013 version rule (the day-shift, not the displayed time's range,
makes America/Santiago/Pacific/Easter v3); and charcnt matches 341/341 after T8-abbrev
ported zic's abbreviation suffix-sharing packer (HST⊂AHST, LMT⊂PLMT). The last axis, the
slim/fat timecnt window, is addressed by T8-slim: --emit-style zic-slim reproduces reference
zic's slim explicit-transition set, collapsing the slim/fat-timecnt class 75 → 1 (only
Europe/Lisbon, a ref-fatter-by-1 no-op) and raising byte-identical to 141/341 — while the
default emission stays the behaviour-matched fat output. Behaviour is 341/0/0 in both modes
(all zdump-equivalent). See docs/structural-parity.md. Behaviour
parity stays the contract; byte parity is claimed only in zic-slim mode (or where a blob is pinned).
Canonical-zone behaviour over 1900..2040 is closed for tzdata.zi 2026b — no canonical zone
fails closed (negative SAVE → law 7, and the non-POSIX Sat<=30-style recurring ON → law 10, are
both solved). What remains is deferred / outside the verified canonical-zone path, none of it
forced by tzdata.zi 2026b: inline-save eras with a %s or STD/DST slash FORMAT; a recurring rule
whose ON is a fixed numeric day (no weekday, hence no Mm.w.d footer); 24:00/negative compiled
AT times outside the implemented footer cases; leap-second / right/ modes; and the operational
zic shell (CLI/install, -r/-R/-b, warning parity) — all tracked in docs/roadmap.md
(T9–T21). (Accepted breadth — matching reference zic: both mixed finite+recurring final-era shapes,
inline-save with a literal or %z FORMAT, and the obsolete FROM = minimum coerced to 1900.) See
the pinned behaviour ledger docs/reference-zic-semantics.md.
Quick start
# Compile a fixed-offset zone and its link into ./zoneinfo
# See what a zone would compile to (or why it is unsupported)
# Compare our output against the system reference zic (semantic + byte)
# Print the supported syntax subset
# Map the whole installed tzdb: which zones compile, and why the rest don't (the frontier map)
# Inventory how our TZif *bytes* differ structurally from reference zic (separate from behaviour)
Documentation
| Doc | Contents |
|---|---|
| docs/design.md | Compiler phases and module layout |
| docs/supported-syntax.md | Exactly what the current declared subset supports |
| docs/unsupported-syntax.md | What is rejected, and why |
| docs/compatibility.md | Fixture zones and oracle status |
| docs/conformance-ladder.md | How IANA slices are admitted (the ladder + receipts); the support-report frontier map |
| docs/security.md | Untrusted-input handling and path safety |
| docs/tzif-notes.md | TZif writer structure and RFC alignment |
| docs/structural-parity.md | The measured structural-parity inventory (structural-report, T8): how our TZif bytes differ from reference zic, classified — a separate axis from behaviour parity |
| docs/zic-operational-parity.md | CLI / filesystem / install-mode parity (T9): the reference-zic flag inventory mapped to zic-rs, classified, with the safety boundary |
| docs/oracle-testing.md | Running comparisons against zic |
| docs/reference-zic-semantics.md | Pinned subtle zic behaviours (the semantics ledger) |
| docs/zic-deep-semantics.md | The named-law index: 18 zic state-machine laws, each tied to a zone/test/bucket |
| docs/zic-v-hazards.md | zic -v verbose-warning hazards → zic-rs status / test / bucket / campaign |
| docs/zic-hidden-compatibility.md | Operational / reader-compat traps (input format, -r/-00, ttisstd, limits, …) |
| docs/standards.md | Normative (RFC 9636 / zic / zdump) vs orientation sources |
| docs/tzdb-governance.md | The authority chain; why zic-rs is not a policy authority |
| docs/legal-history.md | Public-domain posture & provenance (brief) |
| docs/commentary-style.md | Comment/doc/test conventions |
| docs/rust-ecosystem.md | Where zic-rs sits vs tz-rs/tzdb/jiff (producer vs consumers) |
| docs/generated-data-contract.md | What zic-rs output guarantees a consumer |
| docs/consumer-testbench.md | Optional tz-rs interop bench (--features ecosystem-tests); not the oracle |
| docs/roadmap.md | Remaining milestones and deferred syntax |
| docs/prior-art.md | Why this does not duplicate existing crates |
| docs/PROMPT.md | The full project specification/prompt |
Acknowledgements
zic-rs builds on decades of work by the IANA Time Zone Database community. See ACKNOWLEDGEMENTS.md — and note that this project is not affiliated with or endorsed by IANA/ICANN, the TZ Coordinator, or the tzdb maintainers; bugs here are ours alone.
License
Dual-licensed under either of MIT or Apache-2.0, at your option.