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}