Skip to main content

tzcompile/
limits.rs

1//! Resource limits (T17.1b) — generous hard caps on input-driven dimensions.
2//!
3//! Reference `zic` caps none of these, so every cap here is a **bucket-3 intentional safer
4//! divergence** (see `docs/differences-from-reference-zic.md`): a reliability boundary that stops a
5//! malformed or adversarial source set from exhausting memory before [`compile`](crate::compile) ever
6//! runs. The defaults sit **far above any real tzdb** (2026b: ~350 zones, ~600 links, tens of rules
7//! per set, 27 leap seconds, link chains 1–3 deep, ~15 eras per zone), so no legitimate input is ever
8//! rejected — they exist only to bound the pathological tail.
9//!
10//! A breach is a plain [`Error::config`](crate::error::Error::config) (exit 1), **not** a `ZIC###`
11//! diagnostic: a cap is an operational safety limit, not a `zic`-grammar violation, so the diagnostic
12//! contract's code space stays reserved for source-grammar conditions. This pairs with the
13//! `tzif::validate::parse` bounds-guard (T17.1a) and the panic policy (`docs/panic-policy.md`).
14
15use crate::error::{Error, Result};
16use crate::model::Database;
17
18/// Default per-file source-byte ceiling (512 MiB; a real `tzdata.zi` is a few hundred KB).
19pub const DEFAULT_SOURCE_BYTES_MAX: usize = 512 * 1024 * 1024;
20/// Default cap on the number of `Zone` records (real tzdb: ~350).
21pub const DEFAULT_ZONE_COUNT_MAX: usize = 1_000_000;
22/// Default cap on the number of `Rule` rows in any one named rule set (real: tens–hundreds).
23pub const DEFAULT_RULE_COUNT_MAX: usize = 1_000_000;
24/// Default cap on the number of `Link` records (real: ~600).
25pub const DEFAULT_LINK_COUNT_MAX: usize = 1_000_000;
26/// Default cap on leap-second-table entries (real: 27).
27pub const DEFAULT_LEAP_COUNT_MAX: usize = 100_000;
28/// Default cap on link-chain resolution depth (real chains are 1–3 hops; also bounds the
29/// `visited.contains` cost in [`resolve_link_target`](crate::resolve_link_target)).
30pub const DEFAULT_LINK_CHAIN_DEPTH_MAX: usize = 256;
31/// Default cap on continuation eras within a single `Zone` (real: ~15).
32pub const DEFAULT_ZONE_ERA_COUNT_MAX: usize = 100_000;
33
34/// Generous reliability caps on input-driven resource dimensions. [`Default`] is the production set;
35/// tests construct tiny instances to exercise enforcement without giant fixtures, and a future CLI
36/// (T17.2) can expose overrides.
37#[derive(Debug, Clone, Copy)]
38pub struct ResourceLimits {
39    /// Maximum bytes in any single source file (checked before parsing each file).
40    pub source_bytes_max: usize,
41    /// Maximum number of `Zone` records in the assembled database.
42    pub zone_count_max: usize,
43    /// Maximum number of `Rule` rows in any one named rule set.
44    pub rule_count_max: usize,
45    /// Maximum number of `Link` records in the assembled database.
46    pub link_count_max: usize,
47    /// Maximum number of leap-second-table entries.
48    pub leap_count_max: usize,
49    /// Maximum link-chain resolution depth.
50    pub link_chain_depth_max: usize,
51    /// Maximum continuation eras within a single `Zone`.
52    pub zone_era_count_max: usize,
53}
54
55impl Default for ResourceLimits {
56    fn default() -> Self {
57        ResourceLimits {
58            source_bytes_max: DEFAULT_SOURCE_BYTES_MAX,
59            zone_count_max: DEFAULT_ZONE_COUNT_MAX,
60            rule_count_max: DEFAULT_RULE_COUNT_MAX,
61            link_count_max: DEFAULT_LINK_COUNT_MAX,
62            leap_count_max: DEFAULT_LEAP_COUNT_MAX,
63            link_chain_depth_max: DEFAULT_LINK_CHAIN_DEPTH_MAX,
64            zone_era_count_max: DEFAULT_ZONE_ERA_COUNT_MAX,
65        }
66    }
67}
68
69impl ResourceLimits {
70    /// Reject a source file whose byte length exceeds [`Self::source_bytes_max`]. Checked once per
71    /// file, before it is handed to the parser (so an oversize input never gets fully tokenised).
72    pub fn check_source_bytes(&self, len: usize, path: &std::path::Path) -> Result<()> {
73        if len > self.source_bytes_max {
74            return Err(Error::config(format!(
75                "source file {} is {len} bytes, exceeding the zic-rs resource limit of {} bytes",
76                path.display(),
77                self.source_bytes_max
78            )));
79        }
80        Ok(())
81    }
82
83    /// Reject a leap-second table with more than [`Self::leap_count_max`] entries.
84    pub fn check_leap_count(&self, n: usize) -> Result<()> {
85        if n > self.leap_count_max {
86            return Err(Error::config(format!(
87                "leap-second table has {n} entries, exceeding the zic-rs resource limit of {}",
88                self.leap_count_max
89            )));
90        }
91        Ok(())
92    }
93
94    /// Validate an assembled [`Database`] against the count caps: zones, links, rows-per-rule-set, and
95    /// continuation-eras-per-zone. Called once after parsing, before any compile.
96    pub fn enforce(&self, db: &Database) -> Result<()> {
97        if db.zones.len() > self.zone_count_max {
98            return Err(Error::config(format!(
99                "zone count {} exceeds the zic-rs resource limit of {}",
100                db.zones.len(),
101                self.zone_count_max
102            )));
103        }
104        if db.links.len() > self.link_count_max {
105            return Err(Error::config(format!(
106                "link count {} exceeds the zic-rs resource limit of {}",
107                db.links.len(),
108                self.link_count_max
109            )));
110        }
111        for (name, set) in &db.rules {
112            if set.len() > self.rule_count_max {
113                return Err(Error::config(format!(
114                    "rule set {name:?} has {} rows, exceeding the zic-rs resource limit of {}",
115                    set.len(),
116                    self.rule_count_max
117                )));
118            }
119        }
120        for z in &db.zones {
121            if z.eras.len() > self.zone_era_count_max {
122                return Err(Error::config(format!(
123                    "zone {:?} has {} continuation eras, exceeding the zic-rs resource limit of {}",
124                    z.name,
125                    z.eras.len(),
126                    self.zone_era_count_max
127                )));
128            }
129        }
130        Ok(())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::model::time::Offset;
138    use crate::model::{Database, LinkRecord, Origin, ZoneEra, ZoneRecord, ZoneRules};
139
140    fn origin() -> Origin {
141        Origin::new(std::path::Path::new("test.zi"), 1)
142    }
143
144    fn tiny() -> ResourceLimits {
145        ResourceLimits {
146            source_bytes_max: 10,
147            zone_count_max: 1,
148            rule_count_max: 1,
149            link_count_max: 1,
150            leap_count_max: 1,
151            link_chain_depth_max: 4,
152            zone_era_count_max: 1,
153        }
154    }
155
156    fn zone(name: &str) -> ZoneRecord {
157        ZoneRecord {
158            name: name.to_string(),
159            eras: Vec::new(),
160            origin: origin(),
161        }
162    }
163
164    fn link(from: &str, to: &str) -> LinkRecord {
165        LinkRecord {
166            target: to.to_string(),
167            link_name: from.to_string(),
168            origin: origin(),
169        }
170    }
171
172    #[test]
173    fn source_bytes_cap_rejects_oversize() {
174        let l = tiny();
175        assert!(l
176            .check_source_bytes(11, std::path::Path::new("big.zi"))
177            .is_err());
178        assert!(l
179            .check_source_bytes(10, std::path::Path::new("ok.zi"))
180            .is_ok());
181    }
182
183    #[test]
184    fn leap_count_cap_rejects_overflow() {
185        let l = tiny();
186        assert!(l.check_leap_count(2).is_err());
187        assert!(l.check_leap_count(1).is_ok());
188    }
189
190    #[test]
191    fn zone_and_link_count_caps() {
192        let l = tiny();
193        let mut db = Database::default();
194        db.zones.push(zone("A"));
195        db.links.push(link("L", "A"));
196        assert!(l.enforce(&db).is_ok(), "one each is within the cap");
197        db.zones.push(zone("B"));
198        let err = l.enforce(&db).unwrap_err();
199        assert!(err.to_string().contains("zone count 2 exceeds"));
200    }
201
202    fn era() -> ZoneEra {
203        ZoneEra {
204            stdoff: Offset(0),
205            rules: ZoneRules::None,
206            format: String::new(),
207            until: None,
208            origin: origin(),
209        }
210    }
211
212    #[test]
213    fn era_count_cap() {
214        // tiny cap is 1 era per zone; a single zone with 2 continuation eras breaches it (and the
215        // rule-set cap shares this exact `len() > max` loop shape).
216        let l = tiny();
217        let mut db = Database::default();
218        let mut z = zone("A");
219        z.eras.push(era());
220        db.zones.push(z.clone());
221        assert!(l.enforce(&db).is_ok(), "one era is within the cap");
222        db.zones[0].eras.push(era());
223        let err = l.enforce(&db).unwrap_err();
224        assert!(
225            err.to_string().contains("continuation eras"),
226            "expected an era-count breach, got: {err}"
227        );
228    }
229
230    #[test]
231    fn link_chain_depth_cap_bounds_long_acyclic_chains() {
232        // A straight acyclic chain L0 -> L1 -> ... -> L400 that never reaches a zone. The cycle check
233        // can't catch it (no repeat), so the depth cap must stop it with a typed error, not a hang.
234        let mut db = Database::default();
235        for i in 0..400 {
236            db.links
237                .push(link(&format!("L{i}"), &format!("L{}", i + 1)));
238        }
239        let err = crate::resolve_link_target(&db, "L0").unwrap_err();
240        assert!(
241            err.to_string().contains("depth limit"),
242            "expected a link-chain depth-limit error, got: {err}"
243        );
244    }
245
246    #[test]
247    fn defaults_pass_a_realistic_database() {
248        // The production defaults must never reject a normal-sized database.
249        let l = ResourceLimits::default();
250        let mut db = Database::default();
251        for i in 0..500 {
252            db.zones.push(zone(&format!("Zone/{i}")));
253            db.links
254                .push(link(&format!("Alias/{i}"), &format!("Zone/{i}")));
255        }
256        assert!(l.enforce(&db).is_ok());
257    }
258}