Skip to main content

draco_io/
fbx_options.rs

1//! Byte order, resource limits, and read options for the FBX binary reader.
2//!
3//! FBX is the only hand-rolled untrusted binary format in this crate, and its
4//! node records carry file-controlled lengths that feed allocations directly.
5//! The limits here bound those allocations; [`FbxReadOptions`] selects how
6//! strictly the container layout is enforced.
7
8/// Byte order of an FBX file, chosen by the header's endian marker.
9///
10/// The canonical Autodesk profile is little-endian (marker `0`). A non-zero
11/// marker selects big-endian, matching how `ufbx` interprets the field.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum FbxByteOrder {
14    /// Canonical little-endian layout.
15    Little,
16    /// Big-endian layout, produced by some Maya builds.
17    Big,
18}
19
20macro_rules! decode_scalar {
21    ($name:ident, $ty:ty, $width:expr) => {
22        /// Decodes one value in this byte order.
23        #[inline]
24        pub fn $name(self, bytes: [u8; $width]) -> $ty {
25            match self {
26                FbxByteOrder::Little => <$ty>::from_le_bytes(bytes),
27                FbxByteOrder::Big => <$ty>::from_be_bytes(bytes),
28            }
29        }
30    };
31}
32
33impl FbxByteOrder {
34    decode_scalar!(u16, u16, 2);
35    decode_scalar!(i16, i16, 2);
36    decode_scalar!(u32, u32, 4);
37    decode_scalar!(i32, i32, 4);
38    decode_scalar!(u64, u64, 8);
39    decode_scalar!(i64, i64, 8);
40    decode_scalar!(f32, f32, 4);
41    decode_scalar!(f64, f64, 8);
42
43    /// Reverses each `N`-byte element in place when the file is big-endian.
44    ///
45    /// Array payloads are converted in bulk so the little-endian path keeps
46    /// its original per-element decode with no added branch.
47    #[inline]
48    pub(crate) fn swap_elements_in_place(self, data: &mut [u8], element_size: usize) {
49        if self == FbxByteOrder::Little || element_size < 2 {
50            return;
51        }
52        for element in data.chunks_exact_mut(element_size) {
53            element.reverse();
54        }
55    }
56}
57
58/// Bounds on what a single FBX document may allocate while decoding.
59///
60/// Every field is a hard ceiling: exceeding one fails the read with
61/// [`std::io::ErrorKind::OutOfMemory`], which callers can distinguish from the
62/// [`std::io::ErrorKind::InvalidData`] used for structural corruption.
63///
64/// The defaults are calibrated against real assets rather than guessed. They
65/// are deliberately far above observed usage, because their job is to stop a
66/// hostile header from claiming gigabytes, not to police legitimate files.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct FbxDecodeLimits {
70    /// Largest accepted input, in bytes.
71    pub max_file_bytes: u64,
72    /// Deepest accepted node nesting.
73    pub max_depth: u32,
74    /// Largest accepted total node count.
75    pub max_nodes: u64,
76    /// Largest accepted property count on one node.
77    pub max_properties_per_node: u64,
78    /// Largest accepted `S` (string) property payload, in bytes.
79    pub max_string_bytes: u64,
80    /// Largest accepted `R` (raw blob) property payload, in bytes.
81    ///
82    /// Embedded textures arrive through `Video.Content` as `R` properties, so
83    /// this is the limit most likely to matter for real scenes.
84    pub max_blob_bytes: u64,
85    /// Largest accepted element count in one array property.
86    pub max_array_elements: u64,
87    /// Largest accepted decoded size of one array property, in bytes.
88    pub max_array_raw_bytes: u64,
89    /// Largest accepted decoded size of all array properties in one document.
90    pub max_total_array_raw_bytes: u64,
91}
92
93impl Default for FbxDecodeLimits {
94    fn default() -> Self {
95        // Observed maxima across the ufbx corpus (683 binary files) and the
96        // Mixamo/Three.js assets the browser app loads:
97        //
98        //   file bytes            7,045,584     nodes                 15,039
99        //   depth                         8     properties per node    4,224
100        //   blob bytes              328,086     string bytes          87,207
101        //   array raw bytes       1,661,184     array elements       207,648
102        //   total array bytes     7,352,416
103        //
104        // Note `properties per node` alone rules out a 4096 ceiling, which a
105        // plausible-looking guess would have picked.
106        Self {
107            max_file_bytes: 1 << 30, // 1 GiB
108            max_depth: 64,           // ufbx stops at 32
109            max_nodes: 8_000_000,
110            max_properties_per_node: 1 << 16,
111            max_string_bytes: 16 << 20, // 16 MiB
112            max_blob_bytes: 128 << 20,  // 128 MiB, ~400x observed
113            max_array_elements: 1 << 28,
114            max_array_raw_bytes: 256 << 20,     // 256 MiB
115            max_total_array_raw_bytes: 1 << 30, // 1 GiB
116        }
117    }
118}
119
120impl FbxDecodeLimits {
121    /// Limits raised well beyond [`Self::default`], for trusted local input.
122    pub fn permissive() -> Self {
123        Self {
124            max_file_bytes: 16 << 30,
125            max_depth: 256,
126            max_nodes: 64_000_000,
127            max_properties_per_node: 1 << 20,
128            max_string_bytes: 256 << 20,
129            max_blob_bytes: 2 << 30,
130            max_array_elements: 1 << 32,
131            max_array_raw_bytes: 4 << 30,
132            max_total_array_raw_bytes: 8 << 30,
133        }
134    }
135
136    /// Tight limits for fuzzing, so a reported allocation failure is a real
137    /// bug rather than the fuzzer feeding a legitimately huge header.
138    pub fn fuzzing() -> Self {
139        Self {
140            max_file_bytes: 1 << 20,
141            max_depth: 16,
142            max_nodes: 64_000,
143            max_properties_per_node: 4096,
144            max_string_bytes: 1 << 20,
145            max_blob_bytes: 1 << 20,
146            max_array_elements: 1 << 20,
147            max_array_raw_bytes: 4 << 20,
148            max_total_array_raw_bytes: 4 << 20,
149        }
150    }
151}
152
153// `#[non_exhaustive]` blocks `..Default::default()` for downstream crates, so
154// the type would be unconfigurable outside this crate without these setters.
155macro_rules! limit_setter {
156    ($name:ident, $field:ident, $ty:ty, $doc:expr) => {
157        #[doc = $doc]
158        #[must_use]
159        pub fn $name(mut self, value: $ty) -> Self {
160            self.$field = value;
161            self
162        }
163    };
164}
165
166impl FbxDecodeLimits {
167    limit_setter!(
168        with_max_file_bytes,
169        max_file_bytes,
170        u64,
171        "Sets [`Self::max_file_bytes`]."
172    );
173    limit_setter!(with_max_depth, max_depth, u32, "Sets [`Self::max_depth`].");
174    limit_setter!(with_max_nodes, max_nodes, u64, "Sets [`Self::max_nodes`].");
175    limit_setter!(
176        with_max_properties_per_node,
177        max_properties_per_node,
178        u64,
179        "Sets [`Self::max_properties_per_node`]."
180    );
181    limit_setter!(
182        with_max_string_bytes,
183        max_string_bytes,
184        u64,
185        "Sets [`Self::max_string_bytes`]."
186    );
187    limit_setter!(
188        with_max_blob_bytes,
189        max_blob_bytes,
190        u64,
191        "Sets [`Self::max_blob_bytes`]."
192    );
193    limit_setter!(
194        with_max_array_elements,
195        max_array_elements,
196        u64,
197        "Sets [`Self::max_array_elements`]."
198    );
199    limit_setter!(
200        with_max_array_raw_bytes,
201        max_array_raw_bytes,
202        u64,
203        "Sets [`Self::max_array_raw_bytes`]."
204    );
205    limit_setter!(
206        with_max_total_array_raw_bytes,
207        max_total_array_raw_bytes,
208        u64,
209        "Sets [`Self::max_total_array_raw_bytes`]."
210    );
211}
212
213/// How the FBX reader treats a document: what it may allocate, and how
214/// strictly it enforces the binary container layout.
215///
216/// ```
217/// use draco_io::{FbxDecodeLimits, FbxReadOptions, FbxScene};
218///
219/// // Tighten the blob ceiling for untrusted input: embedded textures arrive
220/// // as `R` properties and are the largest thing a document can claim.
221/// let options = FbxReadOptions::default()
222///     .with_limits(FbxDecodeLimits::default().with_max_blob_bytes(16 << 20));
223///
224/// match FbxScene::from_bytes_with_options(b"not an fbx file", options) {
225///     Ok(scene) => println!("{} root nodes", scene.root_nodes.len()),
226///     Err(error) => {
227///         // `OutOfMemory` means "too big, retry with `permissive()`";
228///         // `InvalidData` means the document is corrupt.
229///         assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
230///     }
231/// }
232/// ```
233#[derive(Debug, Clone, Default, PartialEq, Eq)]
234#[non_exhaustive]
235pub struct FbxReadOptions {
236    /// Allocation ceilings for this read.
237    pub limits: FbxDecodeLimits,
238    /// Reject anything the FBX binary layout does not strictly permit.
239    ///
240    /// Off by default: shipping exporters emit slop that every practical
241    /// reader tolerates, so strictness is opt-in for validation tools.
242    pub strict: bool,
243}
244
245impl FbxReadOptions {
246    /// Options that reject any deviation from the documented layout.
247    pub fn strict() -> Self {
248        Self {
249            strict: true,
250            ..Self::default()
251        }
252    }
253
254    /// Replaces the allocation ceilings.
255    #[must_use]
256    pub fn with_limits(mut self, limits: FbxDecodeLimits) -> Self {
257        self.limits = limits;
258        self
259    }
260
261    /// Enables or disables strict container validation.
262    #[must_use]
263    pub fn with_strict(mut self, strict: bool) -> Self {
264        self.strict = strict;
265        self
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn byte_order_decodes_both_layouts() {
275        let bytes = [0x00, 0x00, 0x1d, 0x4c];
276        assert_eq!(FbxByteOrder::Big.u32(bytes), 7500);
277        assert_eq!(FbxByteOrder::Little.u32([0x4c, 0x1d, 0x00, 0x00]), 7500);
278    }
279
280    #[test]
281    fn little_endian_swap_is_a_no_op() {
282        let mut data = [1u8, 2, 3, 4, 5, 6, 7, 8];
283        FbxByteOrder::Little.swap_elements_in_place(&mut data, 4);
284        assert_eq!(data, [1, 2, 3, 4, 5, 6, 7, 8]);
285    }
286
287    #[test]
288    fn big_endian_swap_reverses_each_element() {
289        let mut data = [1u8, 2, 3, 4, 5, 6, 7, 8];
290        FbxByteOrder::Big.swap_elements_in_place(&mut data, 4);
291        assert_eq!(data, [4, 3, 2, 1, 8, 7, 6, 5]);
292    }
293
294    #[test]
295    fn single_byte_elements_are_never_swapped() {
296        let mut data = [1u8, 2, 3];
297        FbxByteOrder::Big.swap_elements_in_place(&mut data, 1);
298        assert_eq!(data, [1, 2, 3]);
299    }
300
301    #[test]
302    fn setters_work_through_non_exhaustive() {
303        let limits = FbxDecodeLimits::default()
304            .with_max_depth(8)
305            .with_max_nodes(9);
306        assert_eq!(limits.max_depth, 8);
307        assert_eq!(limits.max_nodes, 9);
308    }
309
310    #[test]
311    fn defaults_accept_the_measured_corpus_maxima() {
312        // Guards the calibration above: if someone tightens a default below
313        // what real assets need, this fails instead of the browser app.
314        let limits = FbxDecodeLimits::default();
315        assert!(limits.max_file_bytes >= 7_045_584);
316        assert!(limits.max_nodes >= 15_039);
317        assert!(limits.max_depth >= 8);
318        assert!(limits.max_properties_per_node >= 4_224);
319        assert!(limits.max_blob_bytes >= 328_086);
320        assert!(limits.max_string_bytes >= 87_207);
321        assert!(limits.max_array_raw_bytes >= 1_661_184);
322        assert!(limits.max_array_elements >= 207_648);
323        assert!(limits.max_total_array_raw_bytes >= 7_352_416);
324    }
325}