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
86/// Which bound stopped an extraction early.
87///
88/// `SymbolicData` had no diagnostics channel at all (#2938), so a drawing that
89/// lost 60% of its curves was indistinguishable, in the response, from one that
90/// legitimately had nothing more to emit.
91///
92/// The reason matters as much as the fact. #2938's own lead case is a
93/// well-formed nested block import losing content to the PER-ITEM revisit
94/// budget while the whole-file totals sit far below the extraction bounds --
95/// so a diagnostic that only reported the extraction bounds would have reported
96/// nothing on the exact scenario the issue is about.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "kebab-case")]
99pub enum SymbolicTruncationReason {
100 /// [`MAX_SYMBOLIC_ELEMENTS`] reached.
101 ElementCount,
102 /// [`MAX_SYMBOLIC_BYTES`] reached.
103 OutputBytes,
104 /// One representation item nested deeper than the walk follows.
105 ItemDepth,
106 /// One representation item exhausted its revisit budget: the item was a
107 /// large acyclic fan-out, or a legitimate deeply-nested block import.
108 ItemRevisits,
109 /// The walk's path guard (`ItemWalk::enter_node`) refused to re-enter a
110 /// node already on the current path -- a genuine cycle in the
111 /// representation graph, not merely a large fan-out. Distinct from
112 /// [`Self::ItemRevisits`], whose budget can also be exhausted by an
113 /// acyclic file (#2938's lead case); this reason is a cycle and nothing
114 /// else (#3108).
115 ItemCycle,
116}
117
118impl SymbolicTruncationReason {
119 /// The wire spelling, identical to what `Serialize` emits.
120 ///
121 /// The WASM boundary cannot hand a serde enum to JavaScript, so it needs a
122 /// plain string; having it here rather than a `match` in wasm-bindings keeps
123 /// one vocabulary for both surfaces. `the_wire_spellings_match_serde` pins
124 /// them together, because two hand-kept lists is how they drift.
125 pub fn as_wire_str(self) -> &'static str {
126 match self {
127 Self::ElementCount => "element-count",
128 Self::OutputBytes => "output-bytes",
129 Self::ItemDepth => "item-depth",
130 Self::ItemRevisits => "item-revisits",
131 Self::ItemCycle => "item-cycle",
132 }
133 }
134}
135
136/// What stopped an extraction early, when something did.
137///
138/// Present only on a truncated result.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct SymbolicTruncation {
141 /// The MOST SEVERE bound that fired, not the first: an extraction bound
142 /// outranks a per-item one whatever the scan order. See
143 /// `SymbolicAccumulator::record`.
144 pub reason: SymbolicTruncationReason,
145 /// Primitives emitted in total. NOT necessarily equal to any limit: a
146 /// per-item bound stops one item's contribution while the file-level
147 /// totals stay far below the extraction bounds.
148 pub emitted: usize,
149 /// The bound's value, when the reason has a single numeric one. `None` for
150 /// per-item reasons, whose bound is per item and not comparable with
151 /// `emitted`.
152 ///
153 /// Skipped rather than serialized as `null`: the TypeScript mirror declares
154 /// `limit?: number`, which means the key is ABSENT. Emitting `null` satisfies
155 /// Rust and breaks the consumer's `'limit' in truncated` check.
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub limit: Option<usize>,
158}
159
160
161
162/// The extraction's accumulator: a `SymbolicData` under construction, plus the
163/// bound it is being built under.
164///
165/// The bound lives HERE and not on `SymbolicData` because `SymbolicData` is the
166/// wire type -- it is serialized into the response and into the on-disk parse
167/// cache. A policy knob on it would have to be `#[serde(skip)]` with a
168/// hand-written `Default` to stop `SymbolicData::default()` truncating on its
169/// first append, and every struct literal in the repo would break on the
170/// private field. None of that buys anything: the cap is a property of the
171/// EXTRACTION, and this is where extraction state belongs.
172///
173/// It also makes the seam real rather than conventional. During extraction the
174/// vectors are reachable only through `push_*`, because the emitters hold an
175/// accumulator and not a `SymbolicData`; the `pub` fields on the wire type are
176/// then harmless, since nothing is emitting through them.
177pub(super) struct SymbolicAccumulator {
178 data: SymbolicData,
179 /// Cap for this extraction. Injectable so a test can use 500 rather than
180 /// building a fixture that emits two million primitives.
181 limit: usize,
182 /// Refused appends, counted for tests. See [`Self::refusals`].
183 #[cfg(test)]
184 refusals: usize,
185 /// Estimated heap footprint charged so far. See [`MAX_SYMBOLIC_BYTES`].
186 bytes: usize,
187 /// Byte bound for this extraction, injectable alongside `limit`.
188 byte_limit: usize,
189 /// The most severe bound that fired, if any. See [`SymbolicAccumulator::record`].
190 reason: Option<SymbolicTruncationReason>,
191 /// Set only by the EXTRACTION bounds, never by a per-item one.
192 ///
193 /// These are two different questions and conflating them is a bug: "was
194 /// content dropped anywhere" is what the caller must be told, while "must
195 /// I stop walking the rest of the file" is true only when the accumulator
196 /// itself is full. A deep or fan-heavy single item drops its own content
197 /// and must NOT abandon every remaining product.
198 exhausted: bool,
199}
200
201impl SymbolicAccumulator {
202 /// Accumulator under the shipped cap.
203 pub(super) fn new() -> Self {
204 Self {
205 data: SymbolicData::default(),
206 limit: MAX_SYMBOLIC_ELEMENTS,
207 bytes: 0,
208 byte_limit: MAX_SYMBOLIC_BYTES,
209 reason: None,
210 exhausted: false,
211 #[cfg(test)]
212 refusals: 0,
213 }
214 }
215
216 /// Accumulator under caller-chosen bounds. Both are injectable so a test
217 /// can exercise either bound without building a fixture that reaches the
218 /// shipped ones.
219 #[cfg(test)]
220 pub(super) fn with_limits(limit: usize, byte_limit: usize) -> Self {
221 Self { limit, byte_limit, ..Self::new() }
222 }
223
224 /// Accumulator under a caller-chosen count cap and the shipped byte cap.
225 #[cfg(test)]
226 pub(super) fn with_limit(limit: usize) -> Self {
227 Self { limit, ..Self::new() }
228 }
229
230 /// Is the accumulator itself full? The walk and the product scan read THIS
231 /// to stop early. A per-item bound marks the result truncated WITHOUT setting
232 /// this, so one deep item does not abandon every later product.
233 pub(super) fn is_exhausted(&self) -> bool {
234 self.exhausted
235 }
236
237 /// Record that a per-item bound dropped content. Called by the walk, which
238 /// is the only place that knows a bound fired -- refusing to append is not
239 /// the same event and would report the wrong reason.
240 pub(super) fn note_item_bound(&mut self, reason: SymbolicTruncationReason) {
241 self.record(reason);
242 }
243
244 /// Keep the MOST SEVERE reason, not the first.
245 ///
246 /// First-wins is right within a bound class -- the second refusal at the
247 /// same cap is the same event continuing -- and wrong ACROSS classes. A
248 /// per-item bound firing on the first product is ordinary; the whole-output
249 /// cap firing later is the DoS-scale event, and scan order is
250 /// attacker-controlled. First-wins therefore let a file that blew the
251 /// 2,000,000-element ceiling report the mild `item-revisits` instead, with
252 /// its numeric limit dropped, purely because an unrelated item truncated
253 /// earlier in the file.
254 fn record(&mut self, reason: SymbolicTruncationReason) {
255 let severity = |r: SymbolicTruncationReason| match r {
256 SymbolicTruncationReason::ElementCount | SymbolicTruncationReason::OutputBytes => 1,
257 SymbolicTruncationReason::ItemDepth
258 | SymbolicTruncationReason::ItemRevisits
259 | SymbolicTruncationReason::ItemCycle => 0,
260 };
261 match self.reason {
262 Some(existing) if severity(existing) >= severity(reason) => {}
263 _ => self.reason = Some(reason),
264 }
265 }
266
267 /// Refused appends since the last reset, for tests only.
268 ///
269 /// Exists so the early exits can be pinned deterministically. They bound
270 /// WORK, and work is invisible in the output -- a refused append leaves the
271 /// result byte-identical -- but it is visible HERE, and the accumulator is
272 /// already test-injectable. Claiming this was unpinnable was wrong.
273 #[cfg(test)]
274 pub(super) fn refusals(&self) -> usize {
275 self.refusals
276 }
277
278 /// Total primitives emitted so far, across every collection.
279 fn len(&self) -> usize {
280 self.data.len()
281 }
282
283 /// Would appending a primitive of `payload` units exceed either bound?
284 ///
285 /// Two bounds, and the honest reason is NOT symmetry. Bytes alone would do
286 /// as a memory bound: every append charges at least
287 /// `PRIMITIVE_OVERHEAD_BYTES`, so 256 MiB caps a tiny-primitive flood at
288 /// ~4.2M anyway. The count bound earns its place differently -- its
289 /// constant is sized on drawing semantics (4x the largest legitimate case)
290 /// and it bounds the per-primitive costs downstream of this crate that a
291 /// byte estimate does not govern: JSON serialization, cache writes, and
292 /// client-side render setup are per-ELEMENT, not per-byte.
293 fn exceeded_by(&self, payload: usize) -> Option<SymbolicTruncationReason> {
294 if self.len() >= self.limit {
295 return Some(SymbolicTruncationReason::ElementCount);
296 }
297 if self.bytes + PRIMITIVE_OVERHEAD_BYTES + payload * BYTES_PER_COORD > self.byte_limit {
298 return Some(SymbolicTruncationReason::OutputBytes);
299 }
300 None
301 }
302
303 /// Charge an accepted append against the byte budget.
304 fn charge(&mut self, payload: usize) {
305 self.bytes += PRIMITIVE_OVERHEAD_BYTES + payload * BYTES_PER_COORD;
306 }
307
308 /// The one place an append is accepted or refused.
309 ///
310 /// Every `push_*` differs only in its payload charge and its target vector;
311 /// the decision (does this fit, which bound did it break, mark exhausted) is
312 /// identical. Keeping it in five copies is how `push_text` came to omit
313 /// `alignment` from its payload and under-count the byte bound by 13.5x, so
314 /// a sixth primitive must not have to re-derive the block to get it right.
315 fn try_push<F>(&mut self, payload: usize, push: F)
316 where
317 F: FnOnce(&mut SymbolicData),
318 {
319 if let Some(reason) = self.exceeded_by(payload) {
320 self.record(reason);
321 self.exhausted = true;
322 #[cfg(test)]
323 {
324 self.refusals += 1;
325 }
326 } else {
327 self.charge(payload);
328 push(&mut self.data);
329 }
330 }
331
332 /// Append a grid axis unless the extraction has hit its cap.
333 pub(super) fn push_grid_axis(&mut self, axis: SymbolicGridAxis) {
334 // `tag` comes from the file. Grid extraction is top-level and
335 // file-bounded so it has no fan-out amplifier, but an uncharged
336 // heap field is the same class of hole as `alignment` was.
337 let payload = axis.tag.len();
338 self.try_push(payload, |data| data.grid_axes.push(axis));
339 }
340
341 /// Append a polyline unless the extraction has hit its cap.
342 pub(super) fn push_polyline(&mut self, polyline: SymbolicPolyline) {
343 let payload = polyline.points.len()
344 + polyline.ifc_type.len()
345 + polyline.representation.len();
346 self.try_push(payload, |data| data.polylines.push(polyline));
347 }
348
349 /// Append a circle unless the extraction has hit its cap.
350 pub(super) fn push_circle(&mut self, circle: SymbolicCircle) {
351 let payload = 8 + circle.ifc_type.len() + circle.representation.len();
352 self.try_push(payload, |data| data.circles.push(circle));
353 }
354
355 /// Append a text annotation unless the extraction has hit its cap.
356 pub(super) fn push_text(&mut self, text: SymbolicText) {
357 // alignment is read straight from IfcTextLiteralWithExtent's
358 // BoxAlignment attribute with no length bound, and text literals are
359 // dispatched INSIDE the fan-out walk, so it is cloned on every
360 // emission. Omitting it made the byte bound a 13.5x under-count:
361 // 800,100 texts charged 54.9 MB while the process held 3.45 GB and
362 // `truncated` stayed None.
363 let payload = text.content.len()
364 + text.alignment.len()
365 + text.ifc_type.len()
366 + text.representation.len();
367 self.try_push(payload, |data| data.texts.push(text));
368 }
369
370 /// Append a filled region unless the extraction has hit its cap.
371 pub(super) fn push_fill(&mut self, fill: SymbolicFillArea) {
372 let payload = fill.points.len()
373 + fill.holes_offsets.len()
374 + fill.ifc_type.len()
375 + fill.representation.len();
376 self.try_push(payload, |data| data.fills.push(fill));
377 }
378
379 /// Finish, stamping the diagnostics field iff an append was ever refused.
380 pub(super) fn into_data(mut self) -> SymbolicData {
381 if let Some(reason) = self.reason {
382 let emitted = self.data.len();
383 let limit = match reason {
384 SymbolicTruncationReason::ElementCount => Some(self.limit),
385 SymbolicTruncationReason::OutputBytes => Some(self.byte_limit),
386 // Per-item bounds are per ITEM; reporting one next to a
387 // file-level `emitted` would invite the reader to compare two
388 // numbers that are not comparable.
389 SymbolicTruncationReason::ItemDepth
390 | SymbolicTruncationReason::ItemRevisits
391 | SymbolicTruncationReason::ItemCycle => None,
392 };
393 self.data.truncated = Some(SymbolicTruncation { reason, emitted, limit });
394 }
395 self.data
396 }
397}