Skip to main content

teamcraft/
nxpart.rs

1//! Siemens NX part files (`.prt`, NX 1980 and later): the `SPLMSSTR`
2//! stream container.
3//!
4//! The format is not documented. This reader only relies on what was
5//! verified with the NIST test files in `tests/fixtures/cad/nist`:
6//!
7//! ```text
8//! 0x00  "SPLMSSTR"
9//! 0x11  u64 LE  offset of the FOOTER block
10//! 0x19  "HEADER"  u32 count, count × entry
11//! ...   stream data
12//! foot  "FOOTER"  u32 count, count × entry
13//! entry u32 LE name length, name (UTF-8 path below /Root/), u64 offset, u64 length
14//! ```
15//!
16//! Streams used by TeamCraft:
17//!
18//! * `/Root/UG_PART/ExternalReferences`, `/Root/FastLoad/ExternalReferences`:
19//!   the file names of the components (and the part itself). The rest of
20//!   the file also keeps history such as `Part renamed from "…\old.prt"`,
21//!   which is why a scan of the whole file finds false references;
22//! * `/Root/images/preview`: the preview image (JPEG).
23//!
24//! Folder entries end in `/` and have no data. Anything that does not fit
25//! (short file, entry out of bounds) is skipped, never trusted. Files are
26//! read with seeks: only the directory and the requested streams.
27#![deny(
28    clippy::unwrap_used,
29    clippy::expect_used,
30    clippy::indexing_slicing,
31    clippy::panic,
32    clippy::unreachable
33)]
34
35use std::io::{self, Read, Seek, SeekFrom};
36
37/// Magic bytes at the start of an NX part.
38pub const MAGIC: &[u8; 8] = b"SPLMSSTR";
39/// Streams with the names of the referenced component files.
40pub const REFERENCE_STREAMS: [&str; 2] = [
41    "/Root/UG_PART/ExternalReferences",
42    "/Root/FastLoad/ExternalReferences",
43];
44/// Stream with the preview image.
45pub const PREVIEW_STREAM: &str = "/Root/images/preview";
46/// Stream with the lightweight representation (a JT 9 file after a short
47/// index), read by `mesh` for the 3D preview of parts.
48pub const JT_STREAM: &str = "/Root/FastLoad/JT";
49
50const FOOTER_OFFSET_AT: usize = 0x11;
51/// Bytes at the start searched for the header block.
52const HEAD: usize = 64 * 1024;
53/// Largest footer block read.
54const MAX_FOOTER: u64 = 4 * 1024 * 1024;
55const MAX_NAME: usize = 4096;
56const MAX_ENTRIES: usize = 100_000;
57
58/// A stream of the container: absolute offset and length in the file.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Stream {
61    pub name: String,
62    pub offset: u64,
63    pub len: u64,
64}
65
66/// True if the bytes start like an NX part container.
67pub fn is_nx_part(bytes: &[u8]) -> bool {
68    bytes.starts_with(MAGIC)
69}
70
71fn u32_at(bytes: &[u8], at: usize) -> Option<usize> {
72    let b = bytes.get(at..at.checked_add(4)?)?;
73    Some(u32::from_le_bytes(b.try_into().ok()?) as usize)
74}
75
76fn u64_at(bytes: &[u8], at: usize) -> Option<u64> {
77    let b = bytes.get(at..at.checked_add(8)?)?;
78    Some(u64::from_le_bytes(b.try_into().ok()?))
79}
80
81/// Reads one directory block (`HEADER` or `FOOTER`) that starts at `at` of
82/// `buf`. Entries pointing beyond `file_len` are dropped.
83fn block(buf: &[u8], at: usize, tag: &[u8; 6], file_len: u64, out: &mut Vec<Stream>) {
84    let Some(mut pos) = at.checked_add(tag.len()) else {
85        return;
86    };
87    if buf.get(at..pos) != Some(tag) {
88        return;
89    }
90    let Some(count) = u32_at(buf, pos) else {
91        return;
92    };
93    pos += 4;
94    for _ in 0..count.min(MAX_ENTRIES) {
95        let Some(name_len) = u32_at(buf, pos).filter(|n| (1..=MAX_NAME).contains(n)) else {
96            return;
97        };
98        pos += 4;
99        let Some(name) = buf
100            .get(pos..pos + name_len)
101            .and_then(|b| std::str::from_utf8(b).ok())
102        else {
103            return;
104        };
105        pos += name_len;
106        let (Some(offset), Some(len)) = (u64_at(buf, pos), u64_at(buf, pos + 8)) else {
107            return;
108        };
109        pos += 16;
110        let in_bounds = offset.checked_add(len).is_some_and(|end| end <= file_len);
111        if !name.ends_with('/') && len > 0 && in_bounds && !out.iter().any(|s| s.name == name) {
112            out.push(Stream {
113                name: name.to_string(),
114                offset,
115                len,
116            });
117        }
118    }
119}
120
121fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
122    hay.windows(needle.len()).position(|w| w == needle)
123}
124
125/// Header streams from the first bytes of a file.
126fn header_streams(head: &[u8], file_len: u64, out: &mut Vec<Stream>) {
127    if let Some(at) = find(head.get(..256).unwrap_or(head), b"HEADER") {
128        block(head, at, b"HEADER", file_len, out);
129    }
130}
131
132/// The data streams of an NX part held in memory, or an empty list if the
133/// bytes are not one. A file cut short keeps the streams inside the bytes.
134pub fn streams(bytes: &[u8]) -> Vec<Stream> {
135    let mut out = Vec::new();
136    if !is_nx_part(bytes) {
137        return out;
138    }
139    let file_len = bytes.len() as u64;
140    header_streams(bytes, file_len, &mut out);
141    if let Some(at) = u64_at(bytes, FOOTER_OFFSET_AT).and_then(|a| usize::try_from(a).ok()) {
142        block(bytes, at, b"FOOTER", file_len, &mut out);
143    }
144    out
145}
146
147/// The data of a stream of an NX part held in memory.
148pub fn data<'a>(bytes: &'a [u8], stream: &Stream) -> Option<&'a [u8]> {
149    let start = usize::try_from(stream.offset).ok()?;
150    let end = start.checked_add(usize::try_from(stream.len).ok()?)?;
151    bytes.get(start..end)
152}
153
154/// An NX part opened from a file (or any seekable reader).
155pub struct NxPart<R> {
156    reader: R,
157    streams: Vec<Stream>,
158}
159
160impl<R: Read + Seek> NxPart<R> {
161    /// Reads the directory. `None` if the file is not an NX part.
162    pub fn open(mut reader: R) -> io::Result<Option<Self>> {
163        let file_len = reader.seek(SeekFrom::End(0))?;
164        reader.seek(SeekFrom::Start(0))?;
165        let mut head = Vec::new();
166        (&mut reader).take(HEAD as u64).read_to_end(&mut head)?;
167        if !is_nx_part(&head) {
168            return Ok(None);
169        }
170        let mut streams = Vec::new();
171        header_streams(&head, file_len, &mut streams);
172        if let Some(at) = u64_at(&head, FOOTER_OFFSET_AT).filter(|&a| a < file_len) {
173            reader.seek(SeekFrom::Start(at))?;
174            let mut foot = Vec::new();
175            (&mut reader).take(MAX_FOOTER).read_to_end(&mut foot)?;
176            block(&foot, 0, b"FOOTER", file_len, &mut streams);
177        }
178        Ok(Some(Self { reader, streams }))
179    }
180
181    pub fn streams(&self) -> &[Stream] {
182        &self.streams
183    }
184
185    /// The data of the named stream, if it exists and is at most `max`
186    /// bytes long.
187    pub fn read(&mut self, name: &str, max: u64) -> io::Result<Option<Vec<u8>>> {
188        let Some(s) = self.streams.iter().find(|s| s.name == name).cloned() else {
189            return Ok(None);
190        };
191        if s.len > max {
192            return Ok(None);
193        }
194        self.reader.seek(SeekFrom::Start(s.offset))?;
195        let mut data = Vec::with_capacity(s.len as usize);
196        (&mut self.reader).take(s.len).read_to_end(&mut data)?;
197        Ok((data.len() as u64 == s.len).then_some(data))
198    }
199}
200
201/// The data of the reference streams of an NX part (each at most `max`
202/// bytes), or `None` if it is not an NX part or has none. Each stream starts
203/// with the name of the part itself, followed by its components.
204pub fn reference_streams<R: Read + Seek>(reader: R, max: u64) -> io::Result<Option<Vec<Vec<u8>>>> {
205    let Some(mut part) = NxPart::open(reader)? else {
206        return Ok(None);
207    };
208    let mut out = Vec::new();
209    for name in REFERENCE_STREAMS {
210        if let Some(data) = part.read(name, max)? {
211            out.push(data);
212        }
213    }
214    Ok((!out.is_empty()).then_some(out))
215}
216
217/// The reference streams of an NX part held in memory.
218pub fn reference_streams_in(bytes: &[u8]) -> Option<Vec<Vec<u8>>> {
219    let streams = streams(bytes);
220    let out: Vec<Vec<u8>> = REFERENCE_STREAMS
221        .iter()
222        .filter_map(|name| streams.iter().find(|s| s.name == *name))
223        .filter_map(|s| data(bytes, s).map(<[u8]>::to_vec))
224        .collect();
225    (!out.is_empty()).then_some(out)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::io::Cursor;
232
233    fn fixture(name: &str) -> Vec<u8> {
234        let dir = concat!(
235            env!("CARGO_MANIFEST_DIR"),
236            "/tests/fixtures/cad/nist/mtc-assembly/nx"
237        );
238        std::fs::read(format!("{dir}/{name}")).unwrap()
239    }
240
241    #[test]
242    fn reads_the_directory_of_the_nist_assembly() {
243        let bytes = fixture("NIST mtc crada assembly.prt");
244        let names: Vec<String> = streams(&bytes).into_iter().map(|s| s.name).collect();
245        for expected in REFERENCE_STREAMS
246            .iter()
247            .copied()
248            .chain([PREVIEW_STREAM, "/Root/part/attrs"])
249        {
250            assert!(
251                names.iter().any(|n| n == expected),
252                "{expected} in {names:?}"
253            );
254        }
255        // Folders are not streams.
256        assert!(names.iter().all(|n| !n.ends_with('/')));
257        // The seeking reader sees the same directory.
258        let part = NxPart::open(Cursor::new(&bytes)).unwrap().unwrap();
259        let seek_names: Vec<&str> = part.streams().iter().map(|s| s.name.as_str()).collect();
260        assert_eq!(seek_names, names);
261    }
262
263    #[test]
264    fn preview_stream_is_a_jpeg() {
265        let bytes = fixture("91274A118 SHCS.prt");
266        let mut part = NxPart::open(Cursor::new(&bytes)).unwrap().unwrap();
267        let jpeg = part.read(PREVIEW_STREAM, 1 << 20).unwrap().unwrap();
268        assert!(jpeg.starts_with(&[0xFF, 0xD8, 0xFF]));
269        assert!(part.read(PREVIEW_STREAM, 10).unwrap().is_none(), "max size");
270        assert!(part.read("/Root/none", 1 << 20).unwrap().is_none());
271    }
272
273    #[test]
274    fn other_files_and_garbage_are_not_nx_parts() {
275        assert!(streams(b"#UGC:2 ASSEMBLY creo").is_empty());
276        assert!(NxPart::open(Cursor::new(b"ISO-10303-21;".to_vec()))
277            .unwrap()
278            .is_none());
279        // Magic only, truncated directory, bogus footer offset: no panic, no streams.
280        let mut fake = MAGIC.to_vec();
281        fake.extend_from_slice(&[0xFF; 9]);
282        fake.extend_from_slice(&u64::MAX.to_le_bytes());
283        fake.extend_from_slice(b"HEADER\x05\x00\x00\x00\x10\x00");
284        assert!(streams(&fake).is_empty());
285        assert!(NxPart::open(Cursor::new(fake))
286            .unwrap()
287            .unwrap()
288            .streams()
289            .is_empty());
290    }
291
292    /// A synthetic container: the directory entries in the HEADER block, the
293    /// data after it and an empty FOOTER at the end. Entries with `None`
294    /// data are folders (`len` 0).
295    fn synthetic(entries: &[(&str, Option<&[u8]>)]) -> Vec<u8> {
296        let dir_len: usize = 6 + 4 + entries.iter().map(|(n, _)| 4 + n.len() + 16).sum::<usize>();
297        let mut offset = (0x19 + dir_len) as u64;
298        let mut out = MAGIC.to_vec();
299        out.resize(FOOTER_OFFSET_AT, 0);
300        out.extend_from_slice(&0u64.to_le_bytes()); // patched below
301        out.extend_from_slice(b"HEADER");
302        out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
303        for (name, data) in entries {
304            let len = data.map_or(0, |d| d.len() as u64);
305            out.extend_from_slice(&(name.len() as u32).to_le_bytes());
306            out.extend_from_slice(name.as_bytes());
307            out.extend_from_slice(&offset.to_le_bytes());
308            out.extend_from_slice(&len.to_le_bytes());
309            offset += len;
310        }
311        for (_, data) in entries {
312            out.extend_from_slice(data.unwrap_or_default());
313        }
314        let footer = out.len() as u64;
315        out[FOOTER_OFFSET_AT..FOOTER_OFFSET_AT + 8].copy_from_slice(&footer.to_le_bytes());
316        out.extend_from_slice(b"FOOTER\x00\x00\x00\x00");
317        out
318    }
319
320    #[test]
321    fn synthetic_directory_round_trip() {
322        let bytes = synthetic(&[
323            ("/Root/", None),
324            (
325                "/Root/UG_PART/ExternalReferences",
326                Some(b"\x00top.prt\x00wheel.prt\x00"),
327            ),
328            (
329                "/Root/history",
330                Some(b"Part renamed from \"C:\\old\\ghost.prt\""),
331            ),
332        ]);
333        let s = streams(&bytes);
334        assert_eq!(
335            s.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
336            ["/Root/UG_PART/ExternalReferences", "/Root/history"],
337            "folders have no data"
338        );
339        assert_eq!(
340            data(&bytes, &s[0]).unwrap(),
341            b"\x00top.prt\x00wheel.prt\x00"
342        );
343        // The seeking reader sees the same directory.
344        let mut part = NxPart::open(Cursor::new(bytes.clone())).unwrap().unwrap();
345        assert_eq!(part.streams(), &s[..]);
346        assert!(
347            part.read("/Root/history", 3).unwrap().is_none(),
348            "larger than max"
349        );
350        assert_eq!(
351            reference_streams(Cursor::new(bytes.clone()), 1 << 20).unwrap(),
352            reference_streams_in(&bytes)
353        );
354        // Only the reference stream is scanned, without the part itself and
355        // without the history of the file.
356        assert_eq!(
357            crate::cadrefs::find(&bytes, "renamed.prt"),
358            vec!["wheel.prt"]
359        );
360    }
361
362    #[test]
363    fn entries_beyond_the_file_are_dropped() {
364        let mut bytes = synthetic(&[(
365            "/Root/FastLoad/ExternalReferences",
366            Some(b"\x00a.prt\x00b.prt"),
367        )]);
368        // Patch the length of the only entry to point past the end.
369        let len_at = 0x19 + 6 + 4 + 4 + "/Root/FastLoad/ExternalReferences".len() + 8;
370        bytes[len_at..len_at + 8].copy_from_slice(&u64::MAX.to_le_bytes());
371        assert!(streams(&bytes).is_empty());
372        assert!(reference_streams_in(&bytes).is_none());
373        // Without reference streams an NX part has no references to read.
374        let plain = synthetic(&[("/Root/other", Some(b"\x00wheel.prt\x00"))]);
375        assert!(reference_streams_in(&plain).is_none());
376    }
377
378    #[test]
379    fn nx_preview_jpeg_becomes_the_thumbnail() {
380        // SOI, SOF0 with 120 × 90, EOI.
381        let mut jpeg = vec![
382            0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 90, 0x00, 120,
383        ];
384        jpeg.extend_from_slice(&[0; 12]);
385        jpeg.extend_from_slice(&[0xFF, 0xD9]);
386        let bytes = synthetic(&[(PREVIEW_STREAM, Some(&jpeg))]);
387        let path = std::env::temp_dir().join(format!("teamcraft-nx-{}.prt", uuid::Uuid::new_v4()));
388        std::fs::write(&path, &bytes).unwrap();
389        let t = crate::thumbnail::extract(&path).unwrap().unwrap();
390        assert_eq!((t.content_type, t.bytes), ("image/jpeg", jpeg));
391        // A preview that is too small is no thumbnail.
392        let mut tiny = vec![0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 4, 0x00, 4];
393        tiny.extend_from_slice(&[0; 12]);
394        std::fs::write(&path, synthetic(&[(PREVIEW_STREAM, Some(&tiny))])).unwrap();
395        assert_eq!(crate::thumbnail::extract(&path).unwrap(), None);
396        std::fs::remove_file(&path).ok();
397    }
398
399    #[test]
400    fn truncated_files_keep_the_streams_inside() {
401        let bytes = fixture("NIST mtc crada assembly.prt");
402        let cut = &bytes[..bytes.len() / 2];
403        let s = streams(cut);
404        assert!(!s.is_empty());
405        assert!(s.iter().all(|s| s.offset + s.len <= cut.len() as u64));
406    }
407}