Skip to main content

panproto_expr/
limits.rs

1//! One resource policy for every public surface.
2//!
3//! Several subsystems already impose useful local bounds: the parser's
4//! walk depth, CST extraction depth, expression evaluation steps,
5//! morphism search budget, model-check assignment counts. Each is
6//! sound on its own, and together they were not a policy: what an
7//! input was allowed to cost depended on which door it came through,
8//! so the same document could be refused through the CLI and accepted
9//! through the C ABI.
10//!
11//! Two properties make this a policy rather than another local bound.
12//!
13//! A [`Budget`] is *shared*, not per-call. A nested operation draws
14//! from the same allowance as the operation containing it, so a caller
15//! cannot be charged once for a walk and again for each subwalk, and a
16//! document cannot escape a bound by being processed in pieces.
17//!
18//! A failure names *which* resource ran out and *what* the bound was.
19//! "Too deep" without a number tells a caller nothing about what to
20//! pass instead.
21
22use std::sync::Arc;
23use std::sync::atomic::{AtomicU64, Ordering};
24
25/// The resources a bounded operation can exhaust.
26///
27/// Named separately so a failure says which allowance ran out, and so
28/// a caller raising one bound does not have to guess which.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum Resource {
31    /// Bytes of input accepted.
32    InputBytes,
33    /// Documents in one bundle.
34    BundleEntries,
35    /// Vertices, edges and nodes in a decoded schema or instance.
36    GraphElements,
37    /// Bytes of metadata attached to a schema or instance.
38    MetadataBytes,
39    /// Levels of nesting descended.
40    Depth,
41    /// Steps of evaluation or search performed.
42    Steps,
43    /// Bytes of output produced.
44    OutputBytes,
45}
46
47impl Resource {
48    /// The name used in diagnostics and in the configuration field.
49    #[must_use]
50    pub const fn as_str(self) -> &'static str {
51        match self {
52            Self::InputBytes => "input bytes",
53            Self::BundleEntries => "bundle entries",
54            Self::GraphElements => "graph elements",
55            Self::MetadataBytes => "metadata bytes",
56            Self::Depth => "depth",
57            Self::Steps => "steps",
58            Self::OutputBytes => "output bytes",
59        }
60    }
61}
62
63impl std::fmt::Display for Resource {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_str())
66    }
67}
68
69/// A bounded resource ran out.
70///
71/// Carries both halves a caller needs: which allowance was exhausted,
72/// and what it was set to, so raising it is a matter of reading the
73/// error rather than of guessing.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
75#[error("{resource} limit exceeded: the configured bound is {limit}")]
76pub struct LimitExceeded {
77    /// Which allowance ran out.
78    pub resource: Resource,
79    /// The bound that was configured for it.
80    pub limit: u64,
81}
82
83/// What a single operation is allowed to consume.
84///
85/// Every field is a maximum. `0` means unbounded, which is a
86/// deliberate choice a Rust caller can make and which no FFI or
87/// command-line entry point selects by default: unbounded behaviour
88/// should be asked for, never inherited.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct ResourceLimits {
91    /// Bytes of input accepted in one operation.
92    pub input_bytes: u64,
93    /// Documents in one bundle.
94    pub bundle_entries: u64,
95    /// Vertices, edges and nodes in a decoded schema or instance.
96    pub graph_elements: u64,
97    /// Bytes of metadata attached to a schema or instance.
98    pub metadata_bytes: u64,
99    /// Levels of nesting descended.
100    pub depth: u64,
101    /// Steps of evaluation or search performed.
102    pub steps: u64,
103    /// Bytes of output produced.
104    pub output_bytes: u64,
105}
106
107impl ResourceLimits {
108    /// The defaults every untrusted public surface uses.
109    ///
110    /// Chosen to admit any document a person would plausibly author
111    /// while refusing one built to exhaust a machine. The depth bound
112    /// matches the walk and extraction depths that already existed, so
113    /// this changes no behaviour those already governed.
114    #[must_use]
115    pub const fn defaults() -> Self {
116        Self {
117            input_bytes: 64 * 1024 * 1024,
118            bundle_entries: 4_096,
119            graph_elements: 1_000_000,
120            metadata_bytes: 16 * 1024 * 1024,
121            depth: 128,
122            steps: 10_000_000,
123            output_bytes: 64 * 1024 * 1024,
124        }
125    }
126
127    /// No bound on anything.
128    ///
129    /// For a Rust caller processing input it produced itself. Reaching
130    /// for this at a boundary that accepts input from elsewhere is what
131    /// this type exists to prevent.
132    #[must_use]
133    pub const fn unbounded() -> Self {
134        Self {
135            input_bytes: 0,
136            bundle_entries: 0,
137            graph_elements: 0,
138            metadata_bytes: 0,
139            depth: 0,
140            steps: 0,
141            output_bytes: 0,
142        }
143    }
144
145    /// The bound configured for `resource`.
146    #[must_use]
147    pub const fn get(&self, resource: Resource) -> u64 {
148        match resource {
149            Resource::InputBytes => self.input_bytes,
150            Resource::BundleEntries => self.bundle_entries,
151            Resource::GraphElements => self.graph_elements,
152            Resource::MetadataBytes => self.metadata_bytes,
153            Resource::Depth => self.depth,
154            Resource::Steps => self.steps,
155            Resource::OutputBytes => self.output_bytes,
156        }
157    }
158
159    /// Start a budget against these limits.
160    #[must_use]
161    pub fn budget(self) -> Budget {
162        Budget::new(self)
163    }
164}
165
166impl Default for ResourceLimits {
167    fn default() -> Self {
168        Self::defaults()
169    }
170}
171
172/// An allowance being drawn down by one operation and everything
173/// nested inside it.
174///
175/// Cloning shares the same counters rather than copying them, which is
176/// the property that makes limits compose: an operation that calls
177/// another passes a clone, and the two draw from one pool. A budget
178/// that reset per subsystem would bound each step and nothing overall,
179/// which is what having several unrelated local limits already
180/// achieved.
181#[derive(Clone, Debug)]
182pub struct Budget {
183    limits: ResourceLimits,
184    consumed: Arc<Consumed>,
185}
186
187#[derive(Debug, Default)]
188struct Consumed {
189    input_bytes: AtomicU64,
190    bundle_entries: AtomicU64,
191    graph_elements: AtomicU64,
192    metadata_bytes: AtomicU64,
193    steps: AtomicU64,
194    output_bytes: AtomicU64,
195}
196
197impl Budget {
198    /// A fresh budget against `limits`.
199    #[must_use]
200    pub fn new(limits: ResourceLimits) -> Self {
201        Self {
202            limits,
203            consumed: Arc::new(Consumed::default()),
204        }
205    }
206
207    /// A fresh budget against [`ResourceLimits::defaults`].
208    #[must_use]
209    pub fn with_defaults() -> Self {
210        Self::new(ResourceLimits::defaults())
211    }
212
213    /// The limits this budget draws against.
214    #[must_use]
215    pub const fn limits(&self) -> &ResourceLimits {
216        &self.limits
217    }
218
219    /// Charge `amount` against `resource`.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`LimitExceeded`] naming `resource` and its bound when
224    /// the charge would take the total past it. The amount is still
225    /// recorded, so a caller that ignores one failure and continues
226    /// does not find the next charge succeeding.
227    pub fn charge(&self, resource: Resource, amount: u64) -> Result<(), LimitExceeded> {
228        let limit = self.limits.get(resource);
229        let Some(counter) = self.counter(resource) else {
230            // Depth is not cumulative: it rises and falls with the
231            // walk, so it is checked against a level rather than a
232            // running total. See `enter`.
233            return self.check_depth(amount);
234        };
235        let total = counter.fetch_add(amount, Ordering::Relaxed) + amount;
236        if limit != 0 && total > limit {
237            return Err(LimitExceeded { resource, limit });
238        }
239        Ok(())
240    }
241
242    /// Check that `level` is within the depth bound.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`LimitExceeded`] for [`Resource::Depth`] when `level`
247    /// is past the bound.
248    pub const fn enter(&self, level: u64) -> Result<(), LimitExceeded> {
249        self.check_depth(level)
250    }
251
252    const fn check_depth(&self, level: u64) -> Result<(), LimitExceeded> {
253        let limit = self.limits.depth;
254        if limit != 0 && level > limit {
255            return Err(LimitExceeded {
256                resource: Resource::Depth,
257                limit,
258            });
259        }
260        Ok(())
261    }
262
263    /// How much of `resource` has been charged so far.
264    #[must_use]
265    pub fn consumed(&self, resource: Resource) -> u64 {
266        self.counter(resource)
267            .map_or(0, |c| c.load(Ordering::Relaxed))
268    }
269
270    fn counter(&self, resource: Resource) -> Option<&AtomicU64> {
271        match resource {
272            Resource::InputBytes => Some(&self.consumed.input_bytes),
273            Resource::BundleEntries => Some(&self.consumed.bundle_entries),
274            Resource::GraphElements => Some(&self.consumed.graph_elements),
275            Resource::MetadataBytes => Some(&self.consumed.metadata_bytes),
276            Resource::Steps => Some(&self.consumed.steps),
277            Resource::OutputBytes => Some(&self.consumed.output_bytes),
278            Resource::Depth => None,
279        }
280    }
281}
282
283impl Default for Budget {
284    fn default() -> Self {
285        Self::with_defaults()
286    }
287}