1#![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
37pub const MAGIC: &[u8; 8] = b"SPLMSSTR";
39pub const REFERENCE_STREAMS: [&str; 2] = [
41 "/Root/UG_PART/ExternalReferences",
42 "/Root/FastLoad/ExternalReferences",
43];
44pub const PREVIEW_STREAM: &str = "/Root/images/preview";
46pub const JT_STREAM: &str = "/Root/FastLoad/JT";
49
50const FOOTER_OFFSET_AT: usize = 0x11;
51const HEAD: usize = 64 * 1024;
53const MAX_FOOTER: u64 = 4 * 1024 * 1024;
55const MAX_NAME: usize = 4096;
56const MAX_ENTRIES: usize = 100_000;
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Stream {
61 pub name: String,
62 pub offset: u64,
63 pub len: u64,
64}
65
66pub 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
81fn 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
125fn 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
132pub 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
147pub 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
154pub struct NxPart<R> {
156 reader: R,
157 streams: Vec<Stream>,
158}
159
160impl<R: Read + Seek> NxPart<R> {
161 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 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
201pub 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
217pub 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 assert!(names.iter().all(|n| !n.ends_with('/')));
257 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 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 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()); 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 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 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 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 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 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 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}