Skip to main content

teamcraft/
swpart.rs

1//! SOLIDWORKS files from version 2015 on (`.sldprt`, `.sldasm`, `.slddrw`):
2//! SOLIDWORKS' own section container.
3//!
4//! Up to SOLIDWORKS 2014 the files were OLE compound files; newer ones are a
5//! sequence of Deflate-compressed sections, similar to the local entries of
6//! a ZIP file. The format is not documented. This reader only relies on what
7//! was verified with the NIST test files in `tests/fixtures/cad/nist`
8//! (SOLIDWORKS 2018):
9//!
10//! ```text
11//! section  14 00 06 00 08 00   marker (ZIP: version 2.0, flags 6, method 8 = Deflate)
12//!          u32 LE              DOS time and date
13//!          u32 LE              CRC-32 of the uncompressed data
14//!          u32 LE              compressed size
15//!          u32 LE              uncompressed size
16//!          u32 LE              name length
17//!          name                each byte with its two nibbles swapped ("Contents/CMgr")
18//!          data                raw Deflate (no zlib header), compressed size bytes
19//! ```
20//!
21//! Every section is preceded by the same four bytes (a per-file
22//! *signature*). The file also holds records that look like section headers
23//! but carry no data (often their "CRC" is four times, their "size" twice the
24//! "compressed size"; they are not preceded by the signature) and a
25//! directory at the end whose names are prefixed with binary fields. Neither
26//! is needed: a section counts only if it follows the signature (once that
27//! is known), its name is printable, its data lies inside the file and –
28//! when it is read – it inflates to exactly the stored size with the stored
29//! CRC-32.
30//!
31//! Sections used by TeamCraft:
32//!
33//! * `PreviewPNG`: the preview image (PNG, 640 × 480 in the test files);
34//! * `swXmlContents/COMPINSTANCETREE` (assemblies): XML with the component
35//!   files (`swFile swPath="…"`) and their instances (`swReference`);
36//! * `SwDocContentMgr/SwDocContentMgrInfo`: the document manager's record of
37//!   the referenced documents with their full paths – used for files without
38//!   the component tree (drawings; not verified with a real drawing).
39//!
40//! Hostile input: offsets and sizes are never trusted. Sections are found by
41//! a bounded search ([`MAX_SEARCH`] bytes looked at byte by byte, at most
42//! [`MAX_SECTIONS`] sections, the file read in chunks that are each read
43//! about once), the data of a section after the signature is skipped, and
44//! inflating stops one byte after the stored size; sections larger than the
45//! caller's limit are not inflated at all (decompression bombs end there),
46//! and at most [`MAX_TRIES`] sections of one name are inflated.
47#![deny(
48    clippy::unwrap_used,
49    clippy::expect_used,
50    clippy::indexing_slicing,
51    clippy::panic,
52    clippy::unreachable
53)]
54
55use std::collections::{HashMap, HashSet};
56use std::io::{self, Read, Seek, SeekFrom};
57
58use flate2::read::DeflateDecoder;
59
60/// Section with the preview image (PNG).
61pub const PREVIEW_SECTION: &str = "PreviewPNG";
62/// Section with the component tree of an assembly (XML).
63pub const COMPONENT_TREE_SECTION: &str = "swXmlContents/COMPINSTANCETREE";
64/// Section with the referenced documents as recorded by the document manager.
65pub const CONTENT_MANAGER_SECTION: &str = "SwDocContentMgr/SwDocContentMgrInfo";
66
67const MARKER: [u8; 6] = [0x14, 0x00, 0x06, 0x00, 0x08, 0x00];
68/// Marker and the five fixed fields before the name.
69const FIXED: usize = MARKER.len() + 20;
70/// The first section starts within these bytes of the file.
71const FIRST_WITHIN: usize = 64;
72const MAX_NAME: usize = 512;
73/// Most sections read from the directory of one file.
74pub const MAX_SECTIONS: usize = 100_000;
75/// Most bytes looked at one by one while searching for section headers
76/// (the data of recognised sections is skipped, not searched).
77pub const MAX_SEARCH: u64 = 64 * 1024 * 1024;
78/// Bytes before each section header (the same in all sections of a file).
79const SIGNATURE: usize = 4;
80const CHUNK: usize = 64 * 1024;
81/// Most sections of the same name inflated by one [`SwContainer::read`]
82/// (the NIST files have every name once).
83pub const MAX_TRIES: usize = 4;
84
85/// A section of the container: where its compressed data lies in the file.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct Section {
88    pub name: String,
89    /// Absolute offset of the compressed data.
90    pub offset: u64,
91    pub compressed: u64,
92    pub size: u64,
93    pub crc: u32,
94    /// Found after the signature (or the section that revealed it); the
95    /// sections found before are only plausible headers.
96    pub confirmed: bool,
97}
98
99fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
100    let b = bytes.get(at..at.checked_add(4)?)?;
101    Some(u32::from_le_bytes(b.try_into().ok()?))
102}
103
104/// The section whose header starts at `buf[0]` (the file offset of `buf[0]`
105/// is `at`), if the header is plausible: the marker, a printable name and
106/// data inside the file. Header-like records without data are rejected.
107fn parse_header(buf: &[u8], at: u64, file_len: u64) -> Option<Section> {
108    if !buf.starts_with(&MARKER) {
109        return None;
110    }
111    let crc = u32_at(buf, 10)?;
112    let compressed = u32_at(buf, 14)?;
113    let size = u32_at(buf, 18)?;
114    let name_len = usize::try_from(u32_at(buf, 22)?).ok()?;
115    if !(1..=MAX_NAME).contains(&name_len) {
116        return None;
117    }
118    // Records without data: "CRC" = 4 × and "size" = 2 × "compressed size".
119    if compressed > 0
120        && u64::from(crc) == 4 * u64::from(compressed)
121        && u64::from(size) == 2 * u64::from(compressed)
122    {
123        return None;
124    }
125    let raw = buf.get(FIXED..FIXED + name_len)?;
126    let name: String = raw.iter().map(|b| char::from(b.rotate_left(4))).collect();
127    if !name.chars().all(|c| (' '..='~').contains(&c)) || name.trim().is_empty() {
128        return None;
129    }
130    let offset = at.checked_add((FIXED + name_len) as u64)?;
131    let end = offset.checked_add(u64::from(compressed))?;
132    if compressed == 0 || end > file_len {
133        return None;
134    }
135    Some(Section {
136        name,
137        offset,
138        compressed: u64::from(compressed),
139        size: u64::from(size),
140        crc,
141        confirmed: false,
142    })
143}
144
145fn find_marker(hay: &[u8]) -> Option<usize> {
146    let mut from = 0;
147    while let Some(i) = hay.get(from..)?.iter().position(|&b| b == MARKER[0]) {
148        let at = from + i;
149        if hay.get(at..at + MARKER.len())? == MARKER {
150            return Some(at);
151        }
152        from = at + 1;
153    }
154    None
155}
156
157/// True if the first bytes of a file start like a SOLIDWORKS 2015+ file: a
158/// plausible section header within the first `FIRST_WITHIN` bytes (the
159/// first 600 bytes of a file are enough).
160pub fn is_container(head: &[u8]) -> bool {
161    first_section_at(head).is_some()
162}
163
164fn first_section_at(head: &[u8]) -> Option<usize> {
165    let window = head.get(..FIRST_WITHIN + MARKER.len()).unwrap_or(head);
166    let mut from = 0;
167    while let Some(i) = window.get(from..).and_then(find_marker) {
168        let at = from + i;
169        // The first records are header-like records without data; their
170        // marker and name are enough to recognise the container.
171        if head.get(at..).is_some_and(plausible_record) {
172            return Some(at);
173        }
174        from = at + 1;
175    }
176    None
177}
178
179/// Marker, a name length in range and a printable name.
180fn plausible_record(buf: &[u8]) -> bool {
181    let Some(name_len) = u32_at(buf, 22).and_then(|n| usize::try_from(n).ok()) else {
182        return false;
183    };
184    (1..=MAX_NAME).contains(&name_len)
185        && buf
186            .get(FIXED..FIXED + name_len)
187            .is_some_and(|raw| raw.iter().all(|b| (0x20..0x7f).contains(&b.rotate_left(4))))
188}
189
190/// Reads up to `buf.len()` bytes at `pos`; returns how many were read.
191fn read_at<R: Read + Seek>(reader: &mut R, pos: u64, buf: &mut [u8]) -> io::Result<usize> {
192    reader.seek(SeekFrom::Start(pos))?;
193    let mut n = 0;
194    while let Some(rest) = buf.get_mut(n..).filter(|r| !r.is_empty()) {
195        match reader.read(rest) {
196            Ok(0) => break,
197            Ok(k) => n += k,
198            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
199            Err(e) => return Err(e),
200        }
201    }
202    Ok(n)
203}
204
205/// Inflates raw Deflate data of a section: `Some` only if it yields exactly
206/// `size` bytes with the CRC-32 `crc`. Never produces more than `size + 1`
207/// bytes, whatever the data claims.
208pub fn inflate<R: Read>(compressed: R, size: u64, crc: u32) -> io::Result<Option<Vec<u8>>> {
209    let mut out = Vec::with_capacity(usize::try_from(size.min(1 << 20)).unwrap_or(0));
210    match DeflateDecoder::new(compressed)
211        .take(size.saturating_add(1))
212        .read_to_end(&mut out)
213    {
214        Ok(_) => {}
215        Err(e)
216            if matches!(
217                e.kind(),
218                io::ErrorKind::InvalidInput
219                    | io::ErrorKind::InvalidData
220                    | io::ErrorKind::UnexpectedEof
221            ) =>
222        {
223            return Ok(None)
224        }
225        Err(e) => return Err(e),
226    }
227    Ok((out.len() as u64 == size && crc32fast::hash(&out) == crc).then_some(out))
228}
229
230/// A buffered view of the file for the walk: refilled only when the
231/// requested bytes are not inside it.
232#[derive(Default)]
233struct Window {
234    start: u64,
235    data: Vec<u8>,
236    /// The buffer ends at the end of the file.
237    at_eof: bool,
238}
239
240impl Window {
241    /// Up to `len` bytes from `pos` (fewer only at the end of the file).
242    fn get<R: Read + Seek>(&mut self, reader: &mut R, pos: u64, len: usize) -> io::Result<&[u8]> {
243        let rest = self.rest(reader, pos, len)?;
244        Ok(rest.get(..len).unwrap_or(rest))
245    }
246
247    /// The buffered bytes from `pos` on, at least `min` of them (fewer only
248    /// at the end of the file). The buffer is refilled only when fewer are
249    /// buffered, so a search that moves forward through the file reads each
250    /// chunk about once – not once per marker it looks at.
251    fn rest<R: Read + Seek>(&mut self, reader: &mut R, pos: u64, min: usize) -> io::Result<&[u8]> {
252        let end = self.start + self.data.len() as u64;
253        let inside = pos >= self.start
254            && pos <= end
255            && (self.at_eof || pos.saturating_add(min as u64) <= end)
256            && !self.data.is_empty();
257        if !inside {
258            let want = CHUNK.max(min);
259            self.data.resize(want, 0);
260            let n = read_at(reader, pos, &mut self.data)?;
261            self.data.truncate(n);
262            self.at_eof = n < want;
263            self.start = pos;
264        }
265        let from = usize::try_from(pos - self.start).unwrap_or(usize::MAX);
266        Ok(self.data.get(from..).unwrap_or_default())
267    }
268}
269
270/// A SOLIDWORKS 2015+ file opened from a file (or any seekable reader).
271pub struct SwContainer<R> {
272    reader: R,
273    sections: Vec<Section>,
274}
275
276impl<R: Read + Seek> SwContainer<R> {
277    /// Finds the sections. `None` if the file is not a SOLIDWORKS 2015+ file.
278    pub fn open(mut reader: R) -> io::Result<Option<Self>> {
279        let file_len = reader.seek(SeekFrom::End(0))?;
280        let mut head = vec![0u8; FIRST_WITHIN + FIXED + MAX_NAME];
281        let n = read_at(&mut reader, 0, &mut head)?;
282        head.truncate(n);
283        let Some(first) = first_section_at(&head) else {
284            return Ok(None);
285        };
286        let sections = Self::walk(&mut reader, first as u64, file_len)?;
287        Ok(Some(Self { reader, sections }))
288    }
289
290    /// Walks from header to header. Every section of a file is preceded by
291    /// the same four bytes (its *signature*); the header-like records without
292    /// data are not. Until the signature is known – two sections in a row,
293    /// the second four bytes after the data of the first, both preceded by
294    /// the same bytes – the file is searched byte by byte; afterwards only
295    /// headers with the signature count and their data is skipped. The byte
296    /// search is bounded by [`MAX_SEARCH`].
297    fn walk(reader: &mut R, mut pos: u64, file_len: u64) -> io::Result<Vec<Section>> {
298        let mut sections = Vec::new();
299        let mut searched = 0u64;
300        let mut window = Window::default();
301        let mut signature: Option<[u8; 4]> = None;
302        while pos < file_len && searched < MAX_SEARCH && sections.len() < MAX_SECTIONS {
303            // Search the next marker from `pos` in what is buffered (at least
304            // a whole header, unless the file ends earlier).
305            let chunk = window.rest(reader, pos, FIXED + MAX_NAME)?;
306            let n = chunk.len();
307            let Some(i) = find_marker(chunk) else {
308                if n < MARKER.len() {
309                    break;
310                }
311                // Keep the last bytes: a marker may straddle the chunks.
312                let step = (n - (MARKER.len() - 1)) as u64;
313                searched += step;
314                pos += step;
315                continue;
316            };
317            searched += i as u64 + 1;
318            let at = pos + i as u64;
319            let before: Option<[u8; 4]> = match at.checked_sub(4) {
320                Some(b) => window.get(reader, b, 4)?.try_into().ok(),
321                None => None,
322            };
323            let header = window.get(reader, at, FIXED + MAX_NAME)?;
324            let section = parse_header(header, at, file_len)
325                .filter(|_| signature.is_none() || before == signature);
326            let Some(mut section) = section else {
327                pos = at + 1;
328                continue;
329            };
330            let end = section.offset + section.compressed;
331            if signature.is_some() {
332                section.confirmed = true;
333                sections.push(section);
334                pos = end;
335                continue;
336            }
337            let after = window.get(reader, end, SIGNATURE + MARKER.len())?;
338            let (sig_after, marker_after) = after.split_at(SIGNATURE.min(after.len()));
339            if marker_after == MARKER && before.is_some_and(|b| sig_after == b) {
340                signature = before;
341                section.confirmed = true;
342                pos = end;
343            } else {
344                pos = at + 1;
345            }
346            sections.push(section);
347        }
348        Ok(sections)
349    }
350
351    pub fn sections(&self) -> &[Section] {
352        &self.sections
353    }
354
355    /// The uncompressed data of the named section, if it exists, is at most
356    /// `max` bytes long and inflates correctly (size and CRC-32). Should the
357    /// name occur more than once, the first section that inflates wins –
358    /// confirmed sections first (real files also hold header-like records
359    /// with the names of real sections before the signature is known). Of
360    /// each kind only the first [`MAX_TRIES`] are tried: each may inflate up
361    /// to `max` bytes before it fails, so a file full of bogus copies of one
362    /// section must not multiply that work.
363    pub fn read(&mut self, name: &str, max: u64) -> io::Result<Option<Vec<u8>>> {
364        let named = |confirmed: bool| {
365            self.sections
366                .iter()
367                .filter(move |s| s.name == name && s.size <= max && s.confirmed == confirmed)
368                .take(MAX_TRIES)
369                .cloned()
370        };
371        let candidates: Vec<Section> = named(true).chain(named(false)).collect();
372        for s in candidates {
373            self.reader.seek(SeekFrom::Start(s.offset))?;
374            let input = (&mut self.reader).take(s.compressed);
375            if let Some(data) = inflate(input, s.size, s.crc)? {
376                return Ok(Some(data));
377            }
378        }
379        Ok(None)
380    }
381}
382
383/// What a SOLIDWORKS 2015+ file says about the files it references.
384#[derive(Clone, Debug, Default, PartialEq, Eq)]
385pub struct ReferenceData {
386    /// Component files of an assembly (paths as stored), from the component
387    /// tree; `None` if the file has no component tree.
388    pub components: Option<Vec<String>>,
389    /// The document manager's section, to be scanned for file names when
390    /// there is no component tree.
391    pub content_manager: Option<Vec<u8>>,
392}
393
394/// The reference sections of a SOLIDWORKS 2015+ file (each at most `max`
395/// bytes uncompressed), or `None` if the file is not one.
396pub fn reference_data<R: Read + Seek>(reader: R, max: u64) -> io::Result<Option<ReferenceData>> {
397    let Some(mut file) = SwContainer::open(reader)? else {
398        return Ok(None);
399    };
400    let components = file
401        .read(COMPONENT_TREE_SECTION, max)?
402        .map(|xml| components(&xml));
403    let content_manager = match components {
404        Some(_) => None,
405        None => file.read(CONTENT_MANAGER_SECTION, max)?,
406    };
407    Ok(Some(ReferenceData {
408        components,
409        content_manager,
410    }))
411}
412
413/// The instance counts ([`component_counts`]) of a SOLIDWORKS 2015+ file,
414/// or `None` when it is no such file or has no component tree.
415pub fn file_component_counts<R: Read + Seek>(
416    reader: R,
417    max: u64,
418) -> io::Result<Option<Vec<(String, u32)>>> {
419    let Some(mut file) = SwContainer::open(reader)? else {
420        return Ok(None);
421    };
422    Ok(file
423        .read(COMPONENT_TREE_SECTION, max)?
424        .map(|xml| component_counts(&xml)))
425}
426
427/// XML as text: UTF-8, or UTF-16 with a byte order mark.
428fn xml_text(xml: &[u8]) -> String {
429    let utf16 = |bytes: &[u8], be: bool| {
430        let units: Vec<u16> = bytes
431            .chunks_exact(2)
432            .filter_map(|c| <[u8; 2]>::try_from(c).ok())
433            .map(|c| {
434                if be {
435                    u16::from_be_bytes(c)
436                } else {
437                    u16::from_le_bytes(c)
438                }
439            })
440            .collect();
441        String::from_utf16_lossy(&units)
442    };
443    match xml {
444        [0xFF, 0xFE, rest @ ..] => utf16(rest, false),
445        [0xFE, 0xFF, rest @ ..] => utf16(rest, true),
446        [b'<', 0, ..] => utf16(xml, false),
447        _ => String::from_utf8_lossy(xml.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(xml)).into_owned(),
448    }
449}
450
451fn attr(e: &quick_xml::events::BytesStart<'_>, name: &str) -> Option<String> {
452    e.attributes()
453        .flatten()
454        .find(|a| a.key.local_name().as_ref() == name)
455        .and_then(|a| {
456            a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
457                .ok()
458                .map(|v| v.into_owned())
459        })
460}
461
462/// The files of the direct components of an assembly, from its component
463/// tree (`swXmlContents/COMPINSTANCETREE`), each once in the order of the
464/// tree:
465///
466/// ```text
467/// <swFile id="6" swPath="Z:\…\box.SLDPRT"/>                      the files
468/// <swModel id="5" swFileRef="6"/>                                 a model of a file
469/// <swModel id="2" swFileRef="3">                                  the assembly itself
470///   <swReference swModelRef="5" swIsVirtualComponent="NO"/>       an instance
471/// </swModel>
472/// ```
473///
474/// The assembly's own models are those that no instance refers to; only
475/// their instances count (sub-assemblies list their own components).
476/// Virtual components live inside the assembly and have no file. Broken XML
477/// yields the components read up to the error.
478pub fn components(xml: &[u8]) -> Vec<String> {
479    let mut seen = HashSet::new();
480    tree_instances(xml)
481        .into_iter()
482        .map(|(path, _)| path)
483        .filter(|path| seen.insert(path.to_lowercase()))
484        .collect()
485}
486
487/// The instance counts of the direct components of an assembly: every file
488/// of [`components`] with the number of its instances that count for a
489/// BOM (those without `swExcludeFromBOM="YES"`; a file with only excluded
490/// instances has the count 0).
491pub fn component_counts(xml: &[u8]) -> Vec<(String, u32)> {
492    let mut out: Vec<(String, u32)> = Vec::new();
493    let mut index: HashMap<String, usize> = HashMap::new();
494    for (path, excluded) in tree_instances(xml) {
495        let at = *index.entry(path.to_lowercase()).or_insert_with(|| {
496            out.push((path, 0));
497            out.len() - 1
498        });
499        if let Some(entry) = out.get_mut(at) {
500            entry.1 += u32::from(!excluded);
501        }
502    }
503    out
504}
505
506/// Instances of the assembly's own models: (file path, excluded from BOM).
507fn tree_instances(xml: &[u8]) -> Vec<(String, bool)> {
508    use quick_xml::events::Event;
509    let text = xml_text(xml);
510    let mut reader = quick_xml::Reader::from_str(&text);
511    let mut files: HashMap<String, String> = HashMap::new();
512    let mut model_file: HashMap<String, String> = HashMap::new();
513    // (model containing the instance, model the instance refers to, excluded from the BOM)
514    let mut instances: Vec<(String, String, bool)> = Vec::new();
515    let mut current_model: Option<String> = None;
516    loop {
517        let event = match reader.read_event() {
518            Ok(Event::Eof) | Err(_) => break,
519            Ok(event) => event,
520        };
521        match &event {
522            Event::Start(e) | Event::Empty(e) => match e.local_name().as_ref() {
523                "swFile" => {
524                    if let (Some(id), Some(path)) = (attr(e, "id"), attr(e, "swPath")) {
525                        files.insert(id, path);
526                    }
527                }
528                "swModel" => {
529                    let id = attr(e, "id").unwrap_or_default();
530                    if let Some(file) = attr(e, "swFileRef") {
531                        model_file.insert(id.clone(), file);
532                    }
533                    if matches!(event, Event::Start(_)) {
534                        current_model = Some(id);
535                    }
536                }
537                "swReference" => {
538                    let is_virtual = attr(e, "swIsVirtualComponent")
539                        .is_some_and(|v| v.eq_ignore_ascii_case("YES"));
540                    let excluded = attr(e, "swExcludeFromBOM")
541                        .is_some_and(|v| v.eq_ignore_ascii_case("YES") || v == "1");
542                    if let (Some(parent), Some(target), false) =
543                        (current_model.clone(), attr(e, "swModelRef"), is_virtual)
544                    {
545                        instances.push((parent, target, excluded));
546                    }
547                }
548                _ => {}
549            },
550            Event::End(e) if e.local_name().as_ref() == "swModel" => current_model = None,
551            _ => {}
552        }
553        if instances.len() > 1_000_000 {
554            break;
555        }
556    }
557    let used: HashSet<&str> = instances.iter().map(|(_, t, _)| t.as_str()).collect();
558    instances
559        .iter()
560        .filter(|(parent, _, _)| !used.contains(parent.as_str()))
561        .filter_map(|(_, target, excluded)| {
562            Some((files.get(model_file.get(target)?)?.clone(), *excluded))
563        })
564        .collect()
565}
566
567/// Nesting levels of sub-assemblies followed inside one component tree.
568const MAX_TREE_DEPTH: usize = 8;
569/// Placed components read from one component tree at most.
570const MAX_PLACED: usize = 100_000;
571
572/// An instance in the component tree.
573#[derive(Debug)]
574struct Instance {
575    model: String,
576    transform: [f64; 16],
577    left_out: bool,
578}
579
580/// The parts of an assembly with their placements, from its component tree
581/// (`swXmlContents/COMPINSTANCETREE`), sub-assemblies resolved:
582///
583/// ```text
584/// <swFile id="6" swDocType="PART" swPath="…\Topf.SLDPRT"/>
585/// <swModel id="5" swFileRef="6"/>                                  a file in one configuration
586/// <swModel id="2" swFileRef="3">                                   an assembly in one configuration
587///   <swReference swModelRef="5" swSuppressed="NO" swHidden="NO"
588///                swTransform="r00 r01 r02 0 r10 r11 r12 0 r20 r21 r22 0 tx ty tz 1"/>
589/// </swModel>
590/// <swConfiguration swModelRef="2" swMostRecentConfiguration="YES"/>
591/// ```
592///
593/// A point of a component lands at `p · R + t` (t in metres; the
594/// components carry millimetres). The top model is the configuration
595/// that was active when the file was saved; a sub-assembly's model lists
596/// its own instances, placed relative to it. Suppressed, hidden and
597/// virtual instances (the latter have no file of their own) are left out,
598/// with everything below them. Each component is named by the file name
599/// of its `swPath` (the author's path, so it is found by name). `None`
600/// when the XML has no assembly model.
601pub fn placements(xml: &[u8]) -> Option<Vec<crate::nxasm::Component>> {
602    use quick_xml::events::Event;
603    let text = xml_text(xml);
604    let mut reader = quick_xml::Reader::from_str(&text);
605    // id → (path, is an assembly)
606    let mut files: HashMap<String, (String, bool)> = HashMap::new();
607    let mut model_file: HashMap<String, String> = HashMap::new();
608    let mut instances: HashMap<String, Vec<Instance>> = HashMap::new();
609    let mut configurations: Vec<(String, bool)> = Vec::new();
610    let mut current_model: Option<String> = None;
611    let mut count = 0usize;
612    loop {
613        let event = match reader.read_event() {
614            Ok(Event::Eof) | Err(_) => break,
615            Ok(event) => event,
616        };
617        match &event {
618            Event::Start(e) | Event::Empty(e) => match e.local_name().as_ref() {
619                "swFile" => {
620                    if let (Some(id), Some(path)) = (attr(e, "id"), attr(e, "swPath")) {
621                        let assembly = attr(e, "swDocType")
622                            .is_some_and(|t| t.eq_ignore_ascii_case("ASSEMBLY"));
623                        files.insert(id, (path, assembly));
624                    }
625                }
626                "swModel" => {
627                    let id = attr(e, "id").unwrap_or_default();
628                    if let Some(file) = attr(e, "swFileRef") {
629                        model_file.insert(id.clone(), file);
630                    }
631                    if matches!(event, Event::Start(_)) {
632                        current_model = Some(id);
633                    }
634                }
635                "swReference" => {
636                    let yes =
637                        |name: &str| attr(e, name).is_some_and(|v| v.eq_ignore_ascii_case("YES"));
638                    let transform = attr(e, "swTransform").and_then(|t| matrix(&t));
639                    if let (Some(parent), Some(model), Some(transform)) =
640                        (current_model.clone(), attr(e, "swModelRef"), transform)
641                    {
642                        count += 1;
643                        instances.entry(parent).or_default().push(Instance {
644                            model,
645                            transform,
646                            left_out: yes("swSuppressed")
647                                || yes("swHidden")
648                                || yes("swIsVirtualComponent"),
649                        });
650                    }
651                }
652                "swConfiguration" => {
653                    if let Some(model) = attr(e, "swModelRef") {
654                        let recent = attr(e, "swMostRecentConfiguration")
655                            .is_some_and(|v| v.eq_ignore_ascii_case("YES"));
656                        configurations.push((model, recent));
657                    }
658                }
659                _ => {}
660            },
661            Event::End(e) if e.local_name().as_ref() == "swModel" => current_model = None,
662            _ => {}
663        }
664        if count > 1_000_000 {
665            break;
666        }
667    }
668    let top = configurations
669        .iter()
670        .find(|(_, recent)| *recent)
671        .or_else(|| configurations.first())
672        .map(|(model, _)| model.clone())
673        .filter(|m| instances.contains_key(m))
674        .or_else(|| {
675            // no configuration list: the model with instances nobody refers to
676            let used: HashSet<&str> = instances
677                .values()
678                .flatten()
679                .map(|i| i.model.as_str())
680                .collect();
681            instances
682                .keys()
683                .find(|m| !used.contains(m.as_str()))
684                .cloned()
685        })?;
686    let mut out = Vec::new();
687    let mut stack = vec![(top, crate::nxasm::identity(), 0usize)];
688    while let Some((model, parent, depth)) = stack.pop() {
689        for instance in instances.get(&model).into_iter().flatten().rev() {
690            if instance.left_out || out.len() >= MAX_PLACED {
691                continue;
692            }
693            let Some((path, assembly)) = model_file.get(&instance.model).and_then(|f| files.get(f))
694            else {
695                continue;
696            };
697            let t = &instance.transform;
698            let placed = crate::nxasm::Component {
699                name: file_name(path).to_string(),
700                rotation: [[t[0], t[1], t[2]], [t[4], t[5], t[6]], [t[8], t[9], t[10]]],
701                translation: [t[12] * 1000.0, t[13] * 1000.0, t[14] * 1000.0],
702                layer: None,
703                occurrence: None,
704                document: None,
705            }
706            .within(&parent);
707            let children = instances
708                .get(&instance.model)
709                .is_some_and(|c| !c.is_empty());
710            if *assembly && children && depth < MAX_TREE_DEPTH {
711                stack.push((instance.model.clone(), placed, depth + 1));
712            } else if !*assembly || !children {
713                // a part, or an assembly whose tree is not in this file
714                out.push(placed);
715            }
716        }
717    }
718    Some(out)
719}
720
721/// The 16 numbers of a `swTransform`, if they are all finite.
722fn matrix(text: &str) -> Option<[f64; 16]> {
723    let mut m = [0.0; 16];
724    let mut values = text.split_ascii_whitespace();
725    for v in m.iter_mut() {
726        *v = values
727            .next()?
728            .parse::<f64>()
729            .ok()
730            .filter(|x| x.is_finite())?;
731    }
732    values.next().is_none().then_some(m)
733}
734
735/// The last component of a Windows or Unix path.
736fn file_name(path: &str) -> &str {
737    path.rsplit(['\\', '/']).next().unwrap_or(path)
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use std::io::{Cursor, Write};
744
745    fn fixture(name: &str) -> Vec<u8> {
746        let dir = concat!(
747            env!("CARGO_MANIFEST_DIR"),
748            "/tests/fixtures/cad/nist/mtc-assembly/solidworks"
749        );
750        std::fs::read(format!("{dir}/{name}")).unwrap()
751    }
752
753    fn deflate(data: &[u8]) -> Vec<u8> {
754        let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
755        e.write_all(data).unwrap();
756        e.finish().unwrap()
757    }
758
759    const SIG: [u8; 4] = [0xda, 0x1f, 0x8b, 0xe1];
760
761    /// A section as SOLIDWORKS writes it (without the signature before it).
762    fn section(name: &str, data: &[u8]) -> Vec<u8> {
763        let packed = deflate(data);
764        raw_section(name, &packed, data.len() as u32, crc32fast::hash(data))
765    }
766
767    fn raw_section(name: &str, packed: &[u8], size: u32, crc: u32) -> Vec<u8> {
768        let mut out = MARKER.to_vec();
769        out.extend_from_slice(&[0xc3, 0xf8, 0xf8, 0x33]);
770        for v in [crc, packed.len() as u32, size, name.len() as u32] {
771            out.extend_from_slice(&v.to_le_bytes());
772        }
773        out.extend(name.bytes().map(|b| b.rotate_left(4)));
774        out.extend_from_slice(packed);
775        out
776    }
777
778    /// A file start and the sections, each after the signature.
779    fn container(sections: &[Vec<u8>]) -> Vec<u8> {
780        let mut out = vec![0x32, 0x34, 0x09, 0x2c, 0, 0, 0, 4, 0xc1];
781        for s in sections {
782            out.extend_from_slice(&SIG);
783            out.extend_from_slice(s);
784        }
785        out
786    }
787
788    #[test]
789    fn records_without_the_signature_are_ignored_once_it_is_known() {
790        let mut bytes = container(&[section("One", b"one"), section("Two", b"two")]);
791        // Not after the signature: a stray header (e.g. in the directory).
792        bytes.extend_from_slice(&[9, 9, 9, 9]);
793        bytes.extend(section("Stray", b"stray"));
794        bytes.extend_from_slice(&SIG);
795        bytes.extend(section("Three", b"three"));
796        // A file of its own in the system temp folder: the repository's
797        // `target/` need not exist (CARGO_TARGET_DIR elsewhere), and a shared
798        // name would race with parallel test runs.
799        let p = std::env::temp_dir().join(format!("teamcraft-swpart-{}.bin", uuid::Uuid::new_v4()));
800        std::fs::write(&p, &bytes).unwrap();
801        let sw = SwContainer::open(std::fs::File::open(&p).unwrap())
802            .unwrap()
803            .unwrap();
804        let names: Vec<&str> = sw.sections().iter().map(|s| s.name.as_str()).collect();
805        let _ = std::fs::remove_file(&p);
806        assert_eq!(names, ["One", "Two", "Three"]);
807    }
808
809    /// Every section that inflates correctly, found by trying every marker
810    /// in the file (slow, the reference for the walk).
811    fn brute_force(bytes: &[u8]) -> Vec<String> {
812        let mut out = Vec::new();
813        for at in 0..bytes.len() {
814            if let Some(s) = parse_header(&bytes[at..], at as u64, bytes.len() as u64) {
815                let data = &bytes[s.offset as usize..(s.offset + s.compressed) as usize];
816                if inflate(data, s.size, s.crc).unwrap().is_some() {
817                    out.push(s.name);
818                }
819            }
820        }
821        out
822    }
823
824    fn all_fixtures() -> Vec<std::path::PathBuf> {
825        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
826            for e in std::fs::read_dir(dir).unwrap() {
827                let p = e.unwrap().path();
828                let ext = p
829                    .extension()
830                    .map(|x| x.to_string_lossy().to_lowercase())
831                    .unwrap_or_default();
832                if p.is_dir() {
833                    walk(&p, out);
834                } else if matches!(ext.as_str(), "sldprt" | "sldasm" | "slddrw") {
835                    out.push(p);
836                }
837            }
838        }
839        let mut out = Vec::new();
840        walk(
841            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/cad")),
842            &mut out,
843        );
844        assert!(out.len() >= 10, "{out:?}");
845        out
846    }
847
848    #[test]
849    fn the_walk_finds_every_section_of_the_nist_files() {
850        for path in all_fixtures() {
851            let bytes = std::fs::read(&path).unwrap();
852            assert!(is_container(&bytes[..600]), "{path:?}");
853            let mut file = SwContainer::open(Cursor::new(&bytes)).unwrap().unwrap();
854            let mut good = Vec::new();
855            for s in file.sections().to_vec() {
856                if file.read(&s.name, u64::MAX).unwrap().is_some() && !good.contains(&s.name) {
857                    good.push(s.name);
858                }
859            }
860            let mut expected = brute_force(&bytes);
861            expected.sort();
862            expected.dedup();
863            good.sort();
864            assert_eq!(good, expected, "{path:?}");
865            for name in [PREVIEW_SECTION, CONTENT_MANAGER_SECTION, "Header2"] {
866                assert!(good.iter().any(|n| n == name), "{name} in {path:?}");
867            }
868        }
869    }
870
871    #[test]
872    fn every_nist_file_has_a_png_preview() {
873        for path in all_fixtures() {
874            let file = std::fs::File::open(&path).unwrap();
875            let mut sw = SwContainer::open(io::BufReader::new(file))
876                .unwrap()
877                .unwrap();
878            let png = sw.read(PREVIEW_SECTION, 8 << 20).unwrap().unwrap();
879            assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "{path:?}");
880            assert!(
881                sw.read(PREVIEW_SECTION, 1000).unwrap().is_none(),
882                "max size"
883            );
884            assert!(sw.read("Nope", 1 << 20).unwrap().is_none());
885        }
886    }
887
888    #[test]
889    fn the_assembly_names_exactly_its_eight_parts() {
890        let bytes = fixture("nist_mtc_crada_assembly_rev-D.SLDASM");
891        let data = reference_data(Cursor::new(&bytes), 32 << 20)
892            .unwrap()
893            .unwrap();
894        let mut names: Vec<String> = data
895            .components
896            .unwrap()
897            .iter()
898            .map(|p| p.rsplit('\\').next().unwrap().to_string())
899            .collect();
900        names.sort();
901        assert_eq!(
902            names,
903            [
904                "90591A141_ZINC PLATED STEEL HEX NUT.SLDPRT",
905                "91274A118_COATED ALLOY STEEL SOCKET HEAD CAP SCREW.SLDPRT",
906                "91274A141_COATED ALLOY STEEL SOCKET HEAD CAP SCREW.SLDPRT",
907                "91304A112_BLUE FLAT-HEAD SOCKET CAP SCREW.SLDPRT",
908                "nist_mtc_crada_box_rev-D.SLDPRT",
909                "nist_mtc_crada_cover_rev-B.SLDPRT",
910                "nist_mtc_crada_plate_rev-A.SLDPRT",
911                "nist_mtc_crada_polycarb-window.SLDPRT",
912            ]
913        );
914        assert!(data.content_manager.is_none());
915        // Parts have no component tree.
916        let part = reference_data(
917            Cursor::new(fixture("nist_mtc_crada_box_rev-D.SLDPRT")),
918            32 << 20,
919        )
920        .unwrap()
921        .unwrap();
922        assert!(part.components.is_none());
923        assert!(part.content_manager.is_some());
924    }
925
926    #[test]
927    fn component_tree_counts_direct_components_only() {
928        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
929            <swSolidWorks><swHeader>
930              <swFile id="1" swPath="C:\p\Top.SLDASM"/>
931              <swFile id="2" swPath="C:\p\Sub.SLDASM"/>
932              <swFile id="3" swPath="C:\p\Bolt.SLDPRT"/>
933              <swFile id="4" swPath="C:\p\Nut.SLDPRT"/>
934              <swFile id="9" swPath="C:\p\Unused.SLDPRT"/>
935            </swHeader><swModelList>
936              <swModel id="10" swFileRef="3"/>
937              <swModel id="11" swFileRef="4"/>
938              <swModel id="12" swFileRef="2">
939                <swReference swModelRef="11" swIsVirtualComponent="NO"/>
940              </swModel>
941              <swModel id="13" swFileRef="1">
942                <swReference swModelRef="10" swIsVirtualComponent="NO"/>
943                <swReference swModelRef="10" swIsVirtualComponent="NO"/>
944                <swReference swModelRef="12" swIsVirtualComponent="NO"/>
945                <swReference swModelRef="11" swIsVirtualComponent="YES"/>
946                <swReference swModelRef="99"/>
947              </swModel>
948            </swModelList></swSolidWorks>"#;
949        assert_eq!(
950            components(xml.as_bytes()),
951            ["C:\\p\\Bolt.SLDPRT", "C:\\p\\Sub.SLDASM"]
952        );
953        // UTF-16 with BOM gives the same result.
954        let mut utf16 = vec![0xFF, 0xFE];
955        utf16.extend(xml.encode_utf16().flat_map(u16::to_le_bytes));
956        assert_eq!(components(&utf16), components(xml.as_bytes()));
957        // Garbage and truncated XML: no panic, nothing or what was read.
958        assert!(components(b"\x00\x01\xff<<<").is_empty());
959        assert!(components(&xml.as_bytes()[..xml.len() / 3]).is_empty());
960    }
961
962    #[test]
963    fn component_tree_encodings_and_escapes() {
964        let xml = r#"<swSolidWorks><swFile id="1" swPath="C:\p\Gear &amp; Shaft.SLDPRT"/><swFile id="2" swPath="c:\P\gear &amp; shaft.sldprt"/><swModel id="3" swFileRef="1"/><swModel id="4" swFileRef="2"/><swModel id="5"><swReference swModelRef="3"/><swReference swModelRef="4"/></swModel></swSolidWorks>"#;
965        // XML entities are decoded; the same path in another case is one file.
966        assert_eq!(components(xml.as_bytes()), ["C:\\p\\Gear & Shaft.SLDPRT"]);
967        // UTF-8 with BOM, UTF-16 big endian with BOM and UTF-16LE without BOM.
968        let mut bom8 = b"\xEF\xBB\xBF".to_vec();
969        bom8.extend_from_slice(xml.as_bytes());
970        let mut be = vec![0xFE, 0xFF];
971        be.extend(xml.encode_utf16().flat_map(u16::to_be_bytes));
972        let le: Vec<u8> = xml.encode_utf16().flat_map(u16::to_le_bytes).collect();
973        for bytes in [bom8, be, le] {
974            assert_eq!(components(&bytes), components(xml.as_bytes()));
975        }
976    }
977
978    #[test]
979    fn component_counts_count_instances_without_bom_exclusion() {
980        let xml = br#"<swSolidWorks><swFile id="1" swPath="D:\a\Bolt.SLDPRT"/><swFile id="2" swPath="D:\a\Plate.SLDPRT"/><swFile id="3" swPath="D:\a\Glue.SLDPRT"/>
981<swModel id="4" swFileRef="1"/><swModel id="5" swFileRef="2"/><swModel id="6" swFileRef="3"/>
982<swModel id="7"><swReference swModelRef="4"/><swReference swModelRef="4"/><swReference swModelRef="5"/><swReference swModelRef="4" swExcludeFromBOM="YES"/><swReference swModelRef="6" swExcludeFromBOM="YES"/></swModel></swSolidWorks>"#;
983        assert_eq!(
984            component_counts(xml),
985            [
986                ("D:\\a\\Bolt.SLDPRT".to_string(), 2),
987                ("D:\\a\\Plate.SLDPRT".to_string(), 1),
988                ("D:\\a\\Glue.SLDPRT".to_string(), 0),
989            ]
990        );
991        // the plain component list is unchanged: each file once
992        assert_eq!(components(xml).len(), 3);
993        assert!(component_counts(b"garbage").is_empty());
994    }
995
996    #[test]
997    fn a_model_without_instances_has_no_components() {
998        let xml = br#"<swSolidWorks><swFile id="1" swPath="a.SLDPRT"/><swModel id="2" swFileRef="1"/></swSolidWorks>"#;
999        assert!(components(xml).is_empty());
1000        // Instances of unknown models or files are skipped.
1001        let xml = br#"<swSolidWorks><swModel id="2" swFileRef="9"/><swModel id="3"><swReference swModelRef="2"/><swReference swModelRef="8"/></swModel></swSolidWorks>"#;
1002        assert!(components(xml).is_empty());
1003    }
1004
1005    #[test]
1006    fn synthetic_container_round_trip() {
1007        let tree = br#"<swSolidWorks><swFile id="1" swPath="D:\a\Wheel.SLDPRT"/><swModel id="2" swFileRef="1"/><swModel id="3"><swReference swModelRef="2"/></swModel></swSolidWorks>"#;
1008        let bytes = container(&[
1009            section("Contents/Config-0", &[7u8; 5000]),
1010            section(COMPONENT_TREE_SECTION, tree),
1011            section(PREVIEW_SECTION, b"\x89PNG not really"),
1012        ]);
1013        let mut sw = SwContainer::open(Cursor::new(&bytes)).unwrap().unwrap();
1014        let names: Vec<&str> = sw.sections().iter().map(|s| s.name.as_str()).collect();
1015        assert_eq!(
1016            names,
1017            ["Contents/Config-0", COMPONENT_TREE_SECTION, PREVIEW_SECTION]
1018        );
1019        assert_eq!(
1020            sw.read(PREVIEW_SECTION, 100).unwrap().unwrap(),
1021            b"\x89PNG not really"
1022        );
1023        let refs = reference_data(Cursor::new(&bytes), 1 << 20)
1024            .unwrap()
1025            .unwrap();
1026        assert_eq!(refs.components.unwrap(), ["D:\\a\\Wheel.SLDPRT"]);
1027    }
1028
1029    #[test]
1030    fn other_files_and_garbage_are_not_containers() {
1031        for bytes in [
1032            b"".to_vec(),
1033            b"ISO-10303-21;".to_vec(),
1034            b"SPLMSSTR".to_vec(),
1035            vec![0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0],
1036            MARKER.to_vec(),
1037            // marker with a name length beyond the bytes
1038            [MARKER.to_vec(), vec![0; 16], vec![0xff; 4]].concat(),
1039        ] {
1040            assert!(!is_container(&bytes));
1041            assert!(SwContainer::open(Cursor::new(bytes)).unwrap().is_none());
1042        }
1043        // The marker deep inside a file is not the start of a container.
1044        let mut late = vec![0u8; 200];
1045        late.extend(section(PREVIEW_SECTION, b"x"));
1046        assert!(SwContainer::open(Cursor::new(late)).unwrap().is_none());
1047    }
1048
1049    #[test]
1050    fn hostile_sections_are_rejected() {
1051        let good = section("Good", b"hello hello hello");
1052        // Sizes beyond the end of the file.
1053        let mut beyond = raw_section("Beyond", &deflate(b"abc"), 3, crc32fast::hash(b"abc"));
1054        beyond[14..18].copy_from_slice(&u32::MAX.to_le_bytes());
1055        // Wrong CRC, wrong size, data that is not Deflate.
1056        let bad_crc = raw_section("BadCrc", &deflate(b"abc"), 3, 12345);
1057        let bad_size = raw_section("BadSize", &deflate(b"abcdef"), 3, crc32fast::hash(b"abc"));
1058        let not_deflate = raw_section("Garbage", &[0xff; 64], 10, 0);
1059        // A decompression bomb: 64 MiB of zeros claimed as 10 bytes.
1060        let zeros = vec![0u8; 64 << 20];
1061        let bomb = raw_section("Bomb", &deflate(&zeros), 10, crc32fast::hash(&zeros[..10]));
1062        // An honest but huge section: refused by the caller's limit.
1063        let huge = raw_section(
1064            "Huge",
1065            &deflate(&zeros),
1066            zeros.len() as u32,
1067            crc32fast::hash(&zeros),
1068        );
1069        // A header-like record without data and a non-printable name.
1070        let pseudo = raw_section("Pseudo", &[0u8; 20], 40, 80);
1071        let unprintable = raw_section("\u{1}\u{2}", &deflate(b"x"), 1, crc32fast::hash(b"x"));
1072        let bytes = container(&[
1073            good,
1074            bad_crc,
1075            bad_size,
1076            not_deflate,
1077            bomb,
1078            huge,
1079            pseudo,
1080            unprintable,
1081            beyond,
1082        ]);
1083        let mut sw = SwContainer::open(Cursor::new(&bytes)).unwrap().unwrap();
1084        let names: Vec<String> = sw.sections().iter().map(|s| s.name.clone()).collect();
1085        assert!(
1086            !names.iter().any(|n| n == "Pseudo" || n == "Beyond"),
1087            "{names:?}"
1088        );
1089        assert!(!names.iter().any(|n| n.contains('\u{1}')), "{names:?}");
1090        assert_eq!(sw.read("Good", 100).unwrap().unwrap(), b"hello hello hello");
1091        for name in ["BadCrc", "BadSize", "Garbage", "Bomb"] {
1092            assert!(names.iter().any(|n| n == name), "{name} in {names:?}");
1093            assert!(sw.read(name, u64::MAX).unwrap().is_none(), "{name}");
1094        }
1095        assert!(sw.read("Huge", 8 << 20).unwrap().is_none(), "limit");
1096    }
1097
1098    #[test]
1099    fn truncated_files_keep_the_sections_inside() {
1100        let bytes = fixture("nist_mtc_crada_assembly_rev-D.SLDASM");
1101        for cut in [700, bytes.len() / 3, bytes.len() / 2, bytes.len() - 1] {
1102            let part = &bytes[..cut];
1103            let Some(mut sw) = SwContainer::open(Cursor::new(part)).unwrap() else {
1104                continue;
1105            };
1106            assert!(sw
1107                .sections()
1108                .iter()
1109                .all(|s| s.offset + s.compressed <= cut as u64));
1110            // Reading never fails on a cut file.
1111            for s in sw.sections().to_vec() {
1112                sw.read(&s.name, 1 << 20).unwrap();
1113            }
1114            let _ = reference_data(Cursor::new(part), 1 << 20).unwrap();
1115        }
1116        // A cut inside the compressed data of one section.
1117        let s = section(PREVIEW_SECTION, &[9u8; 10_000]);
1118        let bytes = container(&[s]);
1119        let cut = &bytes[..bytes.len() - 50];
1120        let sw = SwContainer::open(Cursor::new(cut)).unwrap();
1121        assert!(sw.is_none_or(|sw| sw.sections().is_empty()));
1122    }
1123
1124    #[test]
1125    fn search_is_bounded_on_marker_floods() {
1126        // Thousands of markers with bogus headers: no panic, no sections.
1127        let mut bytes = container(&[section("A", b"a")]);
1128        for _ in 0..20_000 {
1129            bytes.extend_from_slice(&MARKER);
1130            bytes.extend_from_slice(&[0xff; 26]);
1131        }
1132        let mut reader = CountingReader {
1133            inner: Cursor::new(&bytes),
1134            reads: 0,
1135        };
1136        let sw = SwContainer::open(&mut reader).unwrap().unwrap();
1137        assert_eq!(sw.sections().len(), 1);
1138        // Each chunk is read about once, not once per marker (20 000 here):
1139        // on a real file every refill is a seek and a read system call.
1140        let chunks = bytes.len() / CHUNK + 1;
1141        assert!(reader.reads <= 4 * chunks + 8, "{} reads", reader.reads);
1142    }
1143
1144    /// Counts the read calls that reach the file.
1145    struct CountingReader<R> {
1146        inner: R,
1147        reads: usize,
1148    }
1149
1150    impl<R: Read> Read for CountingReader<R> {
1151        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1152            self.reads += 1;
1153            self.inner.read(buf)
1154        }
1155    }
1156
1157    impl<R: Seek> Seek for CountingReader<R> {
1158        fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
1159            self.inner.seek(pos)
1160        }
1161    }
1162
1163    #[test]
1164    fn bogus_copies_of_a_section_are_tried_only_a_few_times() {
1165        // Wrong CRCs: each copy inflates completely before it is refused.
1166        let bad = || raw_section("Dup", &deflate(b"abc"), 3, 1);
1167        let mut sections: Vec<Vec<u8>> = (0..MAX_TRIES).map(|_| bad()).collect();
1168        sections.push(section("Dup", b"abc"));
1169        let bytes = container(&sections);
1170        let mut sw = SwContainer::open(Cursor::new(&bytes)).unwrap().unwrap();
1171        assert_eq!(sw.sections().len(), MAX_TRIES + 1);
1172        assert!(sw.read("Dup", 100).unwrap().is_none(), "beyond the tries");
1173        // Within the tries the first good copy wins.
1174        let bytes = container(&[bad(), section("Dup", b"abc"), bad()]);
1175        let mut sw = SwContainer::open(Cursor::new(&bytes)).unwrap().unwrap();
1176        assert_eq!(sw.read("Dup", 100).unwrap().unwrap(), b"abc");
1177    }
1178
1179    #[test]
1180    fn damaged_nist_files_never_panic() {
1181        // Truncations and flipped bytes all over two real files: whatever
1182        // comes out, every section lies inside the file and reading works.
1183        for name in [
1184            "nist_mtc_crada_assembly_rev-D.SLDASM",
1185            "nist_mtc_crada_box_rev-D.SLDPRT",
1186        ] {
1187            let bytes = fixture(name);
1188            let check = |data: &[u8]| {
1189                let Some(mut sw) = SwContainer::open(Cursor::new(data)).unwrap() else {
1190                    return;
1191                };
1192                assert!(sw
1193                    .sections()
1194                    .iter()
1195                    .all(|s| s.offset + s.compressed <= data.len() as u64));
1196                for name in [PREVIEW_SECTION, "Header2", "Contents/Config-0"] {
1197                    sw.read(name, 1 << 20).unwrap();
1198                }
1199                let _ = reference_data(Cursor::new(data), 1 << 20).unwrap();
1200            };
1201            let step = bytes.len() / 60;
1202            for cut in (0..bytes.len()).step_by(step) {
1203                check(&bytes[..cut]);
1204            }
1205            let mut state = 0x2545_f491_4f6c_dd1du64;
1206            for _ in 0..60 {
1207                let mut damaged = bytes.clone();
1208                for _ in 0..8 {
1209                    state ^= state << 13;
1210                    state ^= state >> 7;
1211                    state ^= state << 17;
1212                    let at = (state % damaged.len() as u64) as usize;
1213                    damaged[at] = (state >> 32) as u8;
1214                }
1215                check(&damaged);
1216            }
1217        }
1218    }
1219
1220    /// A component tree: the top assembly (model 2) holds a part twice (one
1221    /// hidden), a suppressed and a virtual part and a sub-assembly (model 8)
1222    /// turned by 90° about z; the sub-assembly holds the part 10 mm along x.
1223    const TREE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1224<swSolidWorks xmlns="http://www.solidworks.com/sw2003/schema"><swHeader>
1225<swFile id="3" swDocType="ASSEMBLY" swPath="C:\work\Top.SLDASM"/>
1226<swFile id="6" swDocType="PART" swPath="C:\work\parts\Shaft.SLDPRT"/>
1227<swFile id="9" swDocType="ASSEMBLY" swPath="C:\work\Sub.SLDASM"/>
1228<swFile id="12" swDocType="PART" swPath="C:\Temp\Virtual.SLDPRT"/>
1229</swHeader><swModelList>
1230<swModel id="5" swFileRef="6"/>
1231<swModel id="11" swFileRef="12"/>
1232<swModel id="8" swFileRef="9">
1233<swReference swName="Shaft" swModelRef="5" swSuppressed="NO" swHidden="NO" swTransform="1 0 0 0 0 1 0 0 0 0 1 0 0.01 0 0 1"/>
1234</swModel>
1235<swModel id="2" swFileRef="3">
1236<swReference swName="Shaft" swModelRef="5" swSuppressed="NO" swHidden="NO" swTransform="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.005 1"/>
1237<swReference swName="Shaft" swModelRef="5" swSuppressed="NO" swHidden="YES" swTransform="1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 1"/>
1238<swReference swName="Shaft" swModelRef="5" swSuppressed="YES" swHidden="YES" swTransform="1 0 0 0 0 1 0 0 0 0 1 0 0 0 2 1"/>
1239<swReference swName="Virtual" swModelRef="11" swIsVirtualComponent="YES" swTransform="1 0 0 0 0 1 0 0 0 0 1 0 0 0 3 1"/>
1240<swReference swName="Sub" swModelRef="8" swSuppressed="NO" swHidden="NO" swTransform="0 1 0 0 -1 0 0 0 0 0 1 0 0 0 0.1 1"/>
1241</swModel>
1242</swModelList><swConfigurationList>
1243<swConfiguration swName="Default" swMostRecentConfiguration="YES" swModelRef="2"/>
1244</swConfigurationList></swSolidWorks>"#;
1245
1246    #[test]
1247    fn placements_resolve_sub_assemblies_and_leave_out_hidden_components() {
1248        let parts = placements(TREE.as_bytes()).unwrap();
1249        assert_eq!(parts.len(), 2, "{parts:?}");
1250        assert!(parts.iter().all(|p| p.name == "Shaft.SLDPRT"));
1251        let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9);
1252        let direct = parts.iter().find(|p| p.translation[2] < 50.0).unwrap();
1253        assert!(close(direct.place([0.0; 3]), [0.0, 0.0, 5.0]));
1254        // 10 mm along x in the sub-assembly, turned by 90° about z, 100 mm up
1255        let nested = parts.iter().find(|p| p.translation[2] > 50.0).unwrap();
1256        assert!(
1257            close(nested.place([0.0; 3]), [0.0, 10.0, 100.0]),
1258            "{nested:?}"
1259        );
1260        assert!(close(nested.place([1.0, 0.0, 0.0]), [0.0, 11.0, 100.0]));
1261    }
1262
1263    #[test]
1264    fn broken_component_trees_never_panic() {
1265        assert!(placements(b"").is_none());
1266        assert!(placements(b"<swSolidWorks><swModel id=\"1\"></swModel>").is_none());
1267        let bad = TREE.replace("0 0 0.1 1", "0 0 NaN 1");
1268        assert_eq!(placements(bad.as_bytes()).unwrap().len(), 1);
1269        for cut in (0..TREE.len()).step_by(37) {
1270            if TREE.is_char_boundary(cut) {
1271                let _ = placements(&TREE.as_bytes()[..cut]);
1272            }
1273        }
1274    }
1275
1276    #[test]
1277    fn the_nist_assembly_places_its_36_components() {
1278        let bytes = fixture("nist_mtc_crada_assembly_rev-D.SLDASM");
1279        let mut sw = SwContainer::open(Cursor::new(&bytes)).unwrap().unwrap();
1280        let xml = sw.read(COMPONENT_TREE_SECTION, 1 << 24).unwrap().unwrap();
1281        let parts = placements(&xml).unwrap();
1282        assert_eq!(parts.len(), 36);
1283        assert!(parts.iter().any(|p| p
1284            .name
1285            .eq_ignore_ascii_case("nist_mtc_crada_box_rev-D.SLDPRT")));
1286        for p in &parts {
1287            assert!(p.name.to_lowercase().ends_with(".sldprt"), "{}", p.name);
1288            for (i, row) in p.rotation.iter().enumerate() {
1289                for (j, other) in p.rotation.iter().enumerate() {
1290                    let dot: f64 = row.iter().zip(other).map(|(a, b)| a * b).sum();
1291                    let want = if i == j { 1.0 } else { 0.0 };
1292                    assert!((dot - want).abs() < 1e-6, "{p:?}");
1293                }
1294            }
1295        }
1296    }
1297}