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}