Skip to main content

cadmpeg_codec_nx/
parasolid.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Extract and classify compressed streams in an NX part payload.
3//!
4//! [`extract_streams`] scans the canonical `/Root/UG_PART/UG_PART` file span for
5//! valid zlib headers. An inflated `PS 00 00` prologue identifies Parasolid
6//! neutral-binary data and supplies its subtype and optional `SCH_` schema token.
7//! Other inflated payloads are classified as [`StreamKind::Preview`].
8#![deny(clippy::disallowed_methods)]
9
10use std::io::Read;
11
12use cadmpeg_ir::codec::CodecError;
13use cadmpeg_ir::decode::{ByteRange, DecodeContext, ExpandSpec, View};
14use flate2::read::ZlibDecoder;
15
16use crate::container::Container;
17
18/// Classification of an inflated payload in the part stream.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum StreamKind {
21    /// A Parasolid `(partition)` body snapshot.
22    Partition,
23    /// A Parasolid `(deltas)` edit overlay.
24    Deltas,
25    /// A cached Parasolid body without a partition or deltas subtype.
26    Plain,
27    /// An inflated non-Parasolid payload, such as preview or metadata data.
28    Preview,
29}
30
31impl StreamKind {
32    /// Return the stable label used in summaries and reports.
33    pub fn label(self) -> &'static str {
34        match self {
35            StreamKind::Partition => "partition",
36            StreamKind::Deltas => "deltas",
37            StreamKind::Plain => "plain",
38            StreamKind::Preview => "preview",
39        }
40    }
41
42    /// Return whether this kind contains Parasolid neutral-binary records.
43    pub fn is_parasolid(self) -> bool {
44        !matches!(self, StreamKind::Preview)
45    }
46}
47
48/// A located and inflated stream from the canonical part payload.
49#[derive(Debug, Clone)]
50pub struct Stream {
51    /// Byte offset of the `78 01` zlib header in the source file.
52    pub file_offset: usize,
53    /// Compressed input bytes the decoder consumed at `file_offset`.
54    ///
55    /// The physical extent `[file_offset, file_offset + consumed)` in the source.
56    pub consumed: u64,
57    /// Inflated bytes.
58    pub inflated: Vec<u8>,
59    /// Payload classification.
60    pub kind: StreamKind,
61    /// The Parasolid `SCH_<version>` token, when present.
62    pub schema: Option<String>,
63}
64
65/// One Parasolid attribute-class declaration preceding its field record.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct AttributeDefinition<'a> {
68    /// Inflated-stream offset of the `00 4f` tag.
69    pub offset: usize,
70    /// Stream-local definition record identity.
71    pub xmt: u16,
72    /// Exact printable class name.
73    pub name: &'a str,
74    /// Declared number of fields in the following `00 50` record.
75    pub field_count: u32,
76    /// Stream-local identity of the field record.
77    pub field_record_xmt: u16,
78    /// Ordered catalog references in the field-record header.
79    pub field_record_references: [u16; 2],
80    /// Two field-record header words following the catalog references.
81    pub field_record_header_words: [u16; 2],
82    /// Exact 26-byte descriptor prefix following the field-record header.
83    pub field_descriptor_prefix: [u8; 26],
84    /// One serialized field code for every declared field.
85    pub field_codes: &'a [u8],
86}
87
88/// One framed type-81 Parasolid entity/attribute-list record.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Entity51Record {
91    /// Inflated-stream offset of the `00 51` tag.
92    pub offset: usize,
93    /// Exact framed record length.
94    pub byte_len: usize,
95    /// Record flags preceding the identity.
96    pub flags: u32,
97    /// Stream-local record identity.
98    pub xmt: u32,
99    /// Serialized sequence value.
100    pub sequence: u32,
101    /// Layout discriminator selecting the reference count.
102    pub discriminator: u16,
103    /// Ordered stream-local references.
104    pub references: Vec<u32>,
105}
106
107/// One self-framed printable type-84 string record.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Entity54StringRecord<'a> {
110    /// Inflated-stream offset of the `00 54` tag.
111    pub offset: usize,
112    /// Exact framed record length.
113    pub byte_len: usize,
114    /// Stream-local record identity.
115    pub xmt: u32,
116    /// Nonempty printable string value.
117    pub value: &'a str,
118}
119
120/// One counted type-82 unsigned-integer value record.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Entity52IntegerRecord {
123    /// Inflated-stream offset of the `00 52` tag.
124    pub offset: usize,
125    /// Exact framed record length.
126    pub byte_len: usize,
127    /// Stream-local record identity.
128    pub xmt: u32,
129    /// Ordered big-endian unsigned values.
130    pub values: Vec<u32>,
131}
132
133/// One counted type-83 binary64 value record.
134#[derive(Debug, Clone, PartialEq)]
135pub struct Entity53DoubleRecord {
136    /// Inflated-stream offset of the `00 53` tag.
137    pub offset: usize,
138    /// Exact framed record length.
139    pub byte_len: usize,
140    /// Stream-local record identity.
141    pub xmt: u32,
142    /// Ordered finite big-endian binary64 values.
143    pub values: Vec<f64>,
144}
145
146/// Decode counted type-82 unsigned-integer records.
147pub fn entity_52_integer_records(bytes: &[u8]) -> Vec<Entity52IntegerRecord> {
148    counted_value_records(bytes, 0x52, 4, |value| {
149        Some(u32::from_be_bytes(value.try_into().ok()?))
150    })
151    .into_iter()
152    .map(|record| Entity52IntegerRecord {
153        offset: record.offset,
154        byte_len: record.byte_len,
155        xmt: record.xmt,
156        values: record.values,
157    })
158    .collect()
159}
160
161/// Decode counted type-83 finite binary64 records.
162pub fn entity_53_double_records(bytes: &[u8]) -> Vec<Entity53DoubleRecord> {
163    counted_value_records(bytes, 0x53, 8, |value| {
164        let value = f64::from_be_bytes(value.try_into().ok()?);
165        value.is_finite().then_some(value)
166    })
167    .into_iter()
168    .map(|record| Entity53DoubleRecord {
169        offset: record.offset,
170        byte_len: record.byte_len,
171        xmt: record.xmt,
172        values: record.values,
173    })
174    .collect()
175}
176
177struct CountedValueRecord<T> {
178    offset: usize,
179    byte_len: usize,
180    xmt: u32,
181    values: Vec<T>,
182}
183
184fn counted_value_records<T>(
185    bytes: &[u8],
186    tag: u8,
187    value_width: usize,
188    decode: impl Fn(&[u8]) -> Option<T>,
189) -> Vec<CountedValueRecord<T>> {
190    let mut records = Vec::new();
191    for offset in 0..bytes.len().saturating_sub(10) {
192        if bytes.get(offset..offset + 2) != Some(&[0, tag]) {
193            continue;
194        }
195        let mut at = offset + 2;
196        if bytes.get(at) == Some(&0xff) {
197            at += 1;
198        }
199        let Some(count) = bytes
200            .get(at..at + 4)
201            .map(|value| u32::from_be_bytes(value.try_into().expect("four bytes")) as usize)
202            .filter(|count| *count > 0)
203        else {
204            continue;
205        };
206        at += 4;
207        let Some(xmt) = read_xmt(bytes, &mut at).filter(|xmt| *xmt > 1) else {
208            continue;
209        };
210        let Some(values_end) = count
211            .checked_mul(value_width)
212            .and_then(|length| at.checked_add(length))
213        else {
214            continue;
215        };
216        let Some(value_bytes) = bytes.get(at..values_end) else {
217            continue;
218        };
219        let Some(values) = value_bytes
220            .chunks_exact(value_width)
221            .map(&decode)
222            .collect::<Option<Vec<_>>>()
223        else {
224            continue;
225        };
226        records.push(CountedValueRecord {
227            offset,
228            byte_len: values_end - offset,
229            xmt,
230            values,
231        });
232    }
233    records
234}
235
236/// Decode self-framed printable type-84 string records.
237pub fn entity_54_string_records(bytes: &[u8]) -> Vec<Entity54StringRecord<'_>> {
238    let mut records = Vec::new();
239    for offset in 0..bytes.len().saturating_sub(10) {
240        if bytes.get(offset..offset + 2) != Some(&[0x00, 0x54]) {
241            continue;
242        }
243        let mut at = offset + 2;
244        if bytes.get(at) == Some(&0xff) {
245            at += 1;
246        }
247        let Some(length) = bytes
248            .get(at..at + 4)
249            .map(|value| u32::from_be_bytes(value.try_into().expect("four bytes")) as usize)
250            .filter(|length| *length > 0)
251        else {
252            continue;
253        };
254        at += 4;
255        let Some(xmt) = read_xmt(bytes, &mut at).filter(|xmt| *xmt > 1) else {
256            continue;
257        };
258        let Some(end) = at.checked_add(length) else {
259            continue;
260        };
261        let Some(value) = bytes.get(at..end).filter(|value| {
262            value
263                .iter()
264                .all(|byte| byte.is_ascii_graphic() || *byte == b' ')
265        }) else {
266            continue;
267        };
268        if bytes.get(end) != Some(&0) {
269            continue;
270        }
271        let Ok(value) = std::str::from_utf8(value) else {
272            continue;
273        };
274        records.push(Entity54StringRecord {
275            offset,
276            byte_len: end + 1 - offset,
277            xmt,
278            value,
279        });
280    }
281    records
282}
283
284/// Decode framed type-81 entity/attribute-list records.
285pub fn entity_51_records(bytes: &[u8]) -> Vec<Entity51Record> {
286    let mut records = Vec::new();
287    for offset in 0..bytes.len().saturating_sub(25) {
288        if bytes.get(offset..offset + 2) != Some(&[0x00, 0x51]) {
289            continue;
290        }
291        let mut at = offset + 2;
292        if bytes.get(at) == Some(&0xff) {
293            at += 1;
294        }
295        let Some(flags) = bytes
296            .get(at..at + 4)
297            .map(|value| u32::from_be_bytes(value.try_into().expect("four bytes")))
298        else {
299            continue;
300        };
301        at += 4;
302        let Some(xmt) = read_xmt(bytes, &mut at) else {
303            continue;
304        };
305        let Some(sequence) = bytes
306            .get(at..at + 4)
307            .map(|value| u32::from_be_bytes(value.try_into().expect("four bytes")))
308        else {
309            continue;
310        };
311        at += 4;
312        let Some(discriminator) = bytes
313            .get(at..at + 2)
314            .map(|value| u16::from_be_bytes(value.try_into().expect("two bytes")))
315        else {
316            continue;
317        };
318        at += 2;
319        let low_flag = (flags & 0xff) as u8;
320        if xmt <= 1 || sequence == 0 || !(1..=0x20).contains(&low_flag) {
321            continue;
322        }
323        let reference_count = match (discriminator, low_flag) {
324            (0x0018 | 0x0020 | 0x0025, 1) => 6,
325            (0x001d | 0x001e, 2) => 7,
326            (0x0020 | 0x0024 | 0x0027, 4) => 9,
327            _ => 6,
328        };
329        let Some(references) = entity_51_references(bytes, &mut at, reference_count) else {
330            continue;
331        };
332        records.push(Entity51Record {
333            offset,
334            byte_len: at - offset,
335            flags,
336            xmt,
337            sequence,
338            discriminator,
339            references,
340        });
341    }
342    records
343}
344
345fn entity_51_references(bytes: &[u8], at: &mut usize, count: usize) -> Option<Vec<u32>> {
346    let start = *at;
347    if bytes.get(*at) == Some(&1) {
348        let mut prefixed_at = *at;
349        let mut references = Vec::new();
350        for _ in 0..count {
351            if bytes.get(prefixed_at) != Some(&1) {
352                references.clear();
353                break;
354            }
355            prefixed_at += 1;
356            references.push(read_xmt(bytes, &mut prefixed_at)?);
357        }
358        if references.len() == count && bytes.get(prefixed_at) == Some(&0) {
359            *at = prefixed_at + 1;
360            return Some(references);
361        }
362    }
363    *at = start;
364    (0..count).map(|_| read_xmt(bytes, at)).collect()
365}
366
367fn read_xmt(bytes: &[u8], at: &mut usize) -> Option<u32> {
368    let first = i16::from_be_bytes([*bytes.get(*at)?, *bytes.get(*at + 1)?]);
369    *at += 2;
370    if first >= 0 {
371        return Some(first as u32);
372    }
373    let quotient = u16::from_be_bytes([*bytes.get(*at)?, *bytes.get(*at + 1)?]);
374    *at += 2;
375    Some(u32::from(quotient) * 32_767 + u32::from(first.unsigned_abs()))
376}
377
378/// Decode length-bounded `00 4f` attribute-class declarations.
379pub fn attribute_definitions(bytes: &[u8]) -> Vec<AttributeDefinition<'_>> {
380    let mut definitions = Vec::new();
381    let mut at = 0;
382    while at + 9 <= bytes.len() {
383        if bytes.get(at..at + 2) != Some(&[0x00, 0x4f]) {
384            at += 1;
385            continue;
386        }
387        let escaped = bytes.get(at + 2) == Some(&0xff);
388        let header = at + 2 + usize::from(escaped);
389        let Some(length_bytes) = bytes.get(header..header + 4) else {
390            break;
391        };
392        let name_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
393        let Some(identity) = bytes.get(header + 4..header + 6) else {
394            break;
395        };
396        let name_start = header + 6;
397        let Some(name_end) = name_start.checked_add(name_len) else {
398            at += 1;
399            continue;
400        };
401        let Some(name_bytes) = bytes.get(name_start..name_end) else {
402            at += 1;
403            continue;
404        };
405        let Some(field_header) = bytes.get(name_end..name_end + 16) else {
406            at += 1;
407            continue;
408        };
409        if name_len == 0
410            || !name_bytes
411                .iter()
412                .all(|byte| byte.is_ascii_graphic() && !byte.is_ascii_control())
413            || field_header.get(0..2) != Some(&[0x00, 0x50])
414        {
415            at += 1;
416            continue;
417        }
418        let Ok(name) = std::str::from_utf8(name_bytes) else {
419            at += 1;
420            continue;
421        };
422        let field_count = u32::from_be_bytes(field_header[2..6].try_into().expect("four bytes"));
423        let descriptor_start = name_end + 16;
424        let Some(descriptor_end) = descriptor_start.checked_add(26) else {
425            at += 1;
426            continue;
427        };
428        let Some(field_codes_end) = descriptor_end.checked_add(field_count as usize) else {
429            at += 1;
430            continue;
431        };
432        let Some(field_descriptor_prefix) = bytes
433            .get(descriptor_start..descriptor_end)
434            .and_then(|value| value.try_into().ok())
435        else {
436            at += 1;
437            continue;
438        };
439        let Some(field_codes) = bytes.get(descriptor_end..field_codes_end) else {
440            at += 1;
441            continue;
442        };
443        definitions.push(AttributeDefinition {
444            offset: at,
445            xmt: u16::from_be_bytes([identity[0], identity[1]]),
446            name,
447            field_count,
448            field_record_xmt: u16::from_be_bytes(field_header[6..8].try_into().expect("two bytes")),
449            field_record_references: [
450                u16::from_be_bytes(field_header[8..10].try_into().expect("two bytes")),
451                u16::from_be_bytes(field_header[10..12].try_into().expect("two bytes")),
452            ],
453            field_record_header_words: [
454                u16::from_be_bytes(field_header[12..14].try_into().expect("two bytes")),
455                u16::from_be_bytes(field_header[14..16].try_into().expect("two bytes")),
456            ],
457            field_descriptor_prefix,
458            field_codes,
459        });
460        at = field_codes_end;
461    }
462    definitions
463}
464
465/// The minimum inflated length for a candidate to count as a real stream; below
466/// this a `78 01` match is almost certainly a coincidence in packed data.
467const MIN_INFLATED: usize = 64;
468
469/// Chunk size for streaming inflated output through the expander.
470const INFLATE_CHUNK: usize = 8192;
471
472/// Locates, inflates, and classifies zlib streams in `/Root/UG_PART/UG_PART`.
473pub fn extract_streams<'a>(
474    ctx: &DecodeContext<'a>,
475    root: View<'a>,
476    container: &Container,
477) -> Result<Vec<Stream>, CodecError> {
478    let Some((part_offset, part_size)) = container
479        .entries
480        .iter()
481        .find(|entry| entry.name == "/Root/UG_PART/UG_PART")
482        .and_then(|entry| entry.file_span)
483    else {
484        return Ok(Vec::new());
485    };
486    let (Ok(start), Ok(size)) = (usize::try_from(part_offset), usize::try_from(part_size)) else {
487        return Ok(Vec::new());
488    };
489    let Some(end) = start.checked_add(size) else {
490        return Ok(Vec::new());
491    };
492    let part_view = ctx.register_slice(
493        root,
494        ByteRange {
495            start: start as u64,
496            end: end as u64,
497        },
498    )?;
499    let part = part_view.window();
500
501    let mut streams = Vec::new();
502    let mut i = 0usize;
503    while i + 2 <= part.len() {
504        if is_zlib_header(part[i], part[i + 1]) {
505            if let Some((inflated, consumed)) = inflate_stream(ctx, part_view, i)? {
506                let (kind, schema) = classify(&inflated);
507                streams.push(Stream {
508                    file_offset: start + i,
509                    consumed,
510                    inflated,
511                    kind,
512                    schema,
513                });
514                // Resume past the bytes this member consumed, not at the next
515                // byte: a spurious `78 xx` zlib header inside the compressed
516                // body would otherwise inflate into a second stream whose source
517                // extent [file_offset, file_offset+consumed) overlaps this
518                // member's, double-attributing the same compressed bytes to two
519                // decompression origins. Skipping the consumed run keeps packed
520                // members' input extents disjoint.
521                i = i.saturating_add((consumed as usize).max(2));
522                continue;
523            }
524        }
525        i += 1;
526    }
527    Ok(streams)
528}
529
530/// Inflates one zlib member that meets [`MIN_INFLATED`].
531fn inflate_stream<'a>(
532    ctx: &DecodeContext<'a>,
533    part_view: View<'a>,
534    offset: usize,
535) -> Result<Option<(Vec<u8>, u64)>, CodecError> {
536    let Some(source) = part_view.child(offset, part_view.end()) else {
537        return Ok(None);
538    };
539    let mut decoder = ZlibDecoder::new(source.window());
540    let mut writer = ctx.begin_expand(source, ExpandSpec::Unknown)?;
541    let mut inflated = Vec::new();
542    let mut chunk = [0u8; INFLATE_CHUNK];
543    loop {
544        match decoder.read(&mut chunk) {
545            Ok(0) => break,
546            Ok(read) => {
547                writer.write(&chunk[..read])?;
548                inflated.extend_from_slice(&chunk[..read]);
549            }
550            Err(_) => break,
551        }
552    }
553    if inflated.len() < MIN_INFLATED {
554        return Ok(None);
555    }
556    let consumed = decoder.total_in();
557    writer.finalize()?;
558    Ok(Some((inflated, consumed)))
559}
560
561/// A zlib header has compression method 8 and a 16-bit header divisible by
562/// 31. NX uses the standard `78 01`, `78 9c`, and `78 da` variants, but the
563/// predicate accepts every standards-conforming FLG byte rather than treating a
564/// compression level as a format discriminator.
565#[allow(clippy::manual_is_multiple_of)] // `is_multiple_of` exceeds the workspace MSRV.
566fn is_zlib_header(cmf: u8, flg: u8) -> bool {
567    cmf & 0x0f == 8 && cmf >> 4 <= 7 && u16::from_be_bytes([cmf, flg]).is_multiple_of(31)
568}
569
570/// Classify an inflated payload from its prologue text and read the schema token.
571fn classify(inflated: &[u8]) -> (StreamKind, Option<String>) {
572    if !inflated.starts_with(b"PS\x00\x00") {
573        return (StreamKind::Preview, None);
574    }
575    let window = &inflated[..inflated.len().min(512)];
576    let kind = if contains(window, b"(partition)") {
577        StreamKind::Partition
578    } else if contains(window, b"(deltas)") {
579        StreamKind::Deltas
580    } else {
581        StreamKind::Plain
582    };
583    (kind, read_schema(window))
584}
585
586/// Read a `SCH_<...>` schema token: the `SCH_` prefix followed by the run of
587/// token characters (alphanumeric and `_`).
588fn read_schema(window: &[u8]) -> Option<String> {
589    let pos = find(window, b"SCH_")?;
590    let mut end = pos;
591    while end < window.len() && (window[end].is_ascii_alphanumeric() || window[end] == b'_') {
592        end += 1;
593    }
594    Some(String::from_utf8_lossy(&window[pos..end]).into_owned())
595}
596
597fn contains(haystack: &[u8], needle: &[u8]) -> bool {
598    find(haystack, needle).is_some()
599}
600
601fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
602    if needle.is_empty() || haystack.len() < needle.len() {
603        return None;
604    }
605    haystack.windows(needle.len()).position(|w| w == needle)
606}