Skip to main content

teamcraft/
invasm.rs

1//! Autodesk Inventor assemblies (`.iam`): how often the assembly places each
2//! file it references, read from the file descriptor table in the `UFRxDoc`
3//! stream of the compound file.
4//!
5//! The format is not documented. This reader only relies on what was
6//! verified with 32 real Inventor 2025–2027 assemblies of one project (390
7//! occurrences, 190 referenced files; the counts compared with Inventor's own
8//! API, see `docs/src/research/inventor-instance-counts.md`):
9//!
10//! ```text
11//! u32 n, n × UTF-16LE   full path of the referenced file
12//! u32                   kind of the path (0x43 workspace, 0x4d library, …)
13//! u32 m, m × UTF-16LE   workspace or library ("Workspace", "Content Center Files")
14//! u16                   flags
15//! u32 k, k × UTF-16LE   display name (may be empty)
16//! 6 × 0
17//! 2 × 16 bytes          identifiers
18//! u32
19//! u16 0
20//! u16                   occurrences of the file in this assembly
21//! 10 bytes              the next descriptor follows
22//! ```
23//!
24//! The stream also keeps copies of the table from earlier saves (with the
25//! same or older paths); only the first table counts – the descriptors that
26//! follow each other without a gap.
27//!
28//! The descriptor counts include suppressed occurrences. Inventor's BOM
29//! leaves those out, and so does this reader: the same stream holds one
30//! property record per occurrence, in occurrence order (again followed by
31//! copies from earlier saves, so only the first records as many as the
32//! descriptors count are read):
33//!
34//! ```text
35//! 01 05 01 00 00 00 05  u32 n, n × UTF-16LE   occurrence name ("Bolt" of "Bolt:3")
36//! 4 bytes
37//! 01 02 01 00 00 00 02  16 bytes, 4 bytes     identifier
38//! 01 07 01 00 00 00 07  u8                    1 = suppressed
39//! ```
40//!
41//! The occurrence name is the descriptor's display name, or the file name
42//! without extension when that is empty. Every suppressed record is taken
43//! off the count of the one file whose name it carries; a file with only
44//! suppressed occurrences counts 0 (still referenced, not in the BOM). When
45//! the records do not add up – fewer records than occurrences, a suppressed
46//! record whose name fits no file or several – the descriptor counts stay as
47//! they are: suppression is not guessed.
48//!
49//! Anything that does not fit is skipped, never trusted: a file whose
50//! descriptor is not found has no count (not known), it is never guessed.
51#![deny(
52    clippy::unwrap_used,
53    clippy::expect_used,
54    clippy::indexing_slicing,
55    clippy::panic,
56    clippy::unreachable
57)]
58
59use std::io::{self, Read, Seek};
60
61/// The stream with the file descriptors.
62pub const DESCRIPTOR_STREAM: &str = "/UFRxDoc";
63/// Bytes between the end of one descriptor and the start of the next.
64const TABLE_GAP: usize = 10;
65/// Longest path and longest workspace or display name read, in UTF-16 units.
66const MAX_PATH: usize = 1024;
67const MAX_NAME: usize = 512;
68
69fn u32_at(data: &[u8], i: usize) -> Option<usize> {
70    let bytes: [u8; 4] = data.get(i..i + 4)?.try_into().ok()?;
71    usize::try_from(u32::from_le_bytes(bytes)).ok()
72}
73
74fn u16_at(data: &[u8], i: usize) -> Option<u16> {
75    let bytes: [u8; 2] = data.get(i..i + 2)?.try_into().ok()?;
76    Some(u16::from_le_bytes(bytes))
77}
78
79/// A UTF-16LE string with a `u32` unit count at `i` (`min..=max` units, no
80/// control characters); the string and the offset after it.
81fn text_at(data: &[u8], i: usize, min: usize, max: usize) -> Option<(String, usize)> {
82    let n = u32_at(data, i)?;
83    if !(min..=max).contains(&n) {
84        return None;
85    }
86    let end = i.checked_add(4)?.checked_add(n.checked_mul(2)?)?;
87    let units: Vec<u16> = data
88        .get(i + 4..end)?
89        .chunks_exact(2)
90        .map(|p| {
91            u16::from_le_bytes([
92                p.first().copied().unwrap_or(0),
93                p.get(1).copied().unwrap_or(0),
94            ])
95        })
96        .collect();
97    let text: String = char::decode_utf16(units).collect::<Result<_, _>>().ok()?;
98    if text.chars().any(char::is_control) {
99        return None;
100    }
101    Some((text, end))
102}
103
104/// One file descriptor of the table.
105struct Descriptor {
106    /// File name (last part of the path).
107    name: String,
108    /// Display name, may be empty.
109    display: String,
110    /// Occurrences, suppressed ones included.
111    count: u32,
112    /// Offset after the descriptor.
113    end: usize,
114}
115
116impl Descriptor {
117    /// Does an occurrence record with this name place this file? Its name is
118    /// the display name, or the file name without extension.
119    fn is_named(&self, occurrence: &str) -> bool {
120        let occurrence = occurrence.trim().to_lowercase();
121        let stem = self
122            .name
123            .rsplit_once('.')
124            .map_or(self.name.as_str(), |(stem, _)| stem);
125        (!self.display.trim().is_empty() && self.display.trim().to_lowercase() == occurrence)
126            || stem.trim().to_lowercase() == occurrence
127    }
128}
129
130/// One file descriptor at `i`.
131fn descriptor_at(data: &[u8], i: usize) -> Option<Descriptor> {
132    let (path, after_path) = text_at(data, i, 5, MAX_PATH)?;
133    let name = path.rsplit(['\\', '/']).next()?.trim();
134    if !path.contains(['\\', '/']) || !name.contains('.') || name.starts_with('.') {
135        return None;
136    }
137    u32_at(data, after_path)?; // kind of the path
138    let (_, after_workspace) = text_at(data, after_path + 4, 0, MAX_NAME)?;
139    let (display, after_display) = text_at(data, after_workspace + 2, 0, MAX_NAME)?;
140    if data.get(after_display..after_display + 6)? != [0u8; 6] {
141        return None;
142    }
143    let tail = after_display + 6 + 32;
144    u32_at(data, tail)?;
145    if u16_at(data, tail + 4)? != 0 {
146        return None;
147    }
148    let count = u16_at(data, tail + 6)?;
149    if count == 0 {
150        return None;
151    }
152    Some(Descriptor {
153        name: name.to_string(),
154        display,
155        count: u32::from(count),
156        end: tail + 8,
157    })
158}
159
160/// The first descriptor table: the first descriptor and those that follow it
161/// directly.
162fn descriptor_table(data: &[u8]) -> Vec<Descriptor> {
163    let mut out = Vec::new();
164    let mut i = 0;
165    while i + 4 <= data.len() {
166        let Some(first) = descriptor_at(data, i) else {
167            i += 1;
168            continue;
169        };
170        let mut next = first.end + TABLE_GAP;
171        out.push(first);
172        while let Some(d) = descriptor_at(data, next) {
173            next = d.end + TABLE_GAP;
174            out.push(d);
175        }
176        break;
177    }
178    out
179}
180
181/// The tag of a property in an occurrence record: `01 t 01 00 00 00 t`.
182fn tag(t: u8) -> [u8; 7] {
183    [1, t, 1, 0, 0, 0, t]
184}
185
186/// The occurrence records in stream order: (occurrence name, suppressed).
187fn occurrence_records(data: &[u8]) -> Vec<(String, bool)> {
188    let (name_tag, id_tag, flag_tag) = (tag(0x05), tag(0x02), tag(0x07));
189    let mut out = Vec::new();
190    let mut i = 0;
191    while let Some(found) = data
192        .get(i..)
193        .and_then(|rest| rest.windows(7).position(|w| w == name_tag))
194    {
195        let at = i + found;
196        i = at + 1;
197        let Some((name, after_name)) = text_at(data, at + 7, 1, MAX_NAME) else {
198            continue;
199        };
200        let id_at = after_name + 4;
201        let flag_at = id_at + 7 + 20;
202        if data.get(id_at..id_at + 7) != Some(&id_tag[..])
203            || data.get(flag_at..flag_at + 7) != Some(&flag_tag[..])
204        {
205            continue;
206        }
207        match data.get(flag_at + 7) {
208            Some(0) => out.push((name, false)),
209            Some(1) => out.push((name, true)),
210            _ => {}
211        }
212    }
213    out
214}
215
216/// The occurrence counts of the first descriptor table in the content of
217/// the `UFRxDoc` stream: (file name, occurrences) in table order, without
218/// suppressed occurrences where the occurrence records tell them. A file
219/// name that appears twice (two files of that name in different folders)
220/// is listed twice.
221pub fn occurrence_counts(data: &[u8]) -> Vec<(String, u32)> {
222    let table = descriptor_table(data);
223    let all: Vec<(String, u32)> = table.iter().map(|d| (d.name.clone(), d.count)).collect();
224    let total: u32 = table.iter().map(|d| d.count).sum();
225    let records = occurrence_records(data);
226    let Some(current) = records.get(..usize::try_from(total).unwrap_or(usize::MAX)) else {
227        return all; // fewer records than occurrences: suppression not known
228    };
229    let mut suppressed = vec![0u32; table.len()];
230    for (name, _) in current.iter().filter(|(_, s)| *s) {
231        let mut matches = table.iter().enumerate().filter(|(_, d)| d.is_named(name));
232        match (matches.next(), matches.next()) {
233            (Some((index, _)), None) => {
234                if let Some(n) = suppressed.get_mut(index) {
235                    *n += 1;
236                }
237            }
238            _ => return all, // no file or several: not guessed
239        }
240    }
241    let mut out = Vec::with_capacity(table.len());
242    for (d, n) in table.iter().zip(&suppressed) {
243        let Some(active) = d.count.checked_sub(*n) else {
244            return all;
245        };
246        out.push((d.name.clone(), active));
247    }
248    out
249}
250
251/// How often an Inventor assembly places each referenced file: (file name,
252/// occurrences). `None` when the file is not a compound file or has no
253/// descriptor stream; reads at most `max` bytes of the stream.
254pub fn file_occurrence_counts<R: Read + Seek>(
255    reader: R,
256    max: u64,
257) -> io::Result<Option<Vec<(String, u32)>>> {
258    let Ok(mut cfb) = cfb::CompoundFile::open(reader) else {
259        return Ok(None);
260    };
261    let Ok(stream) = cfb.open_stream(DESCRIPTOR_STREAM) else {
262        return Ok(None);
263    };
264    let mut data = Vec::new();
265    stream.take(max).read_to_end(&mut data)?;
266    Ok(Some(occurrence_counts(&data)))
267}
268
269/// An Inventor-like compound file whose descriptor table lists `entries`
270/// (path, from a library, occurrences) – for tests of the callers.
271#[cfg(test)]
272pub(crate) fn test_assembly(entries: &[(&str, bool, u16)]) -> Vec<u8> {
273    test_assembly_with_records(entries, &[])
274}
275
276/// Like [`test_assembly`], followed by occurrence records (name, suppressed).
277#[cfg(test)]
278pub(crate) fn test_assembly_with_records(
279    entries: &[(&str, bool, u16)],
280    records: &[(&str, bool)],
281) -> Vec<u8> {
282    use std::io::{Cursor, Write};
283    let descriptors: Vec<Vec<u8>> = entries
284        .iter()
285        .map(|(path, library, count)| tests::descriptor(path, *library, "", *count))
286        .collect();
287    let mut data = tests::table(&descriptors);
288    for (name, suppressed) in records {
289        data.extend(tests::record(name, *suppressed));
290    }
291    let mut cfb = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
292    cfb.create_stream(DESCRIPTOR_STREAM)
293        .unwrap()
294        .write_all(&data)
295        .unwrap();
296    cfb.flush().unwrap();
297    cfb.into_inner().into_inner()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::io::{Cursor, Write};
304
305    fn text(s: &str) -> Vec<u8> {
306        let units: Vec<u16> = s.encode_utf16().collect();
307        let mut out = u32::try_from(units.len()).unwrap().to_le_bytes().to_vec();
308        out.extend(units.iter().flat_map(|u| u.to_le_bytes()));
309        out
310    }
311
312    /// One descriptor in the layout of a real Inventor 2027 assembly.
313    pub(super) fn descriptor(path: &str, library: bool, display: &str, count: u16) -> Vec<u8> {
314        let mut d = text(path);
315        d.extend(if library { 0x4du32 } else { 0x43 }.to_le_bytes());
316        d.extend(text(if library {
317            "Content Center Files"
318        } else {
319            "Workspace"
320        }));
321        d.extend(if library { 3u16 } else { 1 }.to_le_bytes());
322        d.extend(text(display));
323        d.extend([0u8; 6]);
324        d.extend((0..32u8).map(|b| b.wrapping_mul(37)));
325        d.extend(0x0001_3652u32.to_le_bytes());
326        d.extend(0u16.to_le_bytes());
327        d.extend(count.to_le_bytes());
328        d
329    }
330
331    pub(super) fn table(entries: &[Vec<u8>]) -> Vec<u8> {
332        let mut out = Vec::new();
333        for (i, e) in entries.iter().enumerate() {
334            if i > 0 {
335                out.extend([0x2f, 0, 0, 0, 1, 0, 4, 0, 0, 0]); // the 10 bytes between descriptors
336            }
337            out.extend_from_slice(e);
338        }
339        out
340    }
341
342    /// One occurrence record in the layout of a real Inventor 2027 assembly
343    /// (with some of the properties around the three that are read).
344    pub(super) fn record(name: &str, suppressed: bool) -> Vec<u8> {
345        let mut r = Vec::new();
346        r.extend(tag(0x12));
347        r.extend([0x5au8; 16]);
348        r.extend([0u8; 4]);
349        r.extend(tag(0x19));
350        r.extend(if suppressed { 5u64 } else { 1 }.to_le_bytes());
351        r.extend(tag(0x05));
352        r.extend(text(name));
353        r.extend([0u8; 4]);
354        r.extend(tag(0x02));
355        r.extend([0xa7u8; 16]);
356        r.extend([0u8; 4]);
357        r.extend(tag(0x07));
358        r.push(u8::from(suppressed));
359        r.extend([0u8; 4]);
360        r.extend(tag(0x1e));
361        r.extend(text(name));
362        r.extend([0u8; 30]);
363        r
364    }
365
366    fn with_records(table_entries: &[Vec<u8>], records: &[(&str, bool)]) -> Vec<u8> {
367        let mut data = table(table_entries);
368        data.extend([0x33u8; 64]);
369        for (name, suppressed) in records {
370            data.extend(record(name, *suppressed));
371        }
372        data
373    }
374
375    fn suppression_table() -> Vec<Vec<u8>> {
376        vec![
377            descriptor("C:\\Rover\\Wheel\\Wheel.iam", false, "", 4),
378            descriptor("C:\\Rover\\Frame.ipt", false, "", 1),
379            descriptor(
380                "C:\\CC\\ISO 4762\\ISO 4762 - M6 x 20.ipt",
381                true,
382                "ISO 4762 M6 x 20",
383                3,
384            ),
385        ]
386    }
387
388    #[test]
389    fn suppressed_occurrences_are_not_counted() {
390        let data = with_records(
391            &suppression_table(),
392            &[
393                ("Wheel", false),
394                ("Wheel", true),
395                ("Frame", true),
396                ("ISO 4762 M6 x 20", false),
397                ("Wheel", false),
398                ("ISO 4762 M6 x 20", false),
399                ("WHEEL", false), // names compare without case
400                ("ISO 4762 M6 x 20", false),
401                // records of an earlier save: not read
402                ("Wheel", true),
403                ("ISO 4762 M6 x 20", true),
404            ],
405        );
406        assert_eq!(
407            occurrence_counts(&data),
408            vec![
409                ("Wheel.iam".to_string(), 3),
410                // only suppressed occurrences: still listed, with 0
411                ("Frame.ipt".to_string(), 0),
412                ("ISO 4762 - M6 x 20.ipt".to_string(), 3),
413            ]
414        );
415    }
416
417    #[test]
418    fn suppression_that_does_not_add_up_is_not_guessed() {
419        let all = vec![
420            ("Wheel.iam".to_string(), 4),
421            ("Frame.ipt".to_string(), 1),
422            ("ISO 4762 - M6 x 20.ipt".to_string(), 3),
423        ];
424        // fewer records than occurrences
425        let few = with_records(&suppression_table(), &[("Wheel", true), ("Frame", false)]);
426        assert_eq!(occurrence_counts(&few), all);
427        // a suppressed record whose name fits no file
428        let mut unknown: Vec<(&str, bool)> = vec![("Wheel", false); 4];
429        unknown.extend([
430            ("Bracket", true),
431            ("ISO 4762 M6 x 20", false),
432            ("ISO 4762 M6 x 20", false),
433        ]);
434        assert_eq!(
435            occurrence_counts(&with_records(&suppression_table(), &unknown)),
436            all
437        );
438        // more suppressed records than the file has occurrences
439        let mut too_many: Vec<(&str, bool)> = vec![("Frame", true); 2];
440        too_many.extend(vec![("Wheel", false); 3]);
441        too_many.extend(vec![("ISO 4762 M6 x 20", false); 3]);
442        assert_eq!(
443            occurrence_counts(&with_records(&suppression_table(), &too_many)),
444            all
445        );
446        // a name that fits two files (same display name)
447        let twins = vec![
448            descriptor("C:\\Rover\\Washer A.ipt", false, "DIN 988 S7", 1),
449            descriptor("C:\\Rover\\Washer B.ipt", false, "DIN 988 S7", 1),
450        ];
451        assert_eq!(
452            occurrence_counts(&with_records(
453                &twins,
454                &[("DIN 988 S7", true), ("DIN 988 S7", false)]
455            )),
456            vec![
457                ("Washer A.ipt".to_string(), 1),
458                ("Washer B.ipt".to_string(), 1)
459            ]
460        );
461        // a record whose flag is neither 0 nor 1 is not a record (were it
462        // read as suppressed, Frame would count 0)
463        let mut odd = record("Frame", true);
464        // after the flag: 4 bytes, tag 1e, the name again, 30 bytes
465        let flag_at = odd.len() - 30 - (4 + 2 * "Frame".len()) - 7 - 4 - 1;
466        assert_eq!(odd.get(flag_at), Some(&1));
467        if let Some(b) = odd.get_mut(flag_at) {
468            *b = 2;
469        }
470        let mut data = table(&suppression_table());
471        for _ in 0..4 {
472            data.extend(record("Wheel", false));
473        }
474        data.extend(odd);
475        for _ in 0..3 {
476            data.extend(record("ISO 4762 M6 x 20", false));
477        }
478        assert_eq!(occurrence_counts(&data), all);
479    }
480
481    fn stream() -> Vec<u8> {
482        // header with the assembly's own path, then the current table, then
483        // an older copy with other paths and counts after a gap
484        let mut data = vec![0x07, 0x00, 0x00, 0x00, 0x01, 0x00];
485        data.extend(text("C:\\Rover\\Rover.iam"));
486        data.extend([0x11u8; 40]);
487        data.extend(table(&[
488            descriptor("C:\\Rover\\Wheel\\Wheel.iam", false, "Rad", 4),
489            descriptor("C:\\Rover\\Frame.ipt", false, "", 1),
490            descriptor(
491                "C:\\CC\\de-DE\\DIN 912\\DIN 912 - M6 x 20.ipt",
492                true,
493                "",
494                16,
495            ),
496            descriptor("C:\\Rover\\Old\\Frame.ipt", false, "", 2),
497        ]));
498        data.extend([0x22u8; 300]);
499        data.extend(table(&[
500            descriptor("D:\\Vault\\Rover\\Wheel\\Wheel.iam", false, "Rad", 3),
501            descriptor("D:\\Vault\\Rover\\Frame.ipt", false, "", 1),
502        ]));
503        data
504    }
505
506    #[test]
507    fn counts_from_the_first_descriptor_table() {
508        assert_eq!(
509            occurrence_counts(&stream()),
510            vec![
511                ("Wheel.iam".to_string(), 4),
512                ("Frame.ipt".to_string(), 1),
513                ("DIN 912 - M6 x 20.ipt".to_string(), 16),
514                // the same name from another folder is a file of its own
515                ("Frame.ipt".to_string(), 2),
516            ]
517        );
518    }
519
520    #[test]
521    fn broken_descriptors_are_not_counted() {
522        // padding not zero
523        let mut bad = descriptor("C:\\Rover\\Frame.ipt", false, "", 1);
524        let pad = bad.len() - 8 - 32 - 6; // first of the six zero bytes
525        if let Some(b) = bad.get_mut(pad) {
526            *b = 1;
527        }
528        assert!(occurrence_counts(&bad).is_empty());
529        // zero occurrences, a name without extension, no path at all
530        assert!(occurrence_counts(&descriptor("C:\\Rover\\Frame.ipt", false, "", 0)).is_empty());
531        assert!(occurrence_counts(&descriptor("C:\\Rover\\Frame", false, "", 1)).is_empty());
532        assert!(occurrence_counts(&descriptor("Frame.ipt", false, "", 1)).is_empty());
533        // cut off in the middle
534        let full = descriptor("C:\\Rover\\Frame.ipt", false, "", 1);
535        assert!(occurrence_counts(full.get(..full.len() - 3).unwrap()).is_empty());
536        assert!(occurrence_counts(&[]).is_empty());
537    }
538
539    #[test]
540    fn a_gap_ends_the_table() {
541        let mut data = descriptor("C:\\Rover\\Frame.ipt", false, "", 2);
542        data.extend([0u8; 11]); // one byte more than between descriptors
543        data.extend(descriptor("C:\\Rover\\Wheel.iam", false, "", 5));
544        assert_eq!(occurrence_counts(&data), vec![("Frame.ipt".to_string(), 2)]);
545    }
546
547    #[test]
548    fn reads_the_stream_of_a_compound_file() {
549        let mut cfb = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
550        cfb.create_storage("/RSeStorage").unwrap();
551        cfb.create_stream("/RSeStorage/RSeSegInfo")
552            .unwrap()
553            .write_all(b"x")
554            .unwrap();
555        cfb.create_stream(DESCRIPTOR_STREAM)
556            .unwrap()
557            .write_all(&stream())
558            .unwrap();
559        cfb.flush().unwrap();
560        let bytes = cfb.into_inner().into_inner();
561        let counts = file_occurrence_counts(Cursor::new(bytes), 1 << 20)
562            .unwrap()
563            .unwrap();
564        assert_eq!(counts.len(), 4);
565        assert_eq!(counts.first(), Some(&("Wheel.iam".to_string(), 4)));
566        // not a compound file, or one without the stream
567        assert_eq!(
568            file_occurrence_counts(Cursor::new(b"plain text".to_vec()), 1 << 20).unwrap(),
569            None
570        );
571        let mut other = cfb::CompoundFile::create(Cursor::new(Vec::new())).unwrap();
572        other
573            .create_stream("/Other")
574            .unwrap()
575            .write_all(b"x")
576            .unwrap();
577        other.flush().unwrap();
578        assert_eq!(
579            file_occurrence_counts(Cursor::new(other.into_inner().into_inner()), 1 << 20).unwrap(),
580            None
581        );
582    }
583}