Skip to main content

teamcraft/
fusion.rs

1//! Autodesk Fusion design archives (`.f3d`).
2//!
3//! A design is a ZIP archive with one folder per document
4//! (`FusionAssetName[Active]/…`). Most entries are compressed with
5//! Zstandard (ZIP method 93), a few are stored; Deflate is accepted too.
6//! The `zip` crate reads the directory; Zstandard entries are inflated with
7//! `ruzstd` (pure Rust) and checked against the stored size and CRC-32.
8//!
9//! Entries used by TeamCraft (see `mesh::f3d` and
10//! `docs/src/research/fusion-ogs-preview.md`):
11//!
12//! * `OGS.BlobFolder/OGS/DefaultScene/world` and `stream_mesh_NNN`: the
13//!   display scene of Fusion's graphics system with the triangles of every
14//!   face (the 3D preview);
15//! * `Previews/big.png`: the thumbnail Fusion rendered when saving.
16//!
17//! Hostile input: an entry is inflated only when its stored size is within
18//! the caller's limit, inflating stops one byte after that size, and the
19//! result must have exactly the stored size and CRC-32.
20#![deny(
21    clippy::unwrap_used,
22    clippy::expect_used,
23    clippy::indexing_slicing,
24    clippy::panic,
25    clippy::unreachable
26)]
27
28use std::io::{self, Read, Seek};
29
30use zip::CompressionMethod;
31
32/// Folder of the display scene inside a document folder.
33pub const SCENE_DIR: &str = "OGS.BlobFolder/OGS/DefaultScene/";
34/// The scene graph of the display scene.
35pub const SCENE_WORLD: &str = "world";
36/// The thumbnail Fusion stores.
37pub const PREVIEW_PNG: &str = "Previews/big.png";
38/// Document folders start with this name; the open document is `[Active]`.
39const ACTIVE: &str = "FusionAssetName[Active]/";
40const DOCUMENT: &str = "FusionAssetName[";
41const ZIP_MAGIC: &[u8; 4] = b"PK\x03\x04";
42
43/// Whether `head` (the first bytes of a file) starts a Fusion archive: a ZIP
44/// whose first entry is a Fusion document folder or manifest.
45pub fn is_archive(head: &[u8]) -> bool {
46    let Some(name) = head
47        .get(26..28)
48        .and_then(|n| <[u8; 2]>::try_from(n).ok())
49        .map(|n| usize::from(u16::from_le_bytes(n)))
50        .and_then(|len| head.get(30..30 + len))
51    else {
52        return false;
53    };
54    head.starts_with(ZIP_MAGIC)
55        && (name.starts_with(DOCUMENT.as_bytes())
56            || name == b"Manifest.dat"
57            || name == b"Properties.dat")
58}
59
60fn invalid(message: impl Into<String>) -> io::Error {
61    io::Error::new(io::ErrorKind::InvalidData, message.into())
62}
63
64/// An opened design archive.
65pub struct Archive<R> {
66    zip: zip::ZipArchive<R>,
67    names: Vec<String>,
68}
69
70impl<R: Read + Seek> Archive<R> {
71    /// Opens a Fusion archive; `None` when the file is not a ZIP with a
72    /// Fusion document folder.
73    pub fn open(reader: R) -> io::Result<Option<Self>> {
74        let Ok(zip) = zip::ZipArchive::new(reader) else {
75            return Ok(None);
76        };
77        let names: Vec<String> = zip
78            .file_names()
79            .filter_map(|n| n.ok().map(|n| n.into_owned()))
80            .collect();
81        let archive = Archive { zip, names };
82        if archive.names().any(|n| n.starts_with(DOCUMENT)) {
83            Ok(Some(archive))
84        } else {
85            Ok(None)
86        }
87    }
88
89    /// The entry names.
90    pub fn names(&self) -> impl Iterator<Item = &str> {
91        self.names.iter().map(String::as_str)
92    }
93
94    /// The full name of `path` (relative to a document folder) in the open
95    /// document, or else in the first document that has it.
96    pub fn entry(&self, path: &str) -> Option<String> {
97        let active = format!("{ACTIVE}{path}");
98        if self.names().any(|n| n == active) {
99            return Some(active);
100        }
101        let mut found: Vec<&str> = self
102            .names()
103            .filter(|n| n.starts_with(DOCUMENT) && n.ends_with(path))
104            .filter(|n| {
105                n.find("]/")
106                    .is_some_and(|end| n.get(end + 2..) == Some(path))
107            })
108            .collect();
109        found.sort_unstable();
110        found.first().map(|n| (*n).to_string())
111    }
112
113    /// The content of the entry `name`; `None` when there is no such entry.
114    /// Entries larger than `limit` bytes are an error, not read.
115    pub fn read(&mut self, name: &str, limit: u64) -> io::Result<Option<Vec<u8>>> {
116        let (method, size, crc) = match self.zip.by_name_raw(name.as_bytes()) {
117            Ok(file) => (file.compression(), file.size(), file.crc32()),
118            Err(zip::result::ZipError::FileNotFound) => return Ok(None),
119            Err(e) => return Err(invalid(format!("damaged archive entry {name}: {e}"))),
120        };
121        if size > limit {
122            return Err(invalid(format!(
123                "the archive entry {name} is larger than {limit} bytes"
124            )));
125        }
126        let capacity = usize::try_from(size).unwrap_or(0);
127        let mut data = Vec::with_capacity(capacity.min(64 * 1024 * 1024));
128        if method == CompressionMethod::ZSTD {
129            let raw = self
130                .zip
131                .by_name_raw(name.as_bytes())
132                .map_err(|e| invalid(format!("damaged archive entry {name}: {e}")))?;
133            let decoder = ruzstd::decoding::StreamingDecoder::new(raw)
134                .map_err(|e| invalid(format!("damaged Zstandard data in {name}: {e}")))?;
135            decoder
136                .take(size.saturating_add(1))
137                .read_to_end(&mut data)
138                .map_err(|e| invalid(format!("damaged Zstandard data in {name}: {e}")))?;
139        } else if method == CompressionMethod::Stored || method == CompressionMethod::Deflated {
140            let file = self
141                .zip
142                .by_name(name)
143                .map_err(|e| invalid(format!("damaged archive entry {name}: {e}")))?;
144            file.take(size.saturating_add(1))
145                .read_to_end(&mut data)
146                .map_err(|e| invalid(format!("damaged archive entry {name}: {e}")))?;
147        } else {
148            return Err(invalid(format!(
149                "the archive entry {name} uses an unsupported compression ({method:?})"
150            )));
151        }
152        if data.len() as u64 != size || crc32fast::hash(&data) != crc {
153            return Err(invalid(format!(
154                "the archive entry {name} does not match its stored size and checksum"
155            )));
156        }
157        Ok(Some(data))
158    }
159}
160
161/// Builders for small design archives, for the tests and the fuzz corpus
162/// (no real Fusion file is committed: their licences are unknown).
163#[doc(hidden)]
164#[allow(clippy::indexing_slicing)]
165pub mod sample {
166    /// One face mesh: positions (centimetres), its normal, triangle indices.
167    pub type Face = (Vec<[f32; 3]>, [f32; 3], Vec<u32>);
168
169    /// A ZIP archive of `entries` (name, method, raw data as stored,
170    /// uncompressed data for the CRC and size). Methods: 0 stored, 8
171    /// Deflate, 93 Zstandard – the caller compresses.
172    pub fn zip(entries: &[(&str, u16, &[u8], &[u8])]) -> Vec<u8> {
173        let mut out = Vec::new();
174        let mut central = Vec::new();
175        for (name, method, raw, plain) in entries {
176            let offset = out.len() as u32;
177            let crc = crc32fast::hash(plain);
178            let header = |sig: &[u8], central: bool| {
179                let mut h = Vec::new();
180                h.extend_from_slice(sig);
181                if central {
182                    h.extend_from_slice(&63u16.to_le_bytes());
183                }
184                h.extend_from_slice(&20u16.to_le_bytes());
185                h.extend_from_slice(&0u16.to_le_bytes());
186                h.extend_from_slice(&method.to_le_bytes());
187                h.extend_from_slice(&[0, 0, 0x21, 0]);
188                h.extend_from_slice(&crc.to_le_bytes());
189                h.extend_from_slice(&(raw.len() as u32).to_le_bytes());
190                h.extend_from_slice(&(plain.len() as u32).to_le_bytes());
191                h.extend_from_slice(&(name.len() as u16).to_le_bytes());
192                h.extend_from_slice(&0u16.to_le_bytes());
193                if central {
194                    // comment length, disk, internal and external attributes
195                    h.extend_from_slice(&[0; 10]);
196                    h.extend_from_slice(&offset.to_le_bytes());
197                }
198                h.extend_from_slice(name.as_bytes());
199                h
200            };
201            out.extend(header(b"PK\x03\x04", false));
202            out.extend_from_slice(raw);
203            central.extend(header(b"PK\x01\x02", true));
204        }
205        let start = out.len() as u32;
206        let count = entries.len() as u16;
207        out.extend_from_slice(&central);
208        out.extend_from_slice(b"PK\x05\x06");
209        out.extend_from_slice(&[0; 4]);
210        out.extend_from_slice(&count.to_le_bytes());
211        out.extend_from_slice(&count.to_le_bytes());
212        out.extend_from_slice(&(central.len() as u32).to_le_bytes());
213        out.extend_from_slice(&start.to_le_bytes());
214        out.extend_from_slice(&0u16.to_le_bytes());
215        out
216    }
217
218    /// `data` as one Zstandard frame of raw (uncompressed) blocks – valid
219    /// Zstandard that every decoder reads, without needing an encoder.
220    pub fn zstd_raw(data: &[u8]) -> Vec<u8> {
221        let mut out = vec![0x28, 0xb5, 0x2f, 0xfd];
222        // frame header: single segment, 8-byte content size
223        out.push(0b1110_0000);
224        out.extend_from_slice(&(data.len() as u64).to_le_bytes());
225        let mut chunks = data.chunks(100_000).peekable();
226        if chunks.peek().is_none() {
227            out.extend_from_slice(&[1, 0, 0]);
228        }
229        while let Some(chunk) = chunks.next() {
230            let last = u32::from(chunks.peek().is_none());
231            let header = ((chunk.len() as u32) << 3) | last;
232            out.extend_from_slice(&header.to_le_bytes()[..3]);
233            out.extend_from_slice(chunk);
234        }
235        out
236    }
237
238    fn utf16(out: &mut Vec<u8>, s: &str) {
239        out.extend_from_slice(&(s.len() as u32).to_le_bytes());
240        for c in s.encode_utf16() {
241            out.extend_from_slice(&c.to_le_bytes());
242        }
243    }
244
245    /// A display scene: `faces` are triangle meshes (positions in
246    /// centimetres, one normal per face, indices) of client type `Face`,
247    /// closed by one `Body`; a sketch profile and an edge are added as
248    /// noise. Returns `(world, stream_mesh_000)`.
249    pub fn scene(faces: &[Face]) -> (Vec<u8>, Vec<u8>) {
250        let mut world = Vec::new();
251        let mut stream = Vec::new();
252        utf16(&mut world, "ARenderList");
253        let mut first_format = true;
254        let mut mesh = |world: &mut Vec<u8>,
255                        stream: &mut Vec<u8>,
256                        context: &str,
257                        primitive: u32,
258                        points: &[[f32; 3]],
259                        normal: [f32; 3],
260                        indices: Option<&[u32]>| {
261            utf16(world, context);
262            world.extend_from_slice(&[0; 9]);
263            utf16(world, "OgsSimpleMesh");
264            world.extend_from_slice(&[0; 24]);
265            world.extend_from_slice(&primitive.to_le_bytes());
266            world.extend_from_slice(&[1, 0, 1]);
267            utf16(world, "VertexFormat");
268            utf16(world, "000001C7C09A9610");
269            if first_format {
270                first_format = false;
271                world.extend_from_slice(&3u32.to_le_bytes());
272                for (kind, semantic, offset) in [(2u32, 0u32, 0u32), (2, 1, 3), (1, 2, 6)] {
273                    for v in [kind, semantic, offset, 0, 0, 0] {
274                        world.extend_from_slice(&v.to_le_bytes());
275                    }
276                }
277            }
278            let start = stream.len() as u32;
279            for p in points {
280                for v in p.iter().chain(&normal).chain(&[0.0, 0.0]) {
281                    stream.extend_from_slice(&v.to_le_bytes());
282                }
283            }
284            for v in [1, 0, start, stream.len() as u32 - start] {
285                world.extend_from_slice(&v.to_le_bytes());
286            }
287            match indices {
288                Some(indices) => {
289                    world.push(1);
290                    utf16(world, "AIndexBuffer");
291                    utf16(world, &format!("000001D04B6E{:04X}", stream.len() % 0xffff));
292                    let start = stream.len() as u32;
293                    for i in indices {
294                        stream.extend_from_slice(&i.to_le_bytes());
295                    }
296                    world.extend_from_slice(&0u32.to_le_bytes());
297                    world.push(0);
298                    world.extend_from_slice(&start.to_le_bytes());
299                    world.extend_from_slice(&(stream.len() as u32 - start).to_le_bytes());
300                }
301                None => world.push(0),
302            }
303            utf16(world, "DepthPriorityAttribute");
304            world.extend_from_slice(&[0; 12]);
305        };
306        let square = [
307            [0.0, 0.0, 0.0],
308            [5.0, 0.0, 0.0],
309            [5.0, 5.0, 0.0],
310            [0.0, 5.0, 0.0],
311        ];
312        mesh(
313            &mut world,
314            &mut stream,
315            "ProfileFace",
316            4,
317            &square,
318            [0.0, 0.0, 1.0],
319            Some(&[0u32, 1, 2, 2, 3, 0][..]),
320        );
321        utf16(&mut world, "Faces");
322        for (points, normal, indices) in faces {
323            mesh(
324                &mut world,
325                &mut stream,
326                "Face",
327                4,
328                points,
329                *normal,
330                Some(indices.as_slice()),
331            );
332        }
333        utf16(&mut world, "Body");
334        mesh(
335            &mut world,
336            &mut stream,
337            "Edge",
338            3,
339            &square[..2],
340            [0.0, 0.0, 1.0],
341            None,
342        );
343        utf16(&mut world, "WorldSerializerEndMark");
344        (world, stream)
345    }
346
347    /// The six faces of an axis-aligned cube with edge `a` (centimetres).
348    pub fn cube_faces(a: f32) -> Vec<Face> {
349        let mut faces = Vec::new();
350        for axis in 0..3 {
351            for side in [0.0f32, 1.0] {
352                let (u, v) = ((axis + 1) % 3, (axis + 2) % 3);
353                let mut points = Vec::new();
354                for (du, dv) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
355                    let mut p = [0.0; 3];
356                    p[axis] = side * a;
357                    p[u] = du * a;
358                    p[v] = dv * a;
359                    points.push(p);
360                }
361                let mut normal = [0.0; 3];
362                normal[axis] = if side > 0.5 { 1.0 } else { -1.0 };
363                let indices = if side > 0.5 {
364                    vec![0, 1, 2, 2, 3, 0]
365                } else {
366                    vec![0, 2, 1, 2, 0, 3]
367                };
368                faces.push((points, normal, indices));
369            }
370        }
371        faces
372    }
373
374    /// A design archive with a cube of edge `a` cm in its display scene
375    /// (Zstandard entries like Fusion writes) and a stored PNG thumbnail.
376    pub fn cube_archive(a: f32, png: &[u8]) -> Vec<u8> {
377        let (world, stream) = scene(&cube_faces(a));
378        let world_name = format!("FusionAssetName[Active]/{}{}", super::SCENE_DIR, "world");
379        let stream_name = format!(
380            "FusionAssetName[Active]/{}{}",
381            super::SCENE_DIR,
382            "stream_mesh_000"
383        );
384        let png_name = format!("FusionAssetName[Active]/{}", super::PREVIEW_PNG);
385        let manifest = b"manifest".to_vec();
386        let (zm, zw, zs) = (zstd_raw(&manifest), zstd_raw(&world), zstd_raw(&stream));
387        zip(&[
388            ("FusionAssetName[Active]/", 0, &[][..], &[][..]),
389            ("Manifest.dat", 93, &zm[..], &manifest[..]),
390            (&world_name, 93, &zw[..], &world[..]),
391            (&stream_name, 93, &zs[..], &stream[..]),
392            (&png_name, 0, png, png),
393        ])
394    }
395}
396
397#[cfg(test)]
398#[allow(clippy::indexing_slicing, clippy::unwrap_used)]
399mod tests {
400    use super::*;
401    use std::io::Cursor;
402
403    #[test]
404    fn zstandard_and_stored_entries_are_read_and_checked() {
405        let archive = sample::cube_archive(1.0, b"\x89PNG fake");
406        assert!(is_archive(&archive));
407        let mut a = Archive::open(Cursor::new(archive.clone()))
408            .unwrap()
409            .unwrap();
410        let world = a.entry(&format!("{SCENE_DIR}{SCENE_WORLD}")).unwrap();
411        assert_eq!(world, format!("FusionAssetName[Active]/{SCENE_DIR}world"));
412        let data = a.read(&world, 1 << 20).unwrap().unwrap();
413        assert!(data.len() > 1000);
414        let png = a.entry(PREVIEW_PNG).unwrap();
415        assert_eq!(a.read(&png, 100).unwrap().unwrap(), b"\x89PNG fake");
416        assert_eq!(a.read("missing", 100).unwrap(), None);
417        // larger than the limit: refused, not read
418        assert!(a.read(&world, 10).is_err());
419    }
420
421    #[test]
422    fn a_wrong_checksum_or_size_is_an_error() {
423        let plain = b"display scene".to_vec();
424        let mut other = plain.clone();
425        other[0] ^= 1;
426        // stored data that does not match the CRC of the central directory
427        let z = sample::zstd_raw(&other);
428        let bad = sample::zip(&[
429            ("FusionAssetName[Active]/", 0, &[][..], &[][..]),
430            ("FusionAssetName[Active]/x", 93, &z[..], &plain[..]),
431        ]);
432        let mut a = Archive::open(Cursor::new(bad)).unwrap().unwrap();
433        assert!(a.read("FusionAssetName[Active]/x", 100).is_err());
434        // a Zstandard frame that is not one
435        let garbage = sample::zip(&[
436            ("FusionAssetName[Active]/", 0, &[][..], &[][..]),
437            (
438                "FusionAssetName[Active]/x",
439                93,
440                &b"not zstd at all"[..],
441                &plain[..],
442            ),
443        ]);
444        let mut a = Archive::open(Cursor::new(garbage)).unwrap().unwrap();
445        assert!(a.read("FusionAssetName[Active]/x", 100).is_err());
446    }
447
448    #[test]
449    fn other_zip_files_and_garbage_are_not_fusion_archives() {
450        let plain = b"x".to_vec();
451        let other = sample::zip(&[("3D/3dmodel.model", 0, &plain[..], &plain[..])]);
452        assert!(!is_archive(&other));
453        assert!(Archive::open(Cursor::new(other)).unwrap().is_none());
454        assert!(Archive::open(Cursor::new(b"PK\x03\x04garbage".to_vec()))
455            .unwrap()
456            .is_none());
457        assert!(!is_archive(b""));
458        assert!(!is_archive(b"PK\x03\x04"));
459    }
460
461    #[test]
462    fn another_document_folder_is_used_without_an_active_one() {
463        let plain = b"w".to_vec();
464        let name = format!("FusionAssetName[Other]/{SCENE_DIR}world");
465        let archive = sample::zip(&[(&name, 0, &plain[..], &plain[..])]);
466        let a = Archive::open(Cursor::new(archive)).unwrap().unwrap();
467        assert_eq!(a.entry(&format!("{SCENE_DIR}world")), Some(name));
468        assert_eq!(a.entry("Previews/big.png"), None);
469    }
470}