zic-rs treats tzdata source as untrusted input and treats the output tree as a
capability boundary. The threat model: a malicious or corrupt source file, or a hostile
zone/link name, must not be able to read or write outside the explicit output directory,
crash the process unsafely, or cause it to consume unbounded resources.
#![forbid(unsafe_code)]at the crate root and the binary — there is nounsafeanywhere.overflow-checks = trueeven in release builds (correctness over micro-optimisation).- Panic discipline is its own contract — see
panic-policy.md: no malformed/ hostile input may panic the library (it becomes a typedErr); panics are reserved for violated internal invariants. T17.1 added the TZif-read bounds-guard (transitiontype_index < typecnt) at thetzif::validate::parsechoke point so no consumer can index out of bounds. - The full failure-mode map — how zic-rs can produce a plausible-but-wrong artifact and what guards
or explicit non-claims bound each case — is the
risk-register.md(15 claim-boundary risks; the dangerous failures are claim-boundary bugs, not first memory bugs). Prove the claim with typed evidence, or refuse it explicitly. - Filesystem materialization (path/symlink/hardlink/clobber/temp/rename/fsync policies + the exact
crash-durability claim and the named TOCTOU residual) is the
install-materialization-contract.md(T17.4): per-file crash-durable publish on Unix; whole-tree crash-atomicity and the parent-component symlink-swap race are explicit non-claims. - Reports + CLI are public contracts —
schema-compatibility-policy.md(when a schema bumps, public-literal ownership, typed-unknown-over-silence, old-reader fail-closed) andcli-compatibility-policy.md(the 0/1/2 exit taxonomy; every command classified gate/diagnosis/witness/admission/convenience; exit 0 from a report command means "it ran," not "all clear") (T17.6).
- NUL bytes in a line are rejected.
- Line length is capped at 2048 bytes (including the newline), matching
zic. - Input must be valid UTF-8 (a deliberate byte-level parser could relax this; silently accepting arbitrary bytes would be a foot-gun, so we don't).
- Unterminated quotes are a hard error, not a best-effort guess.
Zone and link names are untrusted. [safe_relative_path] is the single choke point that
every write passes through. It rejects, with ZIC008_OUTPUT_PATH_TRAVERSAL:
- absolute names (leading
/); - any
.or..path component (no traversal); - empty components (
a//b); - components beginning with
-(avoids option-looking names;zic -vmerely warns — we reject, failing closed); - NUL bytes.
After constructing a path we additionally verify, lexically, that it is contained within
the output root (is_contained) — defence in depth in case the root itself is unusual.
- Output goes only under the explicit
--outdirectory. There is no implicit/usr/share/zoneinfodefault; a missing--outis a clean error. - Writes are atomic (temp file in the same directory, then
rename), so readers never see a half-written TZif file. - Existing files are never overwritten without
--force. - Symlink mode creates relative links inside the tree; it does not follow or write
through pre-existing symlinks (it removes a conflicting link only with
--force).
The compiler never shells out. The only code that runs an external process is the
compare oracle, which invokes reference zic into a caller-controlled temp directory and
is never reached during normal compilation.
A per-zone transition limit (MAX_TRANSITIONS, ZIC009) guards against pathological rule
expansion: a rule set that would generate more explicit transitions than the limit fails
closed with a clear diagnostic rather than allocating unboundedly. This is load-bearing now
that the transition compiler is live (fixed-offset zones still produce zero transitions; DST
rule sets expand across their year span). Making the limit caller-configurable
(--transition-limit, wiring CompileConfig.transition_limit) is a tracked follow-up.
T17.1b added a limits::ResourceLimits layer over the input dimensions reference zic leaves
unbounded — per-file source bytes, zone / rule-per-set / link / leap-table counts,
link-chain depth, and continuation-eras-per-zone. Each has a generous hard default (far above
any real tzdb), enforced once at load_database (counts + per-file bytes), in resolve_link_target
(chain depth — complementing the precise cycle check, and bounding its visited.contains cost), and in
parse_leap_source (leap count). A breach is a plain Error::config, not a ZIC### diagnostic
(an operational safety limit, not a grammar violation). The line-length cap (MAX_LINE_LEN, ZIC017)
and the abbreviation-table byte cap (the u8 designation index) were already in place. See
panic-policy.md for the full reliability posture and its honest non-claims.
zic-rs hashes its compiled TZif files with a small in-house SHA-256 (src/hash.rs,
verified against the FIPS 180-4 / NIST vectors and cross-checked against the system
sha256sum). That digest is used only to fingerprint output artifacts for deterministic
provenance and change detection — the alias-map.json and the compile manifest. It is
not used for, and must not be relied on for, authentication, digital signatures, message
authentication codes (MACs), password hashing, or any adversarial/security protocol. zic-rs
does not implement cryptographic security and assumes a non-malicious build pipeline; the
threat model above is about untrusted tzdata input and the output-tree capability boundary,
not about an attacker who controls the hash function's inputs to forge a collision. If a
security-grade digest is ever needed, depend on a vetted cryptographic crate rather than this
module. (See the matching scope note at the top of src/hash.rs.)