Skip to main content

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}