ifc_lite_processing/symbolic/output_cap.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The extraction-level output bound for `SymbolicData` (#2937, #2938).
6//!
7//! Split out of `primitives.rs` for the same reason `item_walk.rs` is split
8//! out of `items.rs`: that file is the WIRE SHAPE of the symbolic stream, and
9//! this is the policy deciding how much of it one extraction may produce. A
10//! reader auditing the bound should not have to read five primitive structs
11//! to find it, and a reader adding a primitive should not have to step around
12//! the bound.
13
14use super::primitives::{
15 SymbolicCircle, SymbolicData, SymbolicFillArea, SymbolicGridAxis, SymbolicPolyline,
16 SymbolicText,
17};
18use serde::{Deserialize, Serialize};
19
20/// Upper bound on the total number of symbolic primitives one extraction may
21/// emit, across every product in the file.
22///
23/// The per-item recursion bounds in `item_walk.rs` bound ONE top-level
24/// representation item. `extract_symbolic_data` calls the walk once per item of
25/// every Plan/Annotation/FootPrint/Axis representation of every product and
26/// accumulates into one `SymbolicData`, so the file-level total was
27/// `items x per-item bound` and nothing bounded the extraction (#2937).
28/// Measured on a crafted acyclic DAG: 20,002,500 polylines and **2.74 GB RSS from a 1.13 MB upload**, linear in file size, on a path the HTTP server calls
29/// with raw uploaded bytes (`apps/server/src/services/streaming.rs`).
30///
31/// Sized to sit well above real drawings rather than close to them. A flat
32/// `IfcGeometricCurveSet` of 200,050 curves is legitimate (plan hatching, a
33/// survey drawing, an imported DWG), and a nested block import reaching
34/// 500,000 is too, so this leaves roughly 4x headroom over the largest known
35/// legitimate case while still refusing the 20M one. Hitting it is reported,
36/// never silent -- see [`SymbolicData::truncated`].
37pub const MAX_SYMBOLIC_ELEMENTS: usize = 2_000_000;
38
39/// Upper bound on the estimated heap footprint of one extraction's output.
40///
41/// [`MAX_SYMBOLIC_ELEMENTS`] alone is NOT a memory bound, because per-primitive
42/// size is attacker-controlled: `SymbolicPolyline.points` and
43/// `SymbolicText.content` have no length limit anywhere in the extractor, and
44/// the fan-out attack re-emits ONE leaf up to the cap, cloning its point vector
45/// every time. So the leaf is paid for once in the file and two million times
46/// in RAM. Measured against a count-only cap, leaf point count the only knob:
47///
48/// leaf pts fixture emitted peak RSS
49/// 2 0.15 MB 2,000,000 278 MB
50/// 512 1.07 MB 2,000,000 8.47 GB
51/// 1024 2.03 MB 2,000,000 16.70 GB
52///
53/// Linear, and six times worse than the 2.74 GB the count cap was written to
54/// fix. A count cap tuned on a 2-point fixture measures the fixture, not the
55/// bound.
56///
57/// Every append charges its own estimated footprint, so a file of few enormous
58/// primitives and a file of many tiny ones are stopped at the same number of
59/// BYTES -- but only for fields the charge actually includes, which is why
60/// every variable-length field must be in the payload and not just the obvious
61/// one.
62///
63/// Headroom, stated honestly rather than as "far above any real drawing": 256
64/// MiB is ~33.5M charged payload units. The largest cited legitimate cases
65/// (200,050 curves; ~500,000 simple polylines, roughly 40 MB) clear it by ~6x.
66/// A DENSE vector import does not have that margin -- 100k contour polylines at
67/// 2,000 points each is ~200M coordinates and WOULD be truncated. That is a
68/// real drawing, and the honest position is that it degrades visibly (the
69/// result says so) rather than silently, which is the difference this change is
70/// about. Raise the constant if such a file turns up; do not assume it cannot.
71pub const MAX_SYMBOLIC_BYTES: usize = 256 * 1024 * 1024;
72
73/// Heap footprint charged for one emitted primitive.
74///
75/// Deliberately an ESTIMATE, and deliberately an over-estimate of the
76/// per-primitive constant: `Vec`/`String` headers, capacity slack and allocator
77/// rounding are real and a bound that ignores them is not a bound. Calibrated
78/// against the measurements above, which work out at roughly 64 bytes of
79/// fixed overhead plus 8 bytes per coordinate once allocator behaviour is
80/// included.
81const PRIMITIVE_OVERHEAD_BYTES: usize = 64;
82/// Charged per `f32` coordinate or per byte of text.
83const BYTES_PER_COORD: usize = 8;
84
85/// Which bound stopped an extraction early.
86///
87/// `SymbolicData` had no diagnostics channel at all (#2938), so a drawing that
88/// lost 60% of its curves was indistinguishable, in the response, from one that
89/// legitimately had nothing more to emit.
90///
91/// The reason matters as much as the fact. #2938's own lead case is a
92/// well-formed nested block import losing content to the PER-ITEM revisit
93/// budget while the whole-file totals sit far below the extraction bounds --
94/// so a diagnostic that only reported the extraction bounds would have reported
95/// nothing on the exact scenario the issue is about.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "kebab-case")]
98pub enum SymbolicTruncationReason {
99 /// [`MAX_SYMBOLIC_ELEMENTS`] reached.
100 ElementCount,
101 /// [`MAX_SYMBOLIC_BYTES`] reached.
102 OutputBytes,
103 /// One representation item nested deeper than the walk follows.
104 ItemDepth,
105 /// The EXTRACTION exhausted its revisit budget: a large acyclic fan-out,
106 /// or a legitimate deeply-nested block import.
107 ///
108 /// Shared across the whole file since #3114, not per item -- a per-item
109 /// budget reset on every top-level item, so nothing bounded a fan-out
110 /// spread across many items. The name is kept for wire compatibility.
111 /// Note the budget is extraction-wide while `ItemWalk::seen` stays per
112 /// item, so re-placing one library block is not charged as a revisit.
113 ItemRevisits,
114 /// The walk's path guard (`ItemWalk::enter_node`) refused to re-enter a
115 /// node already on the current path -- a genuine cycle in the
116 /// representation graph, not merely a large fan-out. Distinct from
117 /// [`Self::ItemRevisits`], whose budget can also be exhausted by an
118 /// acyclic file (#2938's lead case); this reason is a cycle and nothing
119 /// else (#3108).
120 ItemCycle,
121}
122
123impl SymbolicTruncationReason {
124 /// The wire spelling, identical to what `Serialize` emits.
125 ///
126 /// The WASM boundary cannot hand a serde enum to JavaScript, so it needs a
127 /// plain string; having it here rather than a `match` in wasm-bindings keeps
128 /// one vocabulary for both surfaces. `the_wire_spellings_match_serde` pins
129 /// them together, because two hand-kept lists is how they drift.
130 pub fn as_wire_str(self) -> &'static str {
131 match self {
132 Self::ElementCount => "element-count",
133 Self::OutputBytes => "output-bytes",
134 Self::ItemDepth => "item-depth",
135 Self::ItemRevisits => "item-revisits",
136 Self::ItemCycle => "item-cycle",
137 }
138 }
139}
140
141/// What stopped an extraction early, when something did.
142///
143/// Present only on a truncated result.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SymbolicTruncation {
146 /// The MOST SEVERE bound that fired, not the first: an extraction bound
147 /// outranks a per-item one whatever the scan order. See
148 /// `SymbolicAccumulator::record`.
149 pub reason: SymbolicTruncationReason,
150 /// Primitives emitted in total. NOT necessarily equal to any limit: a
151 /// traversal bound stops content from being produced while the file-level
152 /// totals stay far below the extraction bounds.
153 pub emitted: usize,
154 /// The bound's value, when the reason has a single numeric one. `None` for
155 /// the traversal reasons, whose bounds count a DIFFERENT UNIT from
156 /// `emitted` -- revisits and nesting depth, not primitives -- so there is
157 /// no meaningful "{emitted} of {limit}" to render.
158 ///
159 /// Note this is no longer because those bounds are per item: since #3114
160 /// the revisit budget is extraction-wide. It is the units that do not
161 /// line up, and that is what keeps `limit` absent.
162 ///
163 /// Skipped rather than serialized as `null`: the TypeScript mirror declares
164 /// `limit?: number`, which means the key is ABSENT. Emitting `null` satisfies
165 /// Rust and breaks the consumer's `'limit' in truncated` check.
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub limit: Option<usize>,
168}
169
170/// The extraction's accumulator: a `SymbolicData` under construction, plus the
171/// bound it is being built under.
172///
173/// The bound lives HERE and not on `SymbolicData` because `SymbolicData` is the
174/// wire type -- it is serialized into the response and into the on-disk parse
175/// cache. A policy knob on it would have to be `#[serde(skip)]` with a
176/// hand-written `Default` to stop `SymbolicData::default()` truncating on its
177/// first append, and every struct literal in the repo would break on the
178/// private field. None of that buys anything: the cap is a property of the
179/// EXTRACTION, and this is where extraction state belongs.
180///
181/// It also makes the seam real rather than conventional. During extraction the
182/// vectors are reachable only through `push_*`, because the emitters hold an
183/// accumulator and not a `SymbolicData`; the `pub` fields on the wire type are
184/// then harmless, since nothing is emitting through them.
185pub(super) struct SymbolicAccumulator {
186 data: SymbolicData,
187 /// Cap for this extraction. Injectable so a test can use 500 rather than
188 /// building a fixture that emits two million primitives.
189 pub(super) limit: usize,
190 /// Refused appends, counted for tests. See [`Self::refusals`].
191 #[cfg(test)]
192 pub(super) refusals: usize,
193 /// Estimated heap footprint charged so far. See [`MAX_SYMBOLIC_BYTES`].
194 bytes: usize,
195 /// Byte bound for this extraction, injectable alongside `limit`.
196 pub(super) byte_limit: usize,
197 /// Revisits the WHOLE extraction may still charge, shared across items (#2937).
198 pub(super) revisit_budget: u32,
199 /// The most severe bound that fired, if any. See [`SymbolicAccumulator::record`].
200 reason: Option<SymbolicTruncationReason>,
201 /// Set only by the EXTRACTION bounds, never by a per-item one.
202 ///
203 /// These are two different questions and conflating them is a bug: "was
204 /// content dropped anywhere" is what the caller must be told, while "must
205 /// I stop walking the rest of the file" is true only when the accumulator
206 /// itself is full. A deep or fan-heavy single item drops its own content
207 /// and must NOT abandon every remaining product.
208 exhausted: bool,
209}
210
211impl SymbolicAccumulator {
212 /// Accumulator under the shipped cap.
213 pub(super) fn new() -> Self {
214 Self {
215 data: SymbolicData::default(),
216 limit: MAX_SYMBOLIC_ELEMENTS,
217 bytes: 0,
218 byte_limit: MAX_SYMBOLIC_BYTES,
219 revisit_budget: super::item_walk::MAX_ITEM_REVISITS,
220 reason: None,
221 exhausted: false,
222 #[cfg(test)]
223 refusals: 0,
224 }
225 }
226
227 /// Is the accumulator itself full? The walk and the product scan read THIS
228 /// to stop early. A per-item bound marks the result truncated WITHOUT setting
229 /// this, so one deep item does not abandon every later product.
230 pub(super) fn is_exhausted(&self) -> bool {
231 self.exhausted
232 }
233
234 /// Record that a per-item bound dropped content. Called by the walk, which
235 /// is the only place that knows a bound fired -- refusing to append is not
236 /// the same event and would report the wrong reason.
237 pub(super) fn note_item_bound(&mut self, reason: SymbolicTruncationReason) {
238 self.record(reason);
239 }
240
241 /// Charge one revisit against the extraction-wide pool (#2937); `false` means spent.
242 pub(super) fn charge_revisit(&mut self) -> bool {
243 self.revisit_budget
244 .checked_sub(1)
245 .map(|left| self.revisit_budget = left)
246 .is_some()
247 }
248
249 /// Keep the MOST SEVERE reason, not the first.
250 ///
251 /// First-wins is right within a bound class -- the second refusal at the
252 /// same cap is the same event continuing -- and wrong ACROSS classes. A
253 /// per-item bound firing on the first product is ordinary; the whole-output
254 /// cap firing later is the DoS-scale event, and scan order is
255 /// attacker-controlled. First-wins therefore let a file that blew the
256 /// 2,000,000-element ceiling report the mild `item-revisits` instead, with
257 /// its numeric limit dropped, purely because an unrelated item truncated
258 /// earlier in the file.
259 fn record(&mut self, reason: SymbolicTruncationReason) {
260 let severity = |r: SymbolicTruncationReason| match r {
261 SymbolicTruncationReason::ElementCount | SymbolicTruncationReason::OutputBytes => 1,
262 SymbolicTruncationReason::ItemDepth
263 | SymbolicTruncationReason::ItemRevisits
264 | SymbolicTruncationReason::ItemCycle => 0,
265 };
266 match self.reason {
267 Some(existing) if severity(existing) >= severity(reason) => {}
268 _ => self.reason = Some(reason),
269 }
270 }
271
272 /// Total primitives emitted so far, across every collection.
273 fn len(&self) -> usize {
274 self.data.len()
275 }
276
277 /// Would appending a primitive of `payload` units exceed either bound?
278 ///
279 /// Two bounds, and the honest reason is NOT symmetry. Bytes alone would do
280 /// as a memory bound: every append charges at least
281 /// `PRIMITIVE_OVERHEAD_BYTES`, so 256 MiB caps a tiny-primitive flood at
282 /// ~4.2M anyway. The count bound earns its place differently -- its
283 /// constant is sized on drawing semantics (4x the largest legitimate case)
284 /// and it bounds the per-primitive costs downstream of this crate that a
285 /// byte estimate does not govern: JSON serialization, cache writes, and
286 /// client-side render setup are per-ELEMENT, not per-byte.
287 fn exceeded_by(&self, payload: usize) -> Option<SymbolicTruncationReason> {
288 if self.len() >= self.limit {
289 return Some(SymbolicTruncationReason::ElementCount);
290 }
291 if self.bytes + PRIMITIVE_OVERHEAD_BYTES + payload * BYTES_PER_COORD > self.byte_limit {
292 return Some(SymbolicTruncationReason::OutputBytes);
293 }
294 None
295 }
296
297 /// Charge an accepted append against the byte budget.
298 fn charge(&mut self, payload: usize) {
299 self.bytes += PRIMITIVE_OVERHEAD_BYTES + payload * BYTES_PER_COORD;
300 }
301
302 /// The one place an append is accepted or refused.
303 ///
304 /// Every `push_*` differs only in its payload charge and its target vector;
305 /// the decision (does this fit, which bound did it break, mark exhausted) is
306 /// identical. Keeping it in five copies is how `push_text` came to omit
307 /// `alignment` from its payload and under-count the byte bound by 13.5x, so
308 /// a sixth primitive must not have to re-derive the block to get it right.
309 fn try_push<F>(&mut self, payload: usize, push: F)
310 where
311 F: FnOnce(&mut SymbolicData),
312 {
313 if let Some(reason) = self.exceeded_by(payload) {
314 self.record(reason);
315 self.exhausted = true;
316 #[cfg(test)]
317 {
318 self.refusals += 1;
319 }
320 } else {
321 self.charge(payload);
322 push(&mut self.data);
323 }
324 }
325
326 /// Append a grid axis unless the extraction has hit its cap.
327 pub(super) fn push_grid_axis(&mut self, axis: SymbolicGridAxis) {
328 // `tag` comes from the file. Grid extraction is top-level and
329 // file-bounded so it has no fan-out amplifier, but an uncharged
330 // heap field is the same class of hole as `alignment` was.
331 let payload = axis.tag.len();
332 self.try_push(payload, |data| data.grid_axes.push(axis));
333 }
334
335 /// Append a polyline unless the extraction has hit its cap.
336 pub(super) fn push_polyline(&mut self, polyline: SymbolicPolyline) {
337 let payload = polyline.points.len()
338 + polyline.ifc_type.len()
339 + polyline.representation.len();
340 self.try_push(payload, |data| data.polylines.push(polyline));
341 }
342
343 /// Append a circle unless the extraction has hit its cap.
344 pub(super) fn push_circle(&mut self, circle: SymbolicCircle) {
345 let payload = 8 + circle.ifc_type.len() + circle.representation.len();
346 self.try_push(payload, |data| data.circles.push(circle));
347 }
348
349 /// Append a text annotation unless the extraction has hit its cap.
350 pub(super) fn push_text(&mut self, text: SymbolicText) {
351 // alignment is read straight from IfcTextLiteralWithExtent's
352 // BoxAlignment attribute with no length bound, and text literals are
353 // dispatched INSIDE the fan-out walk, so it is cloned on every
354 // emission. Omitting it made the byte bound a 13.5x under-count:
355 // 800,100 texts charged 54.9 MB while the process held 3.45 GB and
356 // `truncated` stayed None.
357 let payload = text.content.len()
358 + text.alignment.len()
359 + text.ifc_type.len()
360 + text.representation.len();
361 self.try_push(payload, |data| data.texts.push(text));
362 }
363
364 /// Append a filled region unless the extraction has hit its cap.
365 pub(super) fn push_fill(&mut self, fill: SymbolicFillArea) {
366 let payload = fill.points.len()
367 + fill.holes_offsets.len()
368 + fill.ifc_type.len()
369 + fill.representation.len();
370 self.try_push(payload, |data| data.fills.push(fill));
371 }
372
373 /// Finish, stamping the diagnostics field iff an append was ever refused.
374 pub(super) fn into_data(mut self) -> SymbolicData {
375 if let Some(reason) = self.reason {
376 let emitted = self.data.len();
377 let limit = match reason {
378 SymbolicTruncationReason::ElementCount => Some(self.limit),
379 SymbolicTruncationReason::OutputBytes => Some(self.byte_limit),
380 // These count revisits and depth, not primitives, so
381 // reporting one next to a primitive `emitted` would invite
382 // the reader to compare two numbers in different units.
383 SymbolicTruncationReason::ItemDepth
384 | SymbolicTruncationReason::ItemRevisits
385 | SymbolicTruncationReason::ItemCycle => None,
386 };
387 self.data.truncated = Some(SymbolicTruncation { reason, emitted, limit });
388 }
389 self.data
390 }
391}